@anchrd/intel-ui 0.2.1 → 0.4.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 (33) hide show
  1. package/components.json +7 -1
  2. package/package.json +3 -1
  3. package/src/app/app-sidebar/app-sidebar.tsx +56 -0
  4. package/src/app/app-tree/app-tree.tsx +427 -0
  5. package/src/app/app.tsx +84 -54
  6. package/src/app/header-actions/header-actions.tsx +15 -0
  7. package/src/app/header-search/header-search.tsx +291 -0
  8. package/src/app/sidebar-preferences/sidebar-preferences.ts +68 -0
  9. package/src/app/sidebar-preferences/sidebar-preferences.types.ts +15 -0
  10. package/src/app/sidebar-resize-handle/sidebar-resize-handle.tsx +85 -0
  11. package/src/app/user-footer/user-footer.tsx +76 -0
  12. package/src/components/ui/breadcrumb.tsx +102 -0
  13. package/src/components/ui/button.tsx +64 -0
  14. package/src/components/ui/collapsible.tsx +20 -0
  15. package/src/components/ui/command.tsx +160 -0
  16. package/src/components/ui/dialog.tsx +143 -0
  17. package/src/components/ui/dropdown-menu.tsx +84 -0
  18. package/src/components/ui/input.tsx +21 -0
  19. package/src/components/ui/separator.tsx +26 -0
  20. package/src/components/ui/sheet.tsx +136 -0
  21. package/src/components/ui/sidebar.tsx +693 -0
  22. package/src/components/ui/skeleton.tsx +13 -0
  23. package/src/components/ui/tooltip.tsx +51 -0
  24. package/src/data/intel-data-provider/intel-data-provider.ts +58 -39
  25. package/src/data/intel-data-provider/intel-data-provider.types.ts +17 -8
  26. package/src/flows/flows.tsx +59 -142
  27. package/src/hooks/use-mobile.ts +19 -0
  28. package/src/i18n/en.json +48 -29
  29. package/src/knowledge/knowledge.tsx +133 -423
  30. package/src/router/router.tsx +4 -0
  31. package/src/router/selection-search.ts +19 -0
  32. package/src/styles.css +54 -51
  33. package/src/tools/tools.tsx +175 -158
@@ -5,28 +5,22 @@ import type {
5
5
  ResourceRole,
6
6
  } from "@anchrd/intel-contract";
7
7
  import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
8
+ import { useNavigate, useRouterState } from "@tanstack/react-router";
8
9
  import {
9
10
  Archive,
10
- ChevronDown,
11
- ChevronRight,
12
11
  Download,
13
- FileText,
14
- Folder,
15
- FolderPlus,
16
12
  History,
17
13
  Link2,
18
14
  Network,
19
15
  Paperclip,
20
- Plus,
21
- Search,
22
16
  Share2,
23
17
  Trash2,
24
18
  } from "lucide-react";
25
- import { lazy, Suspense, useMemo, useState } from "react";
26
- import { Button, Collection, Tree, TreeItem, TreeItemContent } from "react-aria-components";
27
- import type { KnowledgeTreeNode } from "@/data/intel-data-provider/intel-data-provider.types.ts";
19
+ import { lazy, Suspense, useState } from "react";
20
+ import { HeaderActions } from "@/app/header-actions/header-actions.tsx";
28
21
  import { Modal } from "@/modal/modal.tsx";
29
22
  import { useIntelRouterContext } from "@/router/router-context.ts";
23
+ import { selectedFrom } from "@/router/selection-search.ts";
30
24
 
31
25
  const KnowledgeEditor = lazy(async () => ({
32
26
  default: (await import("@/knowledge-editor/knowledge-editor.tsx")).KnowledgeEditor,
@@ -35,65 +29,30 @@ const KnowledgeGraphView = lazy(async () => ({
35
29
  default: (await import("@/knowledge-graph/knowledge-graph.tsx")).KnowledgeGraphView,
36
30
  }));
37
31
 
38
- function findNode(nodes: KnowledgeTreeNode[], id: string | null): KnowledgeTreeNode | null {
39
- if (!id) return null;
40
- for (const node of nodes) {
41
- if (node.id === id) return node;
42
- const child = findNode(node.children, id);
43
- if (child) return child;
44
- }
45
- return null;
46
- }
47
-
48
- function flattenTree(nodes: KnowledgeTreeNode[]): KnowledgeTreeNode[] {
49
- return nodes.flatMap((node) => [node, ...flattenTree(node.children)]);
50
- }
51
-
52
- async function fileBase64(file: File): Promise<string> {
53
- if (file.size > 15_000_000) throw new Error("Attachment exceeds the 15 MB upload limit");
54
- return await new Promise((resolve, reject) => {
55
- const reader = new FileReader();
56
- reader.onerror = () => reject(reader.error ?? new Error("Attachment could not be read"));
57
- reader.onload = () => {
58
- const result = reader.result;
59
- if (typeof result !== "string" || !result.includes(",")) {
60
- reject(new Error("Attachment could not be encoded"));
61
- return;
62
- }
63
- resolve(result.slice(result.indexOf(",") + 1));
64
- };
65
- reader.readAsDataURL(file);
66
- });
67
- }
68
-
69
32
  export function Knowledge() {
70
33
  const { data, i18n } = useIntelRouterContext();
71
34
  const queryClient = useQueryClient();
72
- const tree = useQuery({ queryKey: ["knowledge-tree"], queryFn: () => data.loadKnowledgeTree() });
73
- const [selectedId, setSelectedId] = useState<string | null>(null);
74
- const [creating, setCreating] = useState<"document" | "folder" | null>(null);
35
+ const navigate = useNavigate();
36
+ // The tree in the sidebar and the header search both name what to open through `?select=`, so this
37
+ // screen has no selection of its own to keep in step with them.
38
+ const selectedId = useRouterState({ select: (state) => selectedFrom(state.location.search) });
75
39
  const [sharing, setSharing] = useState(false);
76
40
  const [linksOpen, setLinksOpen] = useState(false);
77
41
  const [graphOpen, setGraphOpen] = useState(false);
78
42
  const [versionsOpen, setVersionsOpen] = useState(false);
79
- const [searchText, setSearchText] = useState("");
80
- const [searchQuery, setSearchQuery] = useState("");
81
- const selected = useMemo(() => findNode(tree.data ?? [], selectedId), [selectedId, tree.data]);
82
43
  const graph = useQuery({
83
44
  queryKey: ["knowledge-graph"],
84
45
  queryFn: () => data.getKnowledgeGraph(),
85
- enabled: graphOpen,
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,
86
49
  });
87
50
  const document = useQuery({
88
51
  queryKey: ["knowledge", selectedId],
89
52
  queryFn: () => data.getKnowledge(selectedId ?? ""),
90
- enabled: Boolean(selectedId && selected?.kind !== "folder"),
91
- });
92
- const search = useQuery({
93
- queryKey: ["knowledge-search", searchQuery],
94
- queryFn: () => data.searchKnowledge({ query: searchQuery, limit: 12 }),
95
- enabled: searchQuery.length > 0,
53
+ enabled: Boolean(selectedId),
96
54
  });
55
+ const selected = document.data?.node ?? null;
97
56
  const archive = useMutation({
98
57
  mutationFn: (node: KnowledgeNode) =>
99
58
  data.archiveKnowledge({
@@ -102,9 +61,11 @@ export function Knowledge() {
102
61
  archived: true,
103
62
  idempotencyKey: crypto.randomUUID(),
104
63
  }),
105
- onSuccess: async () => {
106
- setSelectedId(null);
107
- await queryClient.invalidateQueries({ queryKey: ["knowledge-tree"] });
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: {} });
108
69
  },
109
70
  });
110
71
  const setContextPolicy = useMutation({
@@ -115,337 +76,146 @@ export function Knowledge() {
115
76
  contextPolicy,
116
77
  idempotencyKey: crypto.randomUUID(),
117
78
  }),
118
- onSuccess: async () => {
119
- await queryClient.invalidateQueries({ queryKey: ["knowledge-tree"] });
120
- },
121
- });
122
- const upload = useMutation({
123
- mutationFn: async (file: File) => {
124
- const contentBase64 = await fileBase64(file);
125
- const node = await data.createKnowledge({
126
- parentId: selected?.kind === "folder" ? selected.id : (selected?.parentId ?? null),
127
- kind: "attachment",
128
- title: file.name,
129
- description: null,
130
- contextPolicy: "relevant",
131
- idempotencyKey: crypto.randomUUID(),
132
- });
133
- try {
134
- const saved = await data.saveKnowledgeAttachment({
135
- nodeId: node.id,
136
- baseVersionId: null,
137
- contentBase64,
138
- mediaType: file.type || "application/octet-stream",
139
- idempotencyKey: crypto.randomUUID(),
140
- });
141
- return saved.node;
142
- } catch (error) {
143
- const current = await data.getKnowledge(node.id).catch(() => null);
144
- if (current?.version === null && current.node.updatedAt === node.updatedAt) {
145
- await data
146
- .archiveKnowledge({
147
- nodeId: node.id,
148
- baseUpdatedAt: current.node.updatedAt,
149
- archived: true,
150
- idempotencyKey: crypto.randomUUID(),
151
- })
152
- .catch(() => undefined);
153
- }
154
- throw error;
155
- }
156
- },
157
79
  onSuccess: async (node) => {
158
- setSelectedId(node.id);
159
- await queryClient.invalidateQueries({ queryKey: ["knowledge-tree"] });
80
+ await queryClient.invalidateQueries({ queryKey: ["knowledge", node.id] });
160
81
  },
161
82
  });
162
83
 
163
- const renderTreeItem = (node: KnowledgeTreeNode) => (
164
- <TreeItem
165
- id={node.id}
166
- textValue={node.title}
167
- className="rounded-md outline-none data-[focused]:ring-2 data-[focused]:ring-ring data-[selected]:bg-accent data-[selected]:text-accent-foreground"
168
- >
169
- <TreeItemContent>
170
- {({ hasChildItems, isExpanded }) => (
171
- <div className="flex min-w-0 items-center gap-2 px-2 py-1.5 text-sm">
172
- {hasChildItems ? (
173
- <Button
174
- slot="chevron"
175
- className="rounded-sm outline-none focus-visible:ring-2 focus-visible:ring-ring"
176
- >
177
- {isExpanded ? (
178
- <ChevronDown aria-hidden="true" className="size-3.5" />
179
- ) : (
180
- <ChevronRight aria-hidden="true" className="size-3.5" />
181
- )}
182
- </Button>
183
- ) : (
184
- <span className="size-3.5" />
185
- )}
186
- {node.kind === "folder" ? (
187
- <Folder aria-hidden="true" className="size-4 shrink-0" />
188
- ) : (
189
- <FileText aria-hidden="true" className="size-4 shrink-0" />
190
- )}
191
- <span className="truncate">{node.title}</span>
192
- </div>
193
- )}
194
- </TreeItemContent>
195
- <Collection items={node.children}>{renderTreeItem}</Collection>
196
- </TreeItem>
197
- );
198
-
199
84
  return (
200
- <div className="flex h-screen min-h-0 flex-col">
201
- <header className="flex items-center justify-between gap-6 border-b px-8 py-5">
202
- <div>
203
- <h1 className="text-xl font-semibold tracking-tight">{i18n.t("knowledge.title")}</h1>
204
- <p className="mt-1 text-sm text-muted-foreground">{i18n.t("knowledge.description")}</p>
205
- </div>
206
- <div className="flex items-center gap-2">
207
- <button
208
- type="button"
209
- onClick={() => setGraphOpen(true)}
210
- className="inline-flex items-center gap-2 rounded-md border bg-background px-3 py-2 text-sm outline-none hover:bg-muted focus-visible:ring-2 focus-visible:ring-ring"
211
- >
212
- <Network aria-hidden="true" className="size-4" />
213
- {i18n.t("knowledge.graph")}
214
- </button>
215
- <button
216
- type="button"
217
- onClick={() => setCreating("folder")}
218
- className="inline-flex items-center gap-2 rounded-md border bg-background px-3 py-2 text-sm outline-none hover:bg-muted focus-visible:ring-2 focus-visible:ring-ring"
219
- >
220
- <FolderPlus aria-hidden="true" className="size-4" />
221
- {i18n.t("knowledge.newFolder")}
222
- </button>
223
- <label className="inline-flex cursor-pointer items-center gap-2 rounded-md border bg-background px-3 py-2 text-sm outline-none hover:bg-muted focus-within:ring-2 focus-within:ring-ring">
224
- <Paperclip aria-hidden="true" className="size-4" />
225
- {upload.isPending ? i18n.t("knowledge.uploading") : i18n.t("knowledge.upload")}
226
- <input
227
- type="file"
228
- className="sr-only"
229
- disabled={upload.isPending}
230
- onChange={(event) => {
231
- const file = event.target.files?.[0];
232
- if (file) upload.mutate(file);
233
- event.currentTarget.value = "";
234
- }}
235
- />
236
- </label>
237
- <button
238
- type="button"
239
- onClick={() => setCreating("document")}
240
- className="inline-flex items-center gap-2 rounded-md bg-primary px-3 py-2 text-sm font-medium text-primary-foreground outline-none hover:bg-primary/90 focus-visible:ring-2 focus-visible:ring-ring"
241
- >
242
- <Plus aria-hidden="true" className="size-4" />
243
- {i18n.t("knowledge.new")}
244
- </button>
245
- </div>
246
- </header>
247
- {upload.isError && (
248
- <p role="alert" className="border-b px-8 py-3 text-sm text-destructive">
249
- {i18n.t("knowledge.uploadFailed")}
250
- </p>
251
- )}
85
+ <div className="flex h-full min-h-0 flex-col">
86
+ {/* 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>
252
98
  {archive.isError || setContextPolicy.isError ? (
253
99
  <p role="alert" className="border-b px-8 py-3 text-sm text-destructive">
254
100
  {i18n.t("knowledge.operationFailed")}
255
101
  </p>
256
102
  ) : null}
257
- <div className="flex min-h-0 flex-1">
258
- <aside className="flex w-80 min-w-72 flex-col border-r bg-muted/20">
259
- <form
260
- className="border-b p-3"
261
- onSubmit={(event) => {
262
- event.preventDefault();
263
- setSearchQuery(searchText.trim());
264
- }}
265
- >
266
- <label className="relative block">
267
- <span className="sr-only">{i18n.t("knowledge.search")}</span>
268
- <Search
269
- aria-hidden="true"
270
- className="absolute left-3 top-1/2 size-4 -translate-y-1/2 text-muted-foreground"
271
- />
272
- <input
273
- type="search"
274
- value={searchText}
275
- onChange={(event) => setSearchText(event.target.value)}
276
- placeholder={i18n.t("knowledge.search")}
277
- className="w-full rounded-md border bg-background py-2 pl-9 pr-3 text-sm outline-none focus-visible:ring-2 focus-visible:ring-ring"
278
- />
279
- </label>
280
- </form>
281
- <div className="min-h-0 flex-1 overflow-y-auto p-3">
282
- {searchQuery ? (
283
- <div className="space-y-1">
284
- {search.data?.items.map((citation) => (
103
+ <section className="relative flex min-h-0 min-w-0 flex-1 flex-col bg-card">
104
+ {!selectedId ? (
105
+ <div className="grid flex-1 place-items-center p-8 text-center text-sm text-muted-foreground">
106
+ {i18n.t("knowledge.select")}
107
+ </div>
108
+ ) : document.isPending ? (
109
+ <p className="p-6 text-sm text-muted-foreground">{i18n.t("common.loading")}</p>
110
+ ) : document.isError || !selected ? (
111
+ <div role="alert" className="grid flex-1 place-items-center p-8 text-center text-sm">
112
+ <div className="space-y-3">
113
+ <p className="text-destructive">{i18n.t("knowledge.loadFailed")}</p>
114
+ <button
115
+ type="button"
116
+ onClick={() => void document.refetch()}
117
+ className="rounded-md border px-3 py-2 text-sm outline-none hover:bg-muted focus-visible:ring-2 focus-visible:ring-ring"
118
+ >
119
+ {i18n.t("common.retry")}
120
+ </button>
121
+ </div>
122
+ </div>
123
+ ) : (
124
+ <>
125
+ <div className="flex items-start justify-between gap-5 border-b px-6 py-4">
126
+ <div className="min-w-0">
127
+ <h2 className="truncate text-lg font-semibold">{selected.title}</h2>
128
+ {selected.description && (
129
+ <p className="mt-1 text-sm text-muted-foreground">{selected.description}</p>
130
+ )}
131
+ </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>
165
+ {selected.kind !== "folder" && (
285
166
  <button
286
- key={`${citation.nodeId}:${citation.versionId}`}
287
167
  type="button"
288
- onClick={() => setSelectedId(citation.nodeId)}
289
- className="w-full rounded-md p-2 text-left outline-none hover:bg-accent focus-visible:ring-2 focus-visible:ring-ring"
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"
290
171
  >
291
- <span className="block truncate text-sm font-medium">{citation.title}</span>
292
- <span className="mt-1 line-clamp-2 text-xs text-muted-foreground">
293
- {citation.passage}
294
- </span>
172
+ <History aria-hidden="true" className="size-4" />
295
173
  </button>
296
- ))}
297
- {search.data?.items.length === 0 && (
298
- <p className="p-2 text-sm text-muted-foreground">
299
- {i18n.t("knowledge.noResults")}
300
- </p>
301
174
  )}
302
175
  <button
303
176
  type="button"
304
- onClick={() => {
305
- setSearchQuery("");
306
- setSearchText("");
307
- }}
308
- className="mt-3 text-xs text-muted-foreground underline outline-none focus-visible:ring-2 focus-visible:ring-ring"
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"
309
180
  >
310
- {i18n.t("knowledge.clearSearch")}
181
+ <Archive aria-hidden="true" className="size-4" />
311
182
  </button>
312
183
  </div>
313
- ) : tree.isPending ? (
314
- <p className="text-sm text-muted-foreground">{i18n.t("common.loading")}</p>
315
- ) : (
316
- <Tree
317
- aria-label={i18n.t("knowledge.tree")}
318
- items={tree.data ?? []}
319
- selectionMode="single"
320
- selectedKeys={selectedId ? [selectedId] : []}
321
- onSelectionChange={(keys) => {
322
- if (keys === "all") return;
323
- const key = [...keys][0];
324
- setSelectedId(key === undefined ? null : String(key));
325
- }}
326
- className="outline-none"
327
- renderEmptyState={() => (
328
- <p className="p-2 text-sm text-muted-foreground">{i18n.t("knowledge.empty")}</p>
329
- )}
330
- >
331
- {renderTreeItem}
332
- </Tree>
333
- )}
334
- </div>
335
- </aside>
336
- <section className="relative flex min-w-0 flex-1 flex-col bg-card">
337
- {!selected && (
338
- <div className="grid flex-1 place-items-center p-8 text-center text-sm text-muted-foreground">
339
- {i18n.t("knowledge.select")}
340
184
  </div>
341
- )}
342
- {selected && (
343
- <>
344
- <div className="flex items-start justify-between gap-5 border-b px-6 py-4">
345
- <div className="min-w-0">
346
- <h2 className="truncate text-lg font-semibold">{selected.title}</h2>
347
- {selected.description && (
348
- <p className="mt-1 text-sm text-muted-foreground">{selected.description}</p>
349
- )}
350
- </div>
351
- <div className="flex shrink-0 items-center gap-1">
352
- <select
353
- value={selected.contextPolicy}
354
- disabled={setContextPolicy.isPending}
355
- onChange={(event) =>
356
- setContextPolicy.mutate({
357
- node: selected,
358
- contextPolicy: event.target.value as ContextPolicy,
359
- })
360
- }
361
- aria-label={i18n.t("knowledge.contextPolicy")}
362
- 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"
363
- >
364
- <option value="pinned">{i18n.t("knowledge.context.pinned")}</option>
365
- <option value="relevant">{i18n.t("knowledge.context.relevant")}</option>
366
- <option value="explicit">{i18n.t("knowledge.context.explicit")}</option>
367
- </select>
368
- <button
369
- type="button"
370
- onClick={() => setLinksOpen(true)}
371
- aria-label={i18n.t("knowledge.links")}
372
- className="rounded-md p-2 outline-none hover:bg-muted focus-visible:ring-2 focus-visible:ring-ring"
373
- >
374
- <Link2 aria-hidden="true" className="size-4" />
375
- </button>
376
- <button
377
- type="button"
378
- onClick={() => setSharing(true)}
379
- aria-label={i18n.t("knowledge.share")}
380
- className="rounded-md p-2 outline-none hover:bg-muted focus-visible:ring-2 focus-visible:ring-ring"
381
- >
382
- <Share2 aria-hidden="true" className="size-4" />
383
- </button>
384
- {selected.kind !== "folder" && (
385
- <button
386
- type="button"
387
- onClick={() => setVersionsOpen((value) => !value)}
388
- aria-label={i18n.t("knowledge.versions")}
389
- className="rounded-md p-2 outline-none hover:bg-muted focus-visible:ring-2 focus-visible:ring-ring"
390
- >
391
- <History aria-hidden="true" className="size-4" />
392
- </button>
393
- )}
394
- <button
395
- type="button"
396
- onClick={() => archive.mutate(selected)}
397
- aria-label={i18n.t("knowledge.archive")}
398
- className="rounded-md p-2 text-destructive outline-none hover:bg-muted focus-visible:ring-2 focus-visible:ring-ring"
399
- >
400
- <Archive aria-hidden="true" className="size-4" />
401
- </button>
402
- </div>
185
+ {selected.kind === "folder" ? (
186
+ <div className="grid flex-1 place-items-center p-8 text-sm text-muted-foreground">
187
+ {i18n.t("knowledge.folderHelp")}
403
188
  </div>
404
- {selected.kind === "folder" ? (
405
- <div className="grid flex-1 place-items-center p-8 text-sm text-muted-foreground">
406
- {i18n.t("knowledge.folderHelp")}
407
- </div>
408
- ) : selected.kind === "attachment" && document.data ? (
409
- <AttachmentPanel node={selected} />
410
- ) : document.isPending ? (
411
- <p className="p-6 text-sm text-muted-foreground">{i18n.t("common.loading")}</p>
412
- ) : document.data ? (
413
- <Suspense
414
- fallback={
415
- <p className="p-6 text-sm text-muted-foreground">{i18n.t("common.loading")}</p>
416
- }
417
- >
418
- <KnowledgeEditor
419
- key={document.data.node.id}
420
- data={data}
421
- document={document.data}
422
- i18n={i18n}
423
- onSaved={(saved) => {
424
- queryClient.setQueryData(["knowledge", selected.id], saved);
425
- void queryClient.invalidateQueries({ queryKey: ["knowledge-tree"] });
426
- }}
427
- />
428
- </Suspense>
429
- ) : null}
430
- {versionsOpen && selected.kind !== "folder" && (
431
- <VersionHistory nodeId={selected.id} close={() => setVersionsOpen(false)} />
432
- )}
433
- </>
434
- )}
435
- </section>
436
- </div>
437
- {creating && (
438
- <CreateKnowledge
439
- kind={creating}
440
- parentId={selected?.kind === "folder" ? selected.id : (selected?.parentId ?? null)}
441
- close={() => setCreating(null)}
442
- />
443
- )}
189
+ ) : selected.kind === "attachment" ? (
190
+ <AttachmentPanel node={selected} />
191
+ ) : document.data ? (
192
+ <Suspense
193
+ fallback={
194
+ <p className="p-6 text-sm text-muted-foreground">{i18n.t("common.loading")}</p>
195
+ }
196
+ >
197
+ <KnowledgeEditor
198
+ key={document.data.node.id}
199
+ data={data}
200
+ document={document.data}
201
+ i18n={i18n}
202
+ onSaved={(saved) => {
203
+ queryClient.setQueryData(["knowledge", selected.id], saved);
204
+ }}
205
+ />
206
+ </Suspense>
207
+ ) : null}
208
+ {versionsOpen && selected.kind !== "folder" && (
209
+ <VersionHistory nodeId={selected.id} close={() => setVersionsOpen(false)} />
210
+ )}
211
+ </>
212
+ )}
213
+ </section>
444
214
  {sharing && selected && <ShareKnowledge node={selected} close={() => setSharing(false)} />}
445
215
  {linksOpen && selected && (
446
216
  <KnowledgeLinks
447
217
  node={selected}
448
- nodes={flattenTree(tree.data ?? [])}
218
+ nodes={graph.data?.nodes ?? []}
449
219
  close={() => setLinksOpen(false)}
450
220
  />
451
221
  )}
@@ -472,8 +242,8 @@ export function Knowledge() {
472
242
  i18n={i18n}
473
243
  close={() => setGraphOpen(false)}
474
244
  select={(nodeId) => {
475
- setSelectedId(nodeId);
476
245
  setGraphOpen(false);
246
+ void navigate({ to: "/knowledge", search: { select: nodeId } });
477
247
  }}
478
248
  />
479
249
  </Suspense>
@@ -657,66 +427,6 @@ function KnowledgeLinks({
657
427
  );
658
428
  }
659
429
 
660
- function CreateKnowledge({
661
- kind,
662
- parentId,
663
- close,
664
- }: {
665
- kind: "document" | "folder";
666
- parentId: string | null;
667
- close(): void;
668
- }) {
669
- const { data, i18n } = useIntelRouterContext();
670
- const queryClient = useQueryClient();
671
- const [title, setTitle] = useState("");
672
- const mutation = useMutation({
673
- mutationFn: () =>
674
- data.createKnowledge({
675
- parentId,
676
- kind,
677
- title,
678
- description: null,
679
- contextPolicy: "relevant",
680
- idempotencyKey: crypto.randomUUID(),
681
- }),
682
- onSuccess: async () => {
683
- await queryClient.invalidateQueries({ queryKey: ["knowledge-tree"] });
684
- close();
685
- },
686
- });
687
- return (
688
- <Modal
689
- title={kind === "folder" ? i18n.t("knowledge.newFolder") : i18n.t("knowledge.new")}
690
- close={close}
691
- >
692
- <form
693
- onSubmit={(event) => {
694
- event.preventDefault();
695
- mutation.mutate();
696
- }}
697
- className="space-y-4"
698
- >
699
- <label className="block text-sm font-medium">
700
- {i18n.t("common.title")}
701
- <input
702
- required
703
- value={title}
704
- onChange={(event) => setTitle(event.target.value)}
705
- className="mt-2 w-full rounded-md border bg-background px-3 py-2 outline-none focus-visible:ring-2 focus-visible:ring-ring"
706
- />
707
- </label>
708
- <button
709
- type="submit"
710
- disabled={mutation.isPending}
711
- 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"
712
- >
713
- {i18n.t("common.create")}
714
- </button>
715
- </form>
716
- </Modal>
717
- );
718
- }
719
-
720
430
  function ShareKnowledge({ node, close }: { node: KnowledgeNode; close(): void }) {
721
431
  const { data, i18n } = useIntelRouterContext();
722
432
  const [email, setEmail] = useState("");