@anchrd/intel-ui 0.4.0 → 0.5.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 (36) hide show
  1. package/package.json +1 -1
  2. package/src/app/action-slot/action-slot.tsx +23 -0
  3. package/src/app/app-sidebar/app-sidebar.tsx +39 -24
  4. package/src/app/app-tree/app-tree.tsx +431 -60
  5. package/src/app/app.tsx +17 -2
  6. package/src/app/sidebar-resize-handle/sidebar-resize-handle.tsx +2 -1
  7. package/src/app/tree-move/tree-move.tsx +197 -0
  8. package/src/app/user-footer/user-footer.tsx +73 -46
  9. package/src/app/view-toggle/view-toggle.tsx +77 -0
  10. package/src/blocknote-view/blocknote-view.tsx +19 -2
  11. package/src/branding/favicon.default.svg +2 -2
  12. package/src/branding/favicon.svg +2 -2
  13. package/src/components/ui/dropdown-menu.tsx +78 -0
  14. package/src/data/intel-data-provider/intel-data-provider.ts +120 -54
  15. package/src/data/intel-data-provider/intel-data-provider.types.ts +47 -12
  16. package/src/document-link/document-link.tsx +132 -0
  17. package/src/flow-runs/flow-runs.tsx +225 -0
  18. package/src/flows/flows.tsx +670 -271
  19. package/src/flows/node-icon/node-icon.ts +28 -0
  20. package/src/flows/node-palette/node-palette.tsx +174 -0
  21. package/src/flows/node-palette/node-palette.types.ts +15 -0
  22. package/src/graph-pane/graph-pane.tsx +44 -0
  23. package/src/i18n/en.json +141 -24
  24. package/src/knowledge/knowledge.tsx +69 -355
  25. package/src/knowledge-editor/knowledge-editor.tsx +169 -21
  26. package/src/knowledge-graph/knowledge-graph.ts +26 -24
  27. package/src/knowledge-graph/knowledge-graph.tsx +33 -24
  28. package/src/knowledge-table/knowledge-table.tsx +129 -0
  29. package/src/main.tsx +2 -2
  30. package/src/resource-menu/resource-menu.tsx +580 -0
  31. package/src/router/selection-search.ts +27 -3
  32. package/src/save-button/save-button.tsx +103 -0
  33. package/src/styles.css +37 -0
  34. package/src/theme/theme.ts +24 -0
  35. package/src/tools/tools.tsx +3 -3
  36. package/src/app/header-actions/header-actions.tsx +0 -15
@@ -1,33 +1,20 @@
1
- import type {
2
- ContextPolicy,
3
- KnowledgeLinkRelation,
4
- KnowledgeNode,
5
- ResourceRole,
6
- } from "@anchrd/intel-contract";
1
+ import type { KnowledgeNode } from "@anchrd/intel-contract";
7
2
  import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
8
3
  import { useNavigate, useRouterState } from "@tanstack/react-router";
9
- import {
10
- Archive,
11
- Download,
12
- History,
13
- Link2,
14
- Network,
15
- Paperclip,
16
- Share2,
17
- Trash2,
18
- } from "lucide-react";
4
+ import { Download, History, Paperclip } from "lucide-react";
19
5
  import { lazy, Suspense, useState } from "react";
20
- import { HeaderActions } from "@/app/header-actions/header-actions.tsx";
21
- import { Modal } from "@/modal/modal.tsx";
6
+ import { ActionSlot } from "@/app/action-slot/action-slot.tsx";
7
+ import { ViewToggle } from "@/app/view-toggle/view-toggle.tsx";
8
+ import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip";
9
+ import { GraphPane } from "@/graph-pane/graph-pane.tsx";
10
+ import { KnowledgeTablePanel } from "@/knowledge-table/knowledge-table.tsx";
11
+ import { ResourceMenu } from "@/resource-menu/resource-menu.tsx";
22
12
  import { useIntelRouterContext } from "@/router/router-context.ts";
23
- import { selectedFrom } from "@/router/selection-search.ts";
13
+ import { graphViewFrom, selectedFrom } from "@/router/selection-search.ts";
24
14
 
25
15
  const KnowledgeEditor = lazy(async () => ({
26
16
  default: (await import("@/knowledge-editor/knowledge-editor.tsx")).KnowledgeEditor,
27
17
  }));
28
- const KnowledgeGraphView = lazy(async () => ({
29
- default: (await import("@/knowledge-graph/knowledge-graph.tsx")).KnowledgeGraphView,
30
- }));
31
18
 
32
19
  export function Knowledge() {
33
20
  const { data, i18n } = useIntelRouterContext();
@@ -35,73 +22,52 @@ export function Knowledge() {
35
22
  const navigate = useNavigate();
36
23
  // The tree in the sidebar and the header search both name what to open through `?select=`, so this
37
24
  // screen has no selection of its own to keep in step with them.
38
- const selectedId = useRouterState({ select: (state) => selectedFrom(state.location.search) });
39
- const [sharing, setSharing] = useState(false);
40
- const [linksOpen, setLinksOpen] = useState(false);
41
- const [graphOpen, setGraphOpen] = useState(false);
42
- const [versionsOpen, setVersionsOpen] = useState(false);
43
- const graph = useQuery({
44
- queryKey: ["knowledge-graph"],
45
- queryFn: () => data.getKnowledgeGraph(),
46
- // The link dialog needs candidates to link to, and the graph is the one authorized list of
47
- // nodes the UI can ask for without walking the whole tree again.
48
- enabled: graphOpen || linksOpen,
25
+ const selection = useRouterState({
26
+ select: (state) => ({
27
+ id: selectedFrom(state.location.search),
28
+ graph: graphViewFrom(state.location.search),
29
+ }),
49
30
  });
31
+ const selectedId = selection.id;
32
+ const [versionsOpen, setVersionsOpen] = useState(false);
50
33
  const document = useQuery({
51
34
  queryKey: ["knowledge", selectedId],
52
35
  queryFn: () => data.getKnowledge(selectedId ?? ""),
53
36
  enabled: Boolean(selectedId),
54
37
  });
55
38
  const selected = document.data?.node ?? null;
56
- const archive = useMutation({
57
- mutationFn: (node: KnowledgeNode) =>
58
- data.archiveKnowledge({
59
- nodeId: node.id,
60
- baseUpdatedAt: node.updatedAt,
61
- archived: true,
62
- idempotencyKey: crypto.randomUUID(),
63
- }),
64
- onSuccess: async (node) => {
65
- // The row leaves the tree, so the level it sat in is what has to be re-read — and the screen
66
- // has nothing left to show.
67
- await queryClient.invalidateQueries({ queryKey: ["tree", node.parentId] });
68
- await navigate({ to: "/knowledge", search: {} });
69
- },
70
- });
71
- const setContextPolicy = useMutation({
72
- mutationFn: ({ node, contextPolicy }: { node: KnowledgeNode; contextPolicy: ContextPolicy }) =>
73
- data.updateKnowledge({
74
- nodeId: node.id,
75
- baseUpdatedAt: node.updatedAt,
76
- contextPolicy,
77
- idempotencyKey: crypto.randomUUID(),
78
- }),
79
- onSuccess: async (node) => {
80
- await queryClient.invalidateQueries({ queryKey: ["knowledge", node.id] });
81
- },
39
+ // A folder shows its contents as a graph, and so does the root of the tree (#19). A document has
40
+ // nothing to draw — which is why the switch is not offered on one rather than offered and empty.
41
+ const graphable = selectedId === null || selected?.kind === "folder";
42
+ const relations = useQuery({
43
+ queryKey: ["relation-graph", "folder", selectedId],
44
+ queryFn: () => data.getRelationGraph({ of: "folder", folderId: selectedId }),
45
+ enabled: graphable && selection.graph,
82
46
  });
83
-
84
47
  return (
85
48
  <div className="flex h-full min-h-0 flex-col">
86
49
  {/* The screen owns the one action that belongs to Knowledge as a whole. Creating things is
87
- the tree's, in the sidebar, at the place a new thing is meant to go. */}
88
- <HeaderActions>
89
- <button
90
- type="button"
91
- onClick={() => setGraphOpen(true)}
92
- className="inline-flex h-8 items-center gap-2 rounded-md border bg-background px-2.5 text-sm outline-none hover:bg-accent focus-visible:ring-2 focus-visible:ring-ring"
93
- >
94
- <Network aria-hidden="true" className="size-4" />
95
- {i18n.t("knowledge.graph")}
96
- </button>
97
- </HeaderActions>
98
- {archive.isError || setContextPolicy.isError ? (
99
- <p role="alert" className="border-b px-8 py-3 text-sm text-destructive">
100
- {i18n.t("knowledge.operationFailed")}
101
- </p>
50
+ the tree's, in the sidebar, at the place a new thing is meant to go.
51
+
52
+ ⚠️ Mounted only where there is a graph to switch to. A switch on a document would point at
53
+ nothing, and the header is where that gets noticed first (#19). */}
54
+ {graphable ? (
55
+ <ActionSlot>
56
+ <ViewToggle />
57
+ </ActionSlot>
102
58
  ) : null}
103
59
  <section className="relative flex min-h-0 min-w-0 flex-1 flex-col bg-card">
104
- {!selectedId ? (
60
+ {graphable && selection.graph ? (
61
+ <GraphPane
62
+ query={relations}
63
+ select={(node) =>
64
+ void navigate({
65
+ to: node.kind === "flow" ? "/flows" : "/knowledge",
66
+ search: { select: node.id },
67
+ })
68
+ }
69
+ />
70
+ ) : !selectedId ? (
105
71
  <div className="grid flex-1 place-items-center p-8 text-center text-sm text-muted-foreground">
106
72
  {i18n.t("knowledge.select")}
107
73
  </div>
@@ -122,6 +88,14 @@ export function Knowledge() {
122
88
  </div>
123
89
  ) : (
124
90
  <>
91
+ {/* The document's own line: its name, and beside it only what acts on this one
92
+ document. What the four loose icons used to do is now one menu, the same one the
93
+ tree row carries — the point of #24 was that there be one place per action, not a
94
+ second row of them here. Saving joins it from the strip it used to own below
95
+ (`title-actions`), so the editor starts one screen row higher than it did.
96
+ ⚠️ The view switch is deliberately NOT here: it changes how the current area is
97
+ shown, not the document, and it stays beside the breadcrumb where the area is
98
+ named. */}
125
99
  <div className="flex items-start justify-between gap-5 border-b px-6 py-4">
126
100
  <div className="min-w-0">
127
101
  <h2 className="truncate text-lg font-semibold">{selected.title}</h2>
@@ -129,57 +103,25 @@ export function Knowledge() {
129
103
  <p className="mt-1 text-sm text-muted-foreground">{selected.description}</p>
130
104
  )}
131
105
  </div>
132
- <div className="flex shrink-0 items-center gap-1">
133
- <select
134
- value={selected.contextPolicy}
135
- disabled={setContextPolicy.isPending}
136
- onChange={(event) =>
137
- setContextPolicy.mutate({
138
- node: selected,
139
- contextPolicy: event.target.value as ContextPolicy,
140
- })
141
- }
142
- aria-label={i18n.t("knowledge.contextPolicy")}
143
- className="mr-2 rounded-md border bg-background px-2 py-1.5 text-xs outline-none focus-visible:ring-2 focus-visible:ring-ring disabled:opacity-50"
144
- >
145
- <option value="pinned">{i18n.t("knowledge.context.pinned")}</option>
146
- <option value="relevant">{i18n.t("knowledge.context.relevant")}</option>
147
- <option value="explicit">{i18n.t("knowledge.context.explicit")}</option>
148
- </select>
149
- <button
150
- type="button"
151
- onClick={() => setLinksOpen(true)}
152
- aria-label={i18n.t("knowledge.links")}
153
- className="rounded-md p-2 outline-none hover:bg-muted focus-visible:ring-2 focus-visible:ring-ring"
154
- >
155
- <Link2 aria-hidden="true" className="size-4" />
156
- </button>
157
- <button
158
- type="button"
159
- onClick={() => setSharing(true)}
160
- aria-label={i18n.t("knowledge.share")}
161
- className="rounded-md p-2 outline-none hover:bg-muted focus-visible:ring-2 focus-visible:ring-ring"
162
- >
163
- <Share2 aria-hidden="true" className="size-4" />
164
- </button>
106
+ <div className="flex shrink-0 items-center gap-2">
165
107
  {selected.kind !== "folder" && (
166
- <button
167
- type="button"
168
- onClick={() => setVersionsOpen((value) => !value)}
169
- aria-label={i18n.t("knowledge.versions")}
170
- className="rounded-md p-2 outline-none hover:bg-muted focus-visible:ring-2 focus-visible:ring-ring"
171
- >
172
- <History aria-hidden="true" className="size-4" />
173
- </button>
108
+ <TooltipProvider delayDuration={300}>
109
+ <Tooltip>
110
+ <TooltipTrigger
111
+ type="button"
112
+ onClick={() => setVersionsOpen((value) => !value)}
113
+ aria-label={i18n.t("knowledge.versions")}
114
+ aria-expanded={versionsOpen}
115
+ 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"
116
+ >
117
+ <History aria-hidden="true" className="size-4" />
118
+ </TooltipTrigger>
119
+ <TooltipContent>{i18n.t("knowledge.versions")}</TooltipContent>
120
+ </Tooltip>
121
+ </TooltipProvider>
174
122
  )}
175
- <button
176
- type="button"
177
- onClick={() => archive.mutate(selected)}
178
- aria-label={i18n.t("knowledge.archive")}
179
- className="rounded-md p-2 text-destructive outline-none hover:bg-muted focus-visible:ring-2 focus-visible:ring-ring"
180
- >
181
- <Archive aria-hidden="true" className="size-4" />
182
- </button>
123
+ <ResourceMenu target={{ type: "knowledge", node: selected }} variant="title" />
124
+ <div data-slot="title-actions" className="flex items-center gap-2" />
183
125
  </div>
184
126
  </div>
185
127
  {selected.kind === "folder" ? (
@@ -188,6 +130,8 @@ export function Knowledge() {
188
130
  </div>
189
131
  ) : selected.kind === "attachment" ? (
190
132
  <AttachmentPanel node={selected} />
133
+ ) : selected.kind === "table" ? (
134
+ <KnowledgeTablePanel node={selected} />
191
135
  ) : document.data ? (
192
136
  <Suspense
193
137
  fallback={
@@ -211,43 +155,6 @@ export function Knowledge() {
211
155
  </>
212
156
  )}
213
157
  </section>
214
- {sharing && selected && <ShareKnowledge node={selected} close={() => setSharing(false)} />}
215
- {linksOpen && selected && (
216
- <KnowledgeLinks
217
- node={selected}
218
- nodes={graph.data?.nodes ?? []}
219
- close={() => setLinksOpen(false)}
220
- />
221
- )}
222
- {graphOpen && !graph.data && (
223
- <Modal title={i18n.t("knowledge.graph")} close={() => setGraphOpen(false)}>
224
- <p role={graph.isError ? "alert" : undefined} className="text-sm text-muted-foreground">
225
- {graph.isError ? i18n.t("knowledge.graphFailed") : i18n.t("common.loading")}
226
- </p>
227
- {graph.isError && (
228
- <button
229
- type="button"
230
- onClick={() => void graph.refetch()}
231
- className="mt-4 rounded-md border px-3 py-2 text-sm outline-none hover:bg-muted focus-visible:ring-2 focus-visible:ring-ring"
232
- >
233
- {i18n.t("common.retry")}
234
- </button>
235
- )}
236
- </Modal>
237
- )}
238
- {graphOpen && graph.data && (
239
- <Suspense fallback={null}>
240
- <KnowledgeGraphView
241
- data={graph.data}
242
- i18n={i18n}
243
- close={() => setGraphOpen(false)}
244
- select={(nodeId) => {
245
- setGraphOpen(false);
246
- void navigate({ to: "/knowledge", search: { select: nodeId } });
247
- }}
248
- />
249
- </Suspense>
250
- )}
251
158
  </div>
252
159
  );
253
160
  }
@@ -292,199 +199,6 @@ function AttachmentPanel({ node }: { node: KnowledgeNode }) {
292
199
  );
293
200
  }
294
201
 
295
- function KnowledgeLinks({
296
- node,
297
- nodes,
298
- close,
299
- }: {
300
- node: KnowledgeNode;
301
- nodes: KnowledgeNode[];
302
- close(): void;
303
- }) {
304
- const { data, i18n } = useIntelRouterContext();
305
- const queryClient = useQueryClient();
306
- const [targetNodeId, setTargetNodeId] = useState("");
307
- const [relation, setRelation] = useState<KnowledgeLinkRelation>("related");
308
- const links = useQuery({
309
- queryKey: ["knowledge-links", node.id],
310
- queryFn: () => data.listKnowledgeLinks(node.id),
311
- });
312
- const create = useMutation({
313
- mutationFn: () =>
314
- data.createKnowledgeLink({
315
- sourceNodeId: node.id,
316
- targetNodeId,
317
- relation,
318
- label: null,
319
- idempotencyKey: crypto.randomUUID(),
320
- }),
321
- onSuccess: async () => {
322
- setTargetNodeId("");
323
- await Promise.all([
324
- queryClient.invalidateQueries({ queryKey: ["knowledge-links", node.id] }),
325
- queryClient.invalidateQueries({ queryKey: ["knowledge-graph"] }),
326
- ]);
327
- },
328
- });
329
- const remove = useMutation({
330
- mutationFn: (linkId: string) =>
331
- data.deleteKnowledgeLink({
332
- sourceNodeId: node.id,
333
- linkId,
334
- idempotencyKey: crypto.randomUUID(),
335
- }),
336
- onSuccess: async () => {
337
- await Promise.all([
338
- queryClient.invalidateQueries({ queryKey: ["knowledge-links", node.id] }),
339
- queryClient.invalidateQueries({ queryKey: ["knowledge-graph"] }),
340
- ]);
341
- },
342
- });
343
- const titles = new Map(nodes.map((candidate) => [candidate.id, candidate.title]));
344
-
345
- return (
346
- <Modal title={i18n.t("knowledge.links")} close={close}>
347
- <ul className="mb-5 max-h-48 space-y-2 overflow-y-auto">
348
- {links.data?.items.map((link) => {
349
- const outgoing = link.sourceNodeId === node.id;
350
- const otherId = outgoing ? link.targetNodeId : link.sourceNodeId;
351
- return (
352
- <li
353
- key={link.id}
354
- className="flex items-center justify-between gap-3 rounded-md border p-3"
355
- >
356
- <div className="min-w-0 text-sm">
357
- <span className="block truncate font-medium">{titles.get(otherId) ?? otherId}</span>
358
- <span className="text-xs text-muted-foreground">
359
- {outgoing ? "→" : "←"} {link.label ?? link.relation.replaceAll("_", " ")}
360
- </span>
361
- </div>
362
- {outgoing && (
363
- <button
364
- type="button"
365
- onClick={() => remove.mutate(link.id)}
366
- aria-label={i18n.t("knowledge.deleteLink")}
367
- className="rounded-md p-2 text-destructive outline-none hover:bg-muted focus-visible:ring-2 focus-visible:ring-ring"
368
- >
369
- <Trash2 aria-hidden="true" className="size-4" />
370
- </button>
371
- )}
372
- </li>
373
- );
374
- })}
375
- {links.data?.items.length === 0 && (
376
- <li className="text-sm text-muted-foreground">{i18n.t("knowledge.noLinks")}</li>
377
- )}
378
- </ul>
379
- <form
380
- className="space-y-4 border-t pt-5"
381
- onSubmit={(event) => {
382
- event.preventDefault();
383
- create.mutate();
384
- }}
385
- >
386
- <label className="block text-sm font-medium">
387
- {i18n.t("knowledge.linkTarget")}
388
- <select
389
- required
390
- value={targetNodeId}
391
- onChange={(event) => setTargetNodeId(event.target.value)}
392
- className="mt-2 w-full rounded-md border bg-background px-3 py-2 outline-none focus-visible:ring-2 focus-visible:ring-ring"
393
- >
394
- <option value="">{i18n.t("knowledge.selectLinkTarget")}</option>
395
- {nodes
396
- .filter((candidate) => candidate.id !== node.id)
397
- .map((candidate) => (
398
- <option key={candidate.id} value={candidate.id}>
399
- {candidate.title}
400
- </option>
401
- ))}
402
- </select>
403
- </label>
404
- <label className="block text-sm font-medium">
405
- {i18n.t("knowledge.relation")}
406
- <select
407
- value={relation}
408
- onChange={(event) => setRelation(event.target.value as KnowledgeLinkRelation)}
409
- className="mt-2 w-full rounded-md border bg-background px-3 py-2 outline-none focus-visible:ring-2 focus-visible:ring-ring"
410
- >
411
- {(["related", "references", "depends_on", "implements"] as const).map((value) => (
412
- <option key={value} value={value}>
413
- {i18n.t(`knowledge.relation.${value}`)}
414
- </option>
415
- ))}
416
- </select>
417
- </label>
418
- <button
419
- type="submit"
420
- disabled={!targetNodeId || create.isPending}
421
- 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"
422
- >
423
- {i18n.t("knowledge.createLink")}
424
- </button>
425
- </form>
426
- </Modal>
427
- );
428
- }
429
-
430
- function ShareKnowledge({ node, close }: { node: KnowledgeNode; close(): void }) {
431
- const { data, i18n } = useIntelRouterContext();
432
- const [email, setEmail] = useState("");
433
- const [role, setRole] = useState<ResourceRole>("viewer");
434
- const mutation = useMutation({
435
- mutationFn: () =>
436
- data.shareKnowledge({
437
- resourceId: node.id,
438
- principal: { type: "email", email },
439
- role,
440
- expiresAt: null,
441
- idempotencyKey: crypto.randomUUID(),
442
- }),
443
- onSuccess: close,
444
- });
445
- return (
446
- <Modal title={i18n.t("knowledge.share")} close={close}>
447
- <form
448
- onSubmit={(event) => {
449
- event.preventDefault();
450
- mutation.mutate();
451
- }}
452
- className="space-y-4"
453
- >
454
- <label className="block text-sm font-medium">
455
- {i18n.t("knowledge.email")}
456
- <input
457
- type="email"
458
- required
459
- value={email}
460
- onChange={(event) => setEmail(event.target.value)}
461
- className="mt-2 w-full rounded-md border bg-background px-3 py-2 outline-none focus-visible:ring-2 focus-visible:ring-ring"
462
- />
463
- </label>
464
- <label className="block text-sm font-medium">
465
- {i18n.t("knowledge.role")}
466
- <select
467
- value={role}
468
- onChange={(event) => setRole(event.target.value as ResourceRole)}
469
- className="mt-2 w-full rounded-md border bg-background px-3 py-2 outline-none focus-visible:ring-2 focus-visible:ring-ring"
470
- >
471
- <option value="viewer">{i18n.t("knowledge.viewer")}</option>
472
- <option value="editor">{i18n.t("knowledge.editor")}</option>
473
- <option value="manager">{i18n.t("knowledge.manager")}</option>
474
- </select>
475
- </label>
476
- <button
477
- type="submit"
478
- disabled={mutation.isPending}
479
- 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"
480
- >
481
- {i18n.t("knowledge.shareAction")}
482
- </button>
483
- </form>
484
- </Modal>
485
- );
486
- }
487
-
488
202
  function VersionHistory({ nodeId, close }: { nodeId: string; close(): void }) {
489
203
  const { data, i18n } = useIntelRouterContext();
490
204
  const versions = useQuery({