@anchrd/intel-ui 0.3.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 (52) hide show
  1. package/components.json +7 -1
  2. package/package.json +3 -1
  3. package/src/app/action-slot/action-slot.tsx +23 -0
  4. package/src/app/app-sidebar/app-sidebar.tsx +71 -0
  5. package/src/app/app-tree/app-tree.tsx +798 -0
  6. package/src/app/app.tsx +99 -54
  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 +86 -0
  11. package/src/app/tree-move/tree-move.tsx +197 -0
  12. package/src/app/user-footer/user-footer.tsx +103 -0
  13. package/src/app/view-toggle/view-toggle.tsx +77 -0
  14. package/src/blocknote-view/blocknote-view.tsx +19 -2
  15. package/src/branding/favicon.default.svg +2 -2
  16. package/src/branding/favicon.svg +2 -2
  17. package/src/components/ui/breadcrumb.tsx +102 -0
  18. package/src/components/ui/button.tsx +64 -0
  19. package/src/components/ui/collapsible.tsx +20 -0
  20. package/src/components/ui/command.tsx +160 -0
  21. package/src/components/ui/dialog.tsx +143 -0
  22. package/src/components/ui/dropdown-menu.tsx +162 -0
  23. package/src/components/ui/input.tsx +21 -0
  24. package/src/components/ui/separator.tsx +26 -0
  25. package/src/components/ui/sheet.tsx +136 -0
  26. package/src/components/ui/sidebar.tsx +693 -0
  27. package/src/components/ui/skeleton.tsx +13 -0
  28. package/src/components/ui/tooltip.tsx +51 -0
  29. package/src/data/intel-data-provider/intel-data-provider.ts +170 -85
  30. package/src/data/intel-data-provider/intel-data-provider.types.ts +64 -20
  31. package/src/document-link/document-link.tsx +132 -0
  32. package/src/flow-runs/flow-runs.tsx +225 -0
  33. package/src/flows/flows.tsx +684 -368
  34. package/src/flows/node-icon/node-icon.ts +28 -0
  35. package/src/flows/node-palette/node-palette.tsx +174 -0
  36. package/src/flows/node-palette/node-palette.types.ts +15 -0
  37. package/src/graph-pane/graph-pane.tsx +44 -0
  38. package/src/hooks/use-mobile.ts +19 -0
  39. package/src/i18n/en.json +188 -52
  40. package/src/knowledge/knowledge.tsx +133 -709
  41. package/src/knowledge-editor/knowledge-editor.tsx +169 -21
  42. package/src/knowledge-graph/knowledge-graph.ts +26 -24
  43. package/src/knowledge-graph/knowledge-graph.tsx +33 -24
  44. package/src/knowledge-table/knowledge-table.tsx +129 -0
  45. package/src/main.tsx +2 -2
  46. package/src/resource-menu/resource-menu.tsx +580 -0
  47. package/src/router/router.tsx +4 -0
  48. package/src/router/selection-search.ts +43 -0
  49. package/src/save-button/save-button.tsx +103 -0
  50. package/src/styles.css +91 -51
  51. package/src/theme/theme.ts +24 -0
  52. package/src/tools/tools.tsx +175 -158
@@ -1,483 +1,160 @@
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
- import {
9
- Archive,
10
- ChevronDown,
11
- ChevronRight,
12
- Download,
13
- FileText,
14
- Folder,
15
- FolderPlus,
16
- History,
17
- Link2,
18
- Network,
19
- Paperclip,
20
- Plus,
21
- Search,
22
- Share2,
23
- Trash2,
24
- } 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";
28
- import { Modal } from "@/modal/modal.tsx";
3
+ import { useNavigate, useRouterState } from "@tanstack/react-router";
4
+ import { Download, History, Paperclip } from "lucide-react";
5
+ import { lazy, Suspense, useState } from "react";
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";
29
12
  import { useIntelRouterContext } from "@/router/router-context.ts";
13
+ import { graphViewFrom, selectedFrom } from "@/router/selection-search.ts";
30
14
 
31
15
  const KnowledgeEditor = lazy(async () => ({
32
16
  default: (await import("@/knowledge-editor/knowledge-editor.tsx")).KnowledgeEditor,
33
17
  }));
34
- const KnowledgeGraphView = lazy(async () => ({
35
- default: (await import("@/knowledge-graph/knowledge-graph.tsx")).KnowledgeGraphView,
36
- }));
37
-
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
18
 
69
19
  export function Knowledge() {
70
20
  const { data, i18n } = useIntelRouterContext();
71
21
  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);
75
- const [sharing, setSharing] = useState(false);
76
- const [linksOpen, setLinksOpen] = useState(false);
77
- const [graphOpen, setGraphOpen] = useState(false);
78
- 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
- const graph = useQuery({
83
- queryKey: ["knowledge-graph"],
84
- queryFn: () => data.getKnowledgeGraph(),
85
- enabled: graphOpen,
22
+ const navigate = useNavigate();
23
+ // The tree in the sidebar and the header search both name what to open through `?select=`, so this
24
+ // screen has no selection of its own to keep in step with them.
25
+ const selection = useRouterState({
26
+ select: (state) => ({
27
+ id: selectedFrom(state.location.search),
28
+ graph: graphViewFrom(state.location.search),
29
+ }),
86
30
  });
31
+ const selectedId = selection.id;
32
+ const [versionsOpen, setVersionsOpen] = useState(false);
87
33
  const document = useQuery({
88
34
  queryKey: ["knowledge", selectedId],
89
35
  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,
36
+ enabled: Boolean(selectedId),
96
37
  });
97
- const archive = useMutation({
98
- mutationFn: (node: KnowledgeNode) =>
99
- data.archiveKnowledge({
100
- nodeId: node.id,
101
- baseUpdatedAt: node.updatedAt,
102
- archived: true,
103
- idempotencyKey: crypto.randomUUID(),
104
- }),
105
- onSuccess: async () => {
106
- setSelectedId(null);
107
- await queryClient.invalidateQueries({ queryKey: ["knowledge-tree"] });
108
- },
109
- });
110
- const setContextPolicy = useMutation({
111
- mutationFn: ({ node, contextPolicy }: { node: KnowledgeNode; contextPolicy: ContextPolicy }) =>
112
- data.updateKnowledge({
113
- nodeId: node.id,
114
- baseUpdatedAt: node.updatedAt,
115
- contextPolicy,
116
- idempotencyKey: crypto.randomUUID(),
117
- }),
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
- onSuccess: async (node) => {
158
- setSelectedId(node.id);
159
- await queryClient.invalidateQueries({ queryKey: ["knowledge-tree"] });
160
- },
38
+ const selected = document.data?.node ?? null;
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,
161
46
  });
47
+ return (
48
+ <div className="flex h-full min-h-0 flex-col">
49
+ {/* The screen owns the one action that belongs to Knowledge as a whole. Creating things is
50
+ the tree's, in the sidebar, at the place a new thing is meant to go.
162
51
 
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"
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>
58
+ ) : null}
59
+ <section className="relative flex min-h-0 min-w-0 flex-1 flex-col bg-card">
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 ? (
71
+ <div className="grid flex-1 place-items-center p-8 text-center text-sm text-muted-foreground">
72
+ {i18n.t("knowledge.select")}
73
+ </div>
74
+ ) : document.isPending ? (
75
+ <p className="p-6 text-sm text-muted-foreground">{i18n.t("common.loading")}</p>
76
+ ) : document.isError || !selected ? (
77
+ <div role="alert" className="grid flex-1 place-items-center p-8 text-center text-sm">
78
+ <div className="space-y-3">
79
+ <p className="text-destructive">{i18n.t("knowledge.loadFailed")}</p>
80
+ <button
81
+ type="button"
82
+ onClick={() => void document.refetch()}
83
+ className="rounded-md border px-3 py-2 text-sm outline-none hover:bg-muted focus-visible:ring-2 focus-visible:ring-ring"
176
84
  >
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>
85
+ {i18n.t("common.retry")}
86
+ </button>
87
+ </div>
192
88
  </div>
193
- )}
194
- </TreeItemContent>
195
- <Collection items={node.children}>{renderTreeItem}</Collection>
196
- </TreeItem>
197
- );
198
-
199
- 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
- )}
252
- {archive.isError || setContextPolicy.isError ? (
253
- <p role="alert" className="border-b px-8 py-3 text-sm text-destructive">
254
- {i18n.t("knowledge.operationFailed")}
255
- </p>
256
- ) : 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) => (
285
- <button
286
- key={`${citation.nodeId}:${citation.versionId}`}
287
- 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"
290
- >
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>
295
- </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>
89
+ ) : (
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. */}
99
+ <div className="flex items-start justify-between gap-5 border-b px-6 py-4">
100
+ <div className="min-w-0">
101
+ <h2 className="truncate text-lg font-semibold">{selected.title}</h2>
102
+ {selected.description && (
103
+ <p className="mt-1 text-sm text-muted-foreground">{selected.description}</p>
301
104
  )}
302
- <button
303
- 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"
309
- >
310
- {i18n.t("knowledge.clearSearch")}
311
- </button>
312
105
  </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>
106
+ <div className="flex shrink-0 items-center gap-2">
107
+ {selected.kind !== "folder" && (
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>
329
122
  )}
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")}
123
+ <ResourceMenu target={{ type: "knowledge", node: selected }} variant="title" />
124
+ <div data-slot="title-actions" className="flex items-center gap-2" />
125
+ </div>
340
126
  </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>
127
+ {selected.kind === "folder" ? (
128
+ <div className="grid flex-1 place-items-center p-8 text-sm text-muted-foreground">
129
+ {i18n.t("knowledge.folderHelp")}
403
130
  </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
- )}
444
- {sharing && selected && <ShareKnowledge node={selected} close={() => setSharing(false)} />}
445
- {linksOpen && selected && (
446
- <KnowledgeLinks
447
- node={selected}
448
- nodes={flattenTree(tree.data ?? [])}
449
- close={() => setLinksOpen(false)}
450
- />
451
- )}
452
- {graphOpen && !graph.data && (
453
- <Modal title={i18n.t("knowledge.graph")} close={() => setGraphOpen(false)}>
454
- <p role={graph.isError ? "alert" : undefined} className="text-sm text-muted-foreground">
455
- {graph.isError ? i18n.t("knowledge.graphFailed") : i18n.t("common.loading")}
456
- </p>
457
- {graph.isError && (
458
- <button
459
- type="button"
460
- onClick={() => void graph.refetch()}
461
- 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"
462
- >
463
- {i18n.t("common.retry")}
464
- </button>
465
- )}
466
- </Modal>
467
- )}
468
- {graphOpen && graph.data && (
469
- <Suspense fallback={null}>
470
- <KnowledgeGraphView
471
- data={graph.data}
472
- i18n={i18n}
473
- close={() => setGraphOpen(false)}
474
- select={(nodeId) => {
475
- setSelectedId(nodeId);
476
- setGraphOpen(false);
477
- }}
478
- />
479
- </Suspense>
480
- )}
131
+ ) : selected.kind === "attachment" ? (
132
+ <AttachmentPanel node={selected} />
133
+ ) : selected.kind === "table" ? (
134
+ <KnowledgeTablePanel node={selected} />
135
+ ) : document.data ? (
136
+ <Suspense
137
+ fallback={
138
+ <p className="p-6 text-sm text-muted-foreground">{i18n.t("common.loading")}</p>
139
+ }
140
+ >
141
+ <KnowledgeEditor
142
+ key={document.data.node.id}
143
+ data={data}
144
+ document={document.data}
145
+ i18n={i18n}
146
+ onSaved={(saved) => {
147
+ queryClient.setQueryData(["knowledge", selected.id], saved);
148
+ }}
149
+ />
150
+ </Suspense>
151
+ ) : null}
152
+ {versionsOpen && selected.kind !== "folder" && (
153
+ <VersionHistory nodeId={selected.id} close={() => setVersionsOpen(false)} />
154
+ )}
155
+ </>
156
+ )}
157
+ </section>
481
158
  </div>
482
159
  );
483
160
  }
@@ -522,259 +199,6 @@ function AttachmentPanel({ node }: { node: KnowledgeNode }) {
522
199
  );
523
200
  }
524
201
 
525
- function KnowledgeLinks({
526
- node,
527
- nodes,
528
- close,
529
- }: {
530
- node: KnowledgeNode;
531
- nodes: KnowledgeNode[];
532
- close(): void;
533
- }) {
534
- const { data, i18n } = useIntelRouterContext();
535
- const queryClient = useQueryClient();
536
- const [targetNodeId, setTargetNodeId] = useState("");
537
- const [relation, setRelation] = useState<KnowledgeLinkRelation>("related");
538
- const links = useQuery({
539
- queryKey: ["knowledge-links", node.id],
540
- queryFn: () => data.listKnowledgeLinks(node.id),
541
- });
542
- const create = useMutation({
543
- mutationFn: () =>
544
- data.createKnowledgeLink({
545
- sourceNodeId: node.id,
546
- targetNodeId,
547
- relation,
548
- label: null,
549
- idempotencyKey: crypto.randomUUID(),
550
- }),
551
- onSuccess: async () => {
552
- setTargetNodeId("");
553
- await Promise.all([
554
- queryClient.invalidateQueries({ queryKey: ["knowledge-links", node.id] }),
555
- queryClient.invalidateQueries({ queryKey: ["knowledge-graph"] }),
556
- ]);
557
- },
558
- });
559
- const remove = useMutation({
560
- mutationFn: (linkId: string) =>
561
- data.deleteKnowledgeLink({
562
- sourceNodeId: node.id,
563
- linkId,
564
- idempotencyKey: crypto.randomUUID(),
565
- }),
566
- onSuccess: async () => {
567
- await Promise.all([
568
- queryClient.invalidateQueries({ queryKey: ["knowledge-links", node.id] }),
569
- queryClient.invalidateQueries({ queryKey: ["knowledge-graph"] }),
570
- ]);
571
- },
572
- });
573
- const titles = new Map(nodes.map((candidate) => [candidate.id, candidate.title]));
574
-
575
- return (
576
- <Modal title={i18n.t("knowledge.links")} close={close}>
577
- <ul className="mb-5 max-h-48 space-y-2 overflow-y-auto">
578
- {links.data?.items.map((link) => {
579
- const outgoing = link.sourceNodeId === node.id;
580
- const otherId = outgoing ? link.targetNodeId : link.sourceNodeId;
581
- return (
582
- <li
583
- key={link.id}
584
- className="flex items-center justify-between gap-3 rounded-md border p-3"
585
- >
586
- <div className="min-w-0 text-sm">
587
- <span className="block truncate font-medium">{titles.get(otherId) ?? otherId}</span>
588
- <span className="text-xs text-muted-foreground">
589
- {outgoing ? "→" : "←"} {link.label ?? link.relation.replaceAll("_", " ")}
590
- </span>
591
- </div>
592
- {outgoing && (
593
- <button
594
- type="button"
595
- onClick={() => remove.mutate(link.id)}
596
- aria-label={i18n.t("knowledge.deleteLink")}
597
- className="rounded-md p-2 text-destructive outline-none hover:bg-muted focus-visible:ring-2 focus-visible:ring-ring"
598
- >
599
- <Trash2 aria-hidden="true" className="size-4" />
600
- </button>
601
- )}
602
- </li>
603
- );
604
- })}
605
- {links.data?.items.length === 0 && (
606
- <li className="text-sm text-muted-foreground">{i18n.t("knowledge.noLinks")}</li>
607
- )}
608
- </ul>
609
- <form
610
- className="space-y-4 border-t pt-5"
611
- onSubmit={(event) => {
612
- event.preventDefault();
613
- create.mutate();
614
- }}
615
- >
616
- <label className="block text-sm font-medium">
617
- {i18n.t("knowledge.linkTarget")}
618
- <select
619
- required
620
- value={targetNodeId}
621
- onChange={(event) => setTargetNodeId(event.target.value)}
622
- className="mt-2 w-full rounded-md border bg-background px-3 py-2 outline-none focus-visible:ring-2 focus-visible:ring-ring"
623
- >
624
- <option value="">{i18n.t("knowledge.selectLinkTarget")}</option>
625
- {nodes
626
- .filter((candidate) => candidate.id !== node.id)
627
- .map((candidate) => (
628
- <option key={candidate.id} value={candidate.id}>
629
- {candidate.title}
630
- </option>
631
- ))}
632
- </select>
633
- </label>
634
- <label className="block text-sm font-medium">
635
- {i18n.t("knowledge.relation")}
636
- <select
637
- value={relation}
638
- onChange={(event) => setRelation(event.target.value as KnowledgeLinkRelation)}
639
- className="mt-2 w-full rounded-md border bg-background px-3 py-2 outline-none focus-visible:ring-2 focus-visible:ring-ring"
640
- >
641
- {(["related", "references", "depends_on", "implements"] as const).map((value) => (
642
- <option key={value} value={value}>
643
- {i18n.t(`knowledge.relation.${value}`)}
644
- </option>
645
- ))}
646
- </select>
647
- </label>
648
- <button
649
- type="submit"
650
- disabled={!targetNodeId || create.isPending}
651
- 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"
652
- >
653
- {i18n.t("knowledge.createLink")}
654
- </button>
655
- </form>
656
- </Modal>
657
- );
658
- }
659
-
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
- function ShareKnowledge({ node, close }: { node: KnowledgeNode; close(): void }) {
721
- const { data, i18n } = useIntelRouterContext();
722
- const [email, setEmail] = useState("");
723
- const [role, setRole] = useState<ResourceRole>("viewer");
724
- const mutation = useMutation({
725
- mutationFn: () =>
726
- data.shareKnowledge({
727
- resourceId: node.id,
728
- principal: { type: "email", email },
729
- role,
730
- expiresAt: null,
731
- idempotencyKey: crypto.randomUUID(),
732
- }),
733
- onSuccess: close,
734
- });
735
- return (
736
- <Modal title={i18n.t("knowledge.share")} close={close}>
737
- <form
738
- onSubmit={(event) => {
739
- event.preventDefault();
740
- mutation.mutate();
741
- }}
742
- className="space-y-4"
743
- >
744
- <label className="block text-sm font-medium">
745
- {i18n.t("knowledge.email")}
746
- <input
747
- type="email"
748
- required
749
- value={email}
750
- onChange={(event) => setEmail(event.target.value)}
751
- className="mt-2 w-full rounded-md border bg-background px-3 py-2 outline-none focus-visible:ring-2 focus-visible:ring-ring"
752
- />
753
- </label>
754
- <label className="block text-sm font-medium">
755
- {i18n.t("knowledge.role")}
756
- <select
757
- value={role}
758
- onChange={(event) => setRole(event.target.value as ResourceRole)}
759
- className="mt-2 w-full rounded-md border bg-background px-3 py-2 outline-none focus-visible:ring-2 focus-visible:ring-ring"
760
- >
761
- <option value="viewer">{i18n.t("knowledge.viewer")}</option>
762
- <option value="editor">{i18n.t("knowledge.editor")}</option>
763
- <option value="manager">{i18n.t("knowledge.manager")}</option>
764
- </select>
765
- </label>
766
- <button
767
- type="submit"
768
- disabled={mutation.isPending}
769
- 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"
770
- >
771
- {i18n.t("knowledge.shareAction")}
772
- </button>
773
- </form>
774
- </Modal>
775
- );
776
- }
777
-
778
202
  function VersionHistory({ nodeId, close }: { nodeId: string; close(): void }) {
779
203
  const { data, i18n } = useIntelRouterContext();
780
204
  const versions = useQuery({