@anchrd/intel-ui 0.4.0 → 0.6.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/package.json +1 -1
- package/src/app/action-slot/action-slot.tsx +27 -0
- package/src/app/app-sidebar/app-sidebar.tsx +39 -24
- package/src/app/app-tree/app-tree.tsx +332 -60
- package/src/app/app.tsx +31 -5
- package/src/app/sidebar-resize-handle/sidebar-resize-handle.tsx +2 -1
- package/src/app/tree-move/tree-move.tsx +331 -0
- package/src/app/user-footer/user-footer.tsx +73 -46
- package/src/app/view-toggle/view-toggle.tsx +77 -0
- package/src/blocknote-view/blocknote-view.tsx +19 -2
- package/src/branding/favicon.default.svg +2 -2
- package/src/branding/favicon.svg +2 -2
- package/src/components/ui/dropdown-menu.tsx +78 -0
- package/src/data/intel-data-provider/intel-data-provider.ts +135 -57
- package/src/data/intel-data-provider/intel-data-provider.types.ts +51 -13
- package/src/document-link/document-link.tsx +132 -0
- package/src/flow-runs/flow-runs.tsx +225 -0
- package/src/flows/flows.tsx +665 -271
- package/src/flows/node-icon/node-icon.ts +28 -0
- package/src/flows/node-palette/node-palette.tsx +200 -0
- package/src/flows/node-palette/node-palette.types.ts +15 -0
- package/src/graph-pane/graph-pane.tsx +44 -0
- package/src/i18n/en.json +144 -29
- package/src/knowledge/knowledge.tsx +91 -367
- package/src/knowledge-editor/knowledge-editor.tsx +169 -21
- package/src/knowledge-graph/knowledge-graph.ts +26 -24
- package/src/knowledge-graph/knowledge-graph.tsx +33 -24
- package/src/knowledge-table/knowledge-table.tsx +141 -0
- package/src/main.tsx +2 -2
- package/src/resource-menu/resource-menu.tsx +615 -0
- package/src/router/selection-search.ts +27 -3
- package/src/save-button/save-button.tsx +103 -0
- package/src/styles.css +37 -0
- package/src/theme/theme.ts +24 -0
- package/src/title-row/title-row.tsx +49 -0
- package/src/tools/tools.tsx +57 -38
- package/src/app/header-actions/header-actions.tsx +0 -15
|
@@ -1,22 +1,34 @@
|
|
|
1
1
|
import {
|
|
2
2
|
BlockNoteDocument,
|
|
3
3
|
BlockNoteMediaType,
|
|
4
|
+
DocumentLinkInlineType,
|
|
4
5
|
type KnowledgeDocument,
|
|
6
|
+
type KnowledgeNode,
|
|
5
7
|
} from "@anchrd/intel-contract";
|
|
6
|
-
import
|
|
7
|
-
import { useCreateBlockNote } from "@blocknote/react";
|
|
8
|
-
import { useMutation } from "@tanstack/react-query";
|
|
9
|
-
import {
|
|
8
|
+
import { filterSuggestionItems } from "@blocknote/core";
|
|
9
|
+
import { SuggestionMenuController, useCreateBlockNote } from "@blocknote/react";
|
|
10
|
+
import { useMutation, useQuery } from "@tanstack/react-query";
|
|
11
|
+
import { Link2 } from "lucide-react";
|
|
10
12
|
import { useEffect, useMemo, useState } from "react";
|
|
11
|
-
import {
|
|
13
|
+
import { ActionSlot } from "@/app/action-slot/action-slot.tsx";
|
|
14
|
+
import { BlockNoteView, defaultSlashMenuItems } from "@/blocknote-view/blocknote-view.tsx";
|
|
12
15
|
import type { IntelDataProvider } from "@/data/intel-data-provider/intel-data-provider.types.ts";
|
|
16
|
+
import {
|
|
17
|
+
DocumentLinkProvider,
|
|
18
|
+
documentLinkIds,
|
|
19
|
+
type IntelEditor,
|
|
20
|
+
type IntelEditorPartialBlock,
|
|
21
|
+
intelEditorSchema,
|
|
22
|
+
} from "@/document-link/document-link.tsx";
|
|
13
23
|
import type { I18n } from "@/i18n/i18n.types.ts";
|
|
24
|
+
import { Modal } from "@/modal/modal.tsx";
|
|
25
|
+
import { SaveButton, UnsavedChangesGuard } from "@/save-button/save-button.tsx";
|
|
14
26
|
|
|
15
|
-
function storedBlocks(content: string | null):
|
|
27
|
+
function storedBlocks(content: string | null): IntelEditorPartialBlock[] | undefined {
|
|
16
28
|
if (!content) return undefined;
|
|
17
29
|
try {
|
|
18
30
|
const parsed = BlockNoteDocument.safeParse(JSON.parse(content));
|
|
19
|
-
return parsed.success ? (parsed.data.blocks as
|
|
31
|
+
return parsed.success ? (parsed.data.blocks as IntelEditorPartialBlock[]) : undefined;
|
|
20
32
|
} catch {
|
|
21
33
|
return undefined;
|
|
22
34
|
}
|
|
@@ -34,10 +46,29 @@ export function KnowledgeEditor({
|
|
|
34
46
|
onSaved(document: KnowledgeDocument): void;
|
|
35
47
|
}) {
|
|
36
48
|
const initialBlocks = useMemo(() => storedBlocks(document.content), [document.content]);
|
|
49
|
+
// ⚠️ `initialContent` is always passed, never spread in conditionally: a conditional spread makes
|
|
50
|
+
// the options a union type, and BlockNote then infers the default schema instead of Intel's —
|
|
51
|
+
// which typechecks and then refuses to insert a document link at run time. One empty paragraph is
|
|
52
|
+
// what the editor would have started with anyway.
|
|
37
53
|
const editor = useCreateBlockNote({
|
|
38
|
-
|
|
39
|
-
|
|
54
|
+
schema: intelEditorSchema,
|
|
55
|
+
initialContent: initialBlocks?.length ? initialBlocks : [{ type: "paragraph" }],
|
|
56
|
+
// ⚠️ The one cast, in the one place. `useCreateBlockNote` decides its return type with a
|
|
57
|
+
// conditional that this repo's `exactOptionalPropertyTypes` defeats, so it hands back the
|
|
58
|
+
// default-schema editor even though it built Intel's. Same upstream seam as `BlockNoteView`.
|
|
59
|
+
}) as unknown as IntelEditor;
|
|
40
60
|
const [dirty, setDirty] = useState(false);
|
|
61
|
+
const [picking, setPicking] = useState(false);
|
|
62
|
+
// Which documents the text links to right now. It is recomputed from the editor rather than from
|
|
63
|
+
// the saved content, so a link resolves the moment it is inserted instead of after a save.
|
|
64
|
+
const [linkedIds, setLinkedIds] = useState<string[]>(() => documentLinkIds(initialBlocks ?? []));
|
|
65
|
+
const resolved = useQuery({
|
|
66
|
+
// The key is the set of targets: two documents linking to the same thing share one answer, and
|
|
67
|
+
// adding a link asks only because the set changed.
|
|
68
|
+
queryKey: ["knowledge-link-titles", [...linkedIds].sort()],
|
|
69
|
+
queryFn: () => data.resolveKnowledgeLinks({ nodeIds: linkedIds }),
|
|
70
|
+
enabled: linkedIds.length > 0,
|
|
71
|
+
});
|
|
41
72
|
const save = useMutation({
|
|
42
73
|
mutationFn: async () => {
|
|
43
74
|
const payload = BlockNoteDocument.parse({
|
|
@@ -66,27 +97,144 @@ export function KnowledgeEditor({
|
|
|
66
97
|
editor.replaceBlocks(editor.document, blocks);
|
|
67
98
|
}, [document.content, editor, initialBlocks]);
|
|
68
99
|
|
|
100
|
+
function insertLink(nodeId: string): void {
|
|
101
|
+
editor.insertInlineContent([
|
|
102
|
+
{ type: DocumentLinkInlineType, props: { nodeId } },
|
|
103
|
+
// A trailing space, or the caret stays inside an element that takes no text and the next
|
|
104
|
+
// keystroke has nowhere to go.
|
|
105
|
+
" ",
|
|
106
|
+
]);
|
|
107
|
+
setPicking(false);
|
|
108
|
+
setDirty(true);
|
|
109
|
+
setLinkedIds(documentLinkIds(editor.document));
|
|
110
|
+
}
|
|
111
|
+
|
|
69
112
|
return (
|
|
70
113
|
<div className="flex min-h-0 flex-1 flex-col">
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
{save.isPending ? i18n.t("common.saving") : i18n.t("common.save")}
|
|
80
|
-
</button>
|
|
81
|
-
</div>
|
|
114
|
+
{/* ⚠️ Up into the title line, not a strip of its own (#24). The button used to cost a whole
|
|
115
|
+
screen row and a rule under it to say nothing most of the time. It is rendered from here
|
|
116
|
+
because this is where `dirty` lives — moving the state up to the screen would mean the
|
|
117
|
+
screen tracking every keystroke in an editor it does not own. */}
|
|
118
|
+
<ActionSlot name="title-actions">
|
|
119
|
+
<SaveButton dirty={dirty} saving={save.isPending} onSave={() => save.mutate()} />
|
|
120
|
+
</ActionSlot>
|
|
121
|
+
<UnsavedChangesGuard dirty={dirty} />
|
|
82
122
|
{save.isError && (
|
|
83
123
|
<p role="alert" className="mx-6 mt-4 text-sm text-destructive">
|
|
84
124
|
{i18n.t("knowledge.saveError")}
|
|
85
125
|
</p>
|
|
86
126
|
)}
|
|
87
127
|
<div className="min-h-0 flex-1 overflow-y-auto py-6">
|
|
88
|
-
<
|
|
128
|
+
<DocumentLinkProvider
|
|
129
|
+
value={{
|
|
130
|
+
titles: new Map((resolved.data?.items ?? []).map((item) => [item.nodeId, item.title])),
|
|
131
|
+
pending: linkedIds.length > 0 && resolved.isPending,
|
|
132
|
+
unresolvedLabel: i18n.t("knowledge.link.unresolved"),
|
|
133
|
+
}}
|
|
134
|
+
>
|
|
135
|
+
<BlockNoteView
|
|
136
|
+
editor={editor}
|
|
137
|
+
onChange={() => {
|
|
138
|
+
setDirty(true);
|
|
139
|
+
setLinkedIds(documentLinkIds(editor.document));
|
|
140
|
+
}}
|
|
141
|
+
>
|
|
142
|
+
{/* The `/` line BlockNote already has, with one item added. A second way to reach the
|
|
143
|
+
same thing would be the second way #41 exists to remove. */}
|
|
144
|
+
<SuggestionMenuController
|
|
145
|
+
triggerCharacter="/"
|
|
146
|
+
getItems={async (query) =>
|
|
147
|
+
filterSuggestionItems(
|
|
148
|
+
[
|
|
149
|
+
...defaultSlashMenuItems(editor),
|
|
150
|
+
{
|
|
151
|
+
title: i18n.t("knowledge.link.insert"),
|
|
152
|
+
group: i18n.t("knowledge.link.group"),
|
|
153
|
+
icon: <Link2 aria-hidden="true" className="size-4" />,
|
|
154
|
+
onItemClick: () => setPicking(true),
|
|
155
|
+
},
|
|
156
|
+
],
|
|
157
|
+
query,
|
|
158
|
+
)
|
|
159
|
+
}
|
|
160
|
+
/>
|
|
161
|
+
</BlockNoteView>
|
|
162
|
+
</DocumentLinkProvider>
|
|
89
163
|
</div>
|
|
164
|
+
{picking ? (
|
|
165
|
+
<DocumentPicker
|
|
166
|
+
data={data}
|
|
167
|
+
i18n={i18n}
|
|
168
|
+
exclude={document.node.id}
|
|
169
|
+
close={() => setPicking(false)}
|
|
170
|
+
choose={insertLink}
|
|
171
|
+
/>
|
|
172
|
+
) : null}
|
|
90
173
|
</div>
|
|
91
174
|
);
|
|
92
175
|
}
|
|
176
|
+
|
|
177
|
+
/**
|
|
178
|
+
* The documents the author may link to.
|
|
179
|
+
*
|
|
180
|
+
* ⚠️ The candidate list is the authorized graph — the same one the server would answer with — so
|
|
181
|
+
* the picker cannot offer a document the author may not see. The reader's side filters again, on
|
|
182
|
+
* its own account: the author seeing a title is not the reader seeing it (#41).
|
|
183
|
+
*/
|
|
184
|
+
function DocumentPicker({
|
|
185
|
+
data,
|
|
186
|
+
i18n,
|
|
187
|
+
exclude,
|
|
188
|
+
close,
|
|
189
|
+
choose,
|
|
190
|
+
}: {
|
|
191
|
+
data: IntelDataProvider;
|
|
192
|
+
i18n: I18n;
|
|
193
|
+
exclude: string;
|
|
194
|
+
close(): void;
|
|
195
|
+
choose(nodeId: string): void;
|
|
196
|
+
}) {
|
|
197
|
+
const [query, setQuery] = useState("");
|
|
198
|
+
const graph = useQuery({
|
|
199
|
+
queryKey: ["knowledge-graph"],
|
|
200
|
+
queryFn: () => data.getKnowledgeGraph(),
|
|
201
|
+
});
|
|
202
|
+
const candidates = (graph.data?.nodes ?? [])
|
|
203
|
+
.filter((node: KnowledgeNode) => node.id !== exclude && node.kind !== "folder")
|
|
204
|
+
.filter((node: KnowledgeNode) => node.title.toLowerCase().includes(query.trim().toLowerCase()))
|
|
205
|
+
.slice(0, 50);
|
|
206
|
+
|
|
207
|
+
return (
|
|
208
|
+
<Modal title={i18n.t("knowledge.link.insert")} close={close}>
|
|
209
|
+
<label className="block text-sm font-medium">
|
|
210
|
+
{i18n.t("knowledge.link.search")}
|
|
211
|
+
<input
|
|
212
|
+
// biome-ignore lint/a11y/noAutofocus: the dialog opens from a keystroke to take this term
|
|
213
|
+
autoFocus
|
|
214
|
+
value={query}
|
|
215
|
+
onChange={(event) => setQuery(event.target.value)}
|
|
216
|
+
className="mt-2 w-full rounded-md border bg-background px-3 py-2 outline-none focus-visible:ring-2 focus-visible:ring-ring"
|
|
217
|
+
/>
|
|
218
|
+
</label>
|
|
219
|
+
<ul className="mt-4 max-h-64 space-y-1 overflow-y-auto">
|
|
220
|
+
{graph.isPending ? (
|
|
221
|
+
<li className="text-sm text-muted-foreground">{i18n.t("common.loading")}</li>
|
|
222
|
+
) : null}
|
|
223
|
+
{candidates.map((node: KnowledgeNode) => (
|
|
224
|
+
<li key={node.id}>
|
|
225
|
+
<button
|
|
226
|
+
type="button"
|
|
227
|
+
onClick={() => choose(node.id)}
|
|
228
|
+
className="w-full rounded-md px-3 py-2 text-left text-sm outline-none hover:bg-muted focus-visible:ring-2 focus-visible:ring-ring"
|
|
229
|
+
>
|
|
230
|
+
{node.title}
|
|
231
|
+
</button>
|
|
232
|
+
</li>
|
|
233
|
+
))}
|
|
234
|
+
{!graph.isPending && candidates.length === 0 ? (
|
|
235
|
+
<li className="text-sm text-muted-foreground">{i18n.t("knowledge.link.noMatches")}</li>
|
|
236
|
+
) : null}
|
|
237
|
+
</ul>
|
|
238
|
+
</Modal>
|
|
239
|
+
);
|
|
240
|
+
}
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import type {
|
|
1
|
+
import type { RelationGraph } from "@anchrd/intel-contract";
|
|
2
2
|
import Color from "colorjs.io";
|
|
3
3
|
import { MultiDirectedGraph } from "graphology";
|
|
4
4
|
|
|
@@ -19,12 +19,21 @@ export function resolveGraphColor(...tokens: string[]): string | undefined {
|
|
|
19
19
|
return undefined;
|
|
20
20
|
}
|
|
21
21
|
|
|
22
|
-
|
|
22
|
+
/**
|
|
23
|
+
* What accesses what, as Sigma draws it (#19).
|
|
24
|
+
*
|
|
25
|
+
* ⚠️ This builds only what it is given, and it is given only what the server let through. An edge
|
|
26
|
+
* whose endpoint is missing is dropped rather than drawn to an invented node: a placeholder for
|
|
27
|
+
* something the user may not see would give away that it exists, which is the one thing the graph
|
|
28
|
+
* must never do. The check is here as well as on the server because this is the last place before
|
|
29
|
+
* the pixels.
|
|
30
|
+
*/
|
|
31
|
+
export function createRelationGraph(data: RelationGraph): MultiDirectedGraph {
|
|
23
32
|
const graph = new MultiDirectedGraph();
|
|
24
|
-
const
|
|
25
|
-
const
|
|
26
|
-
const
|
|
27
|
-
const
|
|
33
|
+
const flowColor = resolveGraphColor("--primary", "--foreground");
|
|
34
|
+
const documentColor = resolveGraphColor("--secondary-foreground", "--foreground");
|
|
35
|
+
const outsideColor = resolveGraphColor("--muted-foreground", "--foreground");
|
|
36
|
+
const edgeColor = resolveGraphColor("--border", "--muted-foreground");
|
|
28
37
|
const count = Math.max(1, data.nodes.length);
|
|
29
38
|
for (const [index, node] of data.nodes.entries()) {
|
|
30
39
|
const angle = index * Math.PI * (3 - Math.sqrt(5));
|
|
@@ -33,26 +42,19 @@ export function createKnowledgeGraph(data: KnowledgeGraphData): MultiDirectedGra
|
|
|
33
42
|
label: node.title,
|
|
34
43
|
x: Math.cos(angle) * radius,
|
|
35
44
|
y: Math.sin(angle) * radius,
|
|
36
|
-
size: node.kind === "
|
|
37
|
-
|
|
45
|
+
size: node.kind === "flow" ? 10 : node.kind === "folder" ? 8 : 7,
|
|
46
|
+
// What is filed in this level and what it merely reaches into are told apart by colour, so a
|
|
47
|
+
// document pulled in from another folder does not read as part of it.
|
|
48
|
+
color: !node.inScope ? outsideColor : node.kind === "flow" ? flowColor : documentColor,
|
|
38
49
|
});
|
|
39
50
|
}
|
|
40
|
-
for (const
|
|
41
|
-
if (
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
}
|
|
47
|
-
}
|
|
48
|
-
for (const link of data.links) {
|
|
49
|
-
if (graph.hasNode(link.sourceNodeId) && graph.hasNode(link.targetNodeId)) {
|
|
50
|
-
graph.addDirectedEdgeWithKey(link.id, link.sourceNodeId, link.targetNodeId, {
|
|
51
|
-
label: link.label ?? link.relation.replaceAll("_", " "),
|
|
52
|
-
size: 2,
|
|
53
|
-
color: primary,
|
|
54
|
-
});
|
|
55
|
-
}
|
|
51
|
+
for (const edge of data.edges) {
|
|
52
|
+
if (!graph.hasNode(edge.source) || !graph.hasNode(edge.target)) continue;
|
|
53
|
+
graph.addDirectedEdgeWithKey(edge.id, edge.source, edge.target, {
|
|
54
|
+
label: edge.relation,
|
|
55
|
+
size: edge.relation === "calls" ? 2.5 : 1.5,
|
|
56
|
+
color: edge.relation === "calls" ? flowColor : edgeColor,
|
|
57
|
+
});
|
|
56
58
|
}
|
|
57
59
|
return graph;
|
|
58
60
|
}
|
|
@@ -1,10 +1,9 @@
|
|
|
1
|
-
import type {
|
|
1
|
+
import type { RelationGraph } from "@anchrd/intel-contract";
|
|
2
2
|
import { ControlsContainer, SigmaContainer, useCamera, useRegisterEvents } from "@react-sigma/core";
|
|
3
3
|
import { LocateFixed, Minus, Plus } from "lucide-react";
|
|
4
4
|
import { useEffect, useMemo } from "react";
|
|
5
5
|
import type { I18n } from "@/i18n/i18n.types.ts";
|
|
6
|
-
import {
|
|
7
|
-
import { createKnowledgeGraph, resolveGraphColor } from "./knowledge-graph.ts";
|
|
6
|
+
import { createRelationGraph, resolveGraphColor } from "./knowledge-graph.ts";
|
|
8
7
|
|
|
9
8
|
function labelColors() {
|
|
10
9
|
const label = resolveGraphColor("--foreground");
|
|
@@ -59,34 +58,39 @@ function GraphControls({ i18n }: { i18n: I18n }) {
|
|
|
59
58
|
);
|
|
60
59
|
}
|
|
61
60
|
|
|
62
|
-
|
|
61
|
+
/**
|
|
62
|
+
* The graph half of the editor ↔ graph switch (#19): one level of the shared tree, its documents and
|
|
63
|
+
* flows side by side, and the edges between them.
|
|
64
|
+
*
|
|
65
|
+
* Clicking a node navigates to it — the graph is a way through the product, not a picture of it.
|
|
66
|
+
*/
|
|
67
|
+
export function RelationGraphView({
|
|
63
68
|
data,
|
|
64
69
|
i18n,
|
|
65
|
-
close,
|
|
66
70
|
select,
|
|
67
71
|
}: {
|
|
68
|
-
data:
|
|
72
|
+
data: RelationGraph;
|
|
69
73
|
i18n: I18n;
|
|
70
|
-
|
|
71
|
-
select(nodeId: string): void;
|
|
74
|
+
select(node: RelationGraph["nodes"][number]): void;
|
|
72
75
|
}) {
|
|
73
|
-
const graph = useMemo(() =>
|
|
76
|
+
const graph = useMemo(() => createRelationGraph(data), [data]);
|
|
77
|
+
const byId = useMemo(() => new Map(data.nodes.map((node) => [node.id, node])), [data.nodes]);
|
|
74
78
|
return (
|
|
75
|
-
<
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
79
|
+
<section aria-label={i18n.t("view.graph")} className="flex min-h-0 flex-1 flex-col">
|
|
80
|
+
<p className="border-b px-6 py-3 text-sm text-muted-foreground">
|
|
81
|
+
{i18n.t("view.graphDescription", { nodes: data.nodes.length, edges: data.edges.length })}
|
|
82
|
+
{/* ⚠️ The cut-off is said out loud. A graph that quietly stopped drawing would read as "this
|
|
83
|
+
folder holds nothing else", which is worse than a graph that admits it is partial. */}
|
|
84
|
+
{data.omitted > 0 ? (
|
|
85
|
+
<span className="ml-2 rounded-md border px-2 py-0.5 text-xs">
|
|
86
|
+
{i18n.t("view.graphTruncated", { omitted: data.omitted, limit: data.limit })}
|
|
87
|
+
</span>
|
|
88
|
+
) : null}
|
|
85
89
|
</p>
|
|
86
|
-
<div className="min-h-0 flex-1
|
|
90
|
+
<div className="min-h-0 flex-1 bg-muted/20">
|
|
87
91
|
{data.nodes.length === 0 ? (
|
|
88
|
-
<div className="grid h-full place-items-center text-sm text-muted-foreground">
|
|
89
|
-
{i18n.t("
|
|
92
|
+
<div className="grid h-full place-items-center p-8 text-sm text-muted-foreground">
|
|
93
|
+
{i18n.t("view.graphEmpty")}
|
|
90
94
|
</div>
|
|
91
95
|
) : (
|
|
92
96
|
<SigmaContainer
|
|
@@ -101,11 +105,16 @@ export function KnowledgeGraphView({
|
|
|
101
105
|
...labelColors(),
|
|
102
106
|
}}
|
|
103
107
|
>
|
|
104
|
-
<GraphEvents
|
|
108
|
+
<GraphEvents
|
|
109
|
+
select={(nodeId) => {
|
|
110
|
+
const node = byId.get(nodeId);
|
|
111
|
+
if (node) select(node);
|
|
112
|
+
}}
|
|
113
|
+
/>
|
|
105
114
|
<GraphControls i18n={i18n} />
|
|
106
115
|
</SigmaContainer>
|
|
107
116
|
)}
|
|
108
117
|
</div>
|
|
109
|
-
</
|
|
118
|
+
</section>
|
|
110
119
|
);
|
|
111
120
|
}
|
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
import type { KnowledgeNode } from "@anchrd/intel-contract";
|
|
2
|
+
import { useMutation, useQuery } from "@tanstack/react-query";
|
|
3
|
+
import { Download } from "lucide-react";
|
|
4
|
+
import { ActionSlot } from "@/app/action-slot/action-slot.tsx";
|
|
5
|
+
import { useIntelRouterContext } from "@/router/router-context.ts";
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* A table as a grid (#40).
|
|
9
|
+
*
|
|
10
|
+
* Reading is the whole of the first cut: there is no cell editor, and rows arrive through
|
|
11
|
+
* `knowledge_table_append` — the point of the kind is that an agent fills it without a person
|
|
12
|
+
* retyping anything.
|
|
13
|
+
*
|
|
14
|
+
* ⚠️ The rows are the server's answer, parsed by the server's one CSV reader. A second reader here
|
|
15
|
+
* would eventually disagree with it about a quoted comma, and the grid would then show something
|
|
16
|
+
* the export does not contain.
|
|
17
|
+
*/
|
|
18
|
+
export function KnowledgeTablePanel({ node }: { node: KnowledgeNode }) {
|
|
19
|
+
const { data, i18n } = useIntelRouterContext();
|
|
20
|
+
const table = useQuery({
|
|
21
|
+
queryKey: ["knowledge-table", node.id],
|
|
22
|
+
queryFn: () => data.getKnowledgeTable(node.id),
|
|
23
|
+
});
|
|
24
|
+
// CSV is the export. The canonical body is already CSV, so downloading is handing over the bytes
|
|
25
|
+
// that are stored rather than rendering a second format that could drift from them.
|
|
26
|
+
const download = useMutation({
|
|
27
|
+
mutationFn: async () => {
|
|
28
|
+
const document_ = await data.getKnowledge(node.id);
|
|
29
|
+
return new Blob([document_.content ?? ""], { type: "text/csv;charset=utf-8" });
|
|
30
|
+
},
|
|
31
|
+
onSuccess: (blob) => {
|
|
32
|
+
const url = URL.createObjectURL(blob);
|
|
33
|
+
const anchor = document.createElement("a");
|
|
34
|
+
anchor.href = url;
|
|
35
|
+
anchor.download = node.title.toLowerCase().endsWith(".csv")
|
|
36
|
+
? node.title
|
|
37
|
+
: `${node.title}.csv`;
|
|
38
|
+
document.body.append(anchor);
|
|
39
|
+
anchor.click();
|
|
40
|
+
anchor.remove();
|
|
41
|
+
setTimeout(() => URL.revokeObjectURL(url), 0);
|
|
42
|
+
},
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
return (
|
|
46
|
+
<div className="flex min-h-0 flex-1 flex-col">
|
|
47
|
+
{/* ⚠️ Both of these used to be a second bar of their own, directly under the title line (#54):
|
|
48
|
+
two headers stacked, and the table starting a whole row lower for it. A table is one
|
|
49
|
+
Knowledge kind among four, not a screen with its own chrome — so the count goes beside the
|
|
50
|
+
title as a quiet word and the export joins the buttons in the title line. They are rendered
|
|
51
|
+
from here rather than from the screen because this is where the query lives; the screen
|
|
52
|
+
would otherwise have to load a table it does not show. */}
|
|
53
|
+
<ActionSlot name="title-meta">
|
|
54
|
+
{table.data
|
|
55
|
+
? i18n.t("knowledge.table.summary", {
|
|
56
|
+
rows: table.data.rows.length,
|
|
57
|
+
columns: table.data.columns.length,
|
|
58
|
+
})
|
|
59
|
+
: ""}
|
|
60
|
+
</ActionSlot>
|
|
61
|
+
<ActionSlot name="title-actions">
|
|
62
|
+
<button
|
|
63
|
+
type="button"
|
|
64
|
+
onClick={() => download.mutate()}
|
|
65
|
+
// Unchanged: nothing to export before the header is known, and a button that answers with
|
|
66
|
+
// an empty file is worse than one that says it is not ready.
|
|
67
|
+
disabled={download.isPending || !table.data?.columns.length}
|
|
68
|
+
className="inline-flex h-8 items-center gap-2 rounded-md border px-3 text-sm outline-none hover:bg-muted focus-visible:ring-2 focus-visible:ring-ring disabled:opacity-50"
|
|
69
|
+
>
|
|
70
|
+
<Download aria-hidden="true" className="size-4" />
|
|
71
|
+
{i18n.t("knowledge.table.download")}
|
|
72
|
+
</button>
|
|
73
|
+
</ActionSlot>
|
|
74
|
+
{/* ⚠️ The refusal stays down here, in the body, where a sentence has room to be read. The
|
|
75
|
+
title line has none — a failed export that only greyed a button in a header would be a
|
|
76
|
+
failure nobody is told about. */}
|
|
77
|
+
{download.isError ? (
|
|
78
|
+
<p role="alert" className="mx-6 mt-4 text-sm text-destructive">
|
|
79
|
+
{i18n.t("knowledge.downloadFailed")}
|
|
80
|
+
</p>
|
|
81
|
+
) : null}
|
|
82
|
+
{table.isPending ? (
|
|
83
|
+
<p className="p-6 text-sm text-muted-foreground">{i18n.t("common.loading")}</p>
|
|
84
|
+
) : table.isError ? (
|
|
85
|
+
<div role="alert" className="space-y-3 p-6 text-sm">
|
|
86
|
+
<p className="text-destructive">{i18n.t("knowledge.loadFailed")}</p>
|
|
87
|
+
<button
|
|
88
|
+
type="button"
|
|
89
|
+
onClick={() => void table.refetch()}
|
|
90
|
+
className="rounded-md border px-3 py-2 text-sm outline-none hover:bg-muted focus-visible:ring-2 focus-visible:ring-ring"
|
|
91
|
+
>
|
|
92
|
+
{i18n.t("common.retry")}
|
|
93
|
+
</button>
|
|
94
|
+
</div>
|
|
95
|
+
) : table.data.columns.length === 0 ? (
|
|
96
|
+
<p className="grid flex-1 place-items-center p-8 text-center text-sm text-muted-foreground">
|
|
97
|
+
{i18n.t("knowledge.table.undefined")}
|
|
98
|
+
</p>
|
|
99
|
+
) : (
|
|
100
|
+
<div className="min-h-0 flex-1 overflow-auto">
|
|
101
|
+
<table className="w-full border-collapse text-sm">
|
|
102
|
+
<caption className="sr-only">{node.title}</caption>
|
|
103
|
+
<thead className="sticky top-0 bg-card">
|
|
104
|
+
<tr>
|
|
105
|
+
{table.data.columns.map((column) => (
|
|
106
|
+
// Column names are distinct by contract (`DefineKnowledgeTableInput`), so the
|
|
107
|
+
// name is the key and nothing has to fall back to a position.
|
|
108
|
+
<th
|
|
109
|
+
key={column}
|
|
110
|
+
scope="col"
|
|
111
|
+
className="border-b border-r px-3 py-2 text-left font-medium whitespace-nowrap"
|
|
112
|
+
>
|
|
113
|
+
{column}
|
|
114
|
+
</th>
|
|
115
|
+
))}
|
|
116
|
+
</tr>
|
|
117
|
+
</thead>
|
|
118
|
+
<tbody>
|
|
119
|
+
{table.data.rows.map((row, rowIndex) => (
|
|
120
|
+
// ⚠️ Rows carry no identity of their own. A table is an append-only list of text
|
|
121
|
+
// and its position is the only thing that names a row — there is no id to key on,
|
|
122
|
+
// and inventing one would be inventing data.
|
|
123
|
+
// biome-ignore lint/suspicious/noArrayIndexKey: an appended row has no other key
|
|
124
|
+
<tr key={rowIndex} className="even:bg-muted/40">
|
|
125
|
+
{table.data.columns.map((column, cellIndex) => (
|
|
126
|
+
<td key={column} className="border-b border-r px-3 py-2 align-top">
|
|
127
|
+
{row[cellIndex] ?? ""}
|
|
128
|
+
</td>
|
|
129
|
+
))}
|
|
130
|
+
</tr>
|
|
131
|
+
))}
|
|
132
|
+
</tbody>
|
|
133
|
+
</table>
|
|
134
|
+
{table.data.rows.length === 0 ? (
|
|
135
|
+
<p className="p-6 text-sm text-muted-foreground">{i18n.t("knowledge.table.empty")}</p>
|
|
136
|
+
) : null}
|
|
137
|
+
</div>
|
|
138
|
+
)}
|
|
139
|
+
</div>
|
|
140
|
+
);
|
|
141
|
+
}
|
package/src/main.tsx
CHANGED
|
@@ -5,11 +5,11 @@ import { createRoot } from "react-dom/client";
|
|
|
5
5
|
import { createIntelDataProvider } from "@/data/intel-data-provider/intel-data-provider.ts";
|
|
6
6
|
import { createI18n } from "@/i18n/i18n.ts";
|
|
7
7
|
import { createIntelRouter } from "@/router/router.tsx";
|
|
8
|
-
import { watchSystemTheme } from "@/theme/theme.ts";
|
|
8
|
+
import { SystemThemeQuery, watchSystemTheme } from "@/theme/theme.ts";
|
|
9
9
|
import "./styles.css";
|
|
10
10
|
import "./theme/custom.css";
|
|
11
11
|
|
|
12
|
-
watchSystemTheme(document.documentElement, window.matchMedia(
|
|
12
|
+
watchSystemTheme(document.documentElement, window.matchMedia(SystemThemeQuery));
|
|
13
13
|
|
|
14
14
|
const root = document.getElementById("root");
|
|
15
15
|
if (!root) throw new Error("Missing #root element");
|