@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,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 type { PartialBlock } from "@blocknote/core";
7
- import { useCreateBlockNote } from "@blocknote/react";
8
- import { useMutation } from "@tanstack/react-query";
9
- import { Save } from "lucide-react";
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 { BlockNoteView } from "@/blocknote-view/blocknote-view.tsx";
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): PartialBlock[] | undefined {
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 PartialBlock[]) : undefined;
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
- ...(initialBlocks?.length ? { initialContent: initialBlocks } : {}),
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
- <div className="flex items-center justify-end border-b px-4 py-2">
72
- <button
73
- type="button"
74
- disabled={!dirty || save.isPending}
75
- onClick={() => save.mutate()}
76
- 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 disabled:cursor-not-allowed disabled:opacity-50"
77
- >
78
- <Save aria-hidden="true" className="size-4" />
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
- <BlockNoteView editor={editor} onChange={() => setDirty(true)} />
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 { KnowledgeGraph as KnowledgeGraphData } from "@anchrd/intel-contract";
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
- export function createKnowledgeGraph(data: KnowledgeGraphData): MultiDirectedGraph {
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 primary = resolveGraphColor("--primary", "--foreground");
25
- const secondary = resolveGraphColor("--secondary-foreground", "--foreground");
26
- const muted = resolveGraphColor("--muted-foreground", "--foreground");
27
- const edge = resolveGraphColor("--border", "--muted-foreground");
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 === "folder" ? 11 : node.kind === "attachment" ? 6 : 8,
37
- color: node.kind === "folder" ? secondary : node.kind === "attachment" ? muted : primary,
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 node of data.nodes) {
41
- if (node.parentId && graph.hasNode(node.parentId)) {
42
- graph.addDirectedEdgeWithKey(`tree:${node.id}`, node.parentId, node.id, {
43
- size: 0.6,
44
- color: edge,
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 { KnowledgeGraph as KnowledgeGraphData } from "@anchrd/intel-contract";
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 { Modal } from "@/modal/modal.tsx";
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
- export function KnowledgeGraphView({
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: KnowledgeGraphData;
72
+ data: RelationGraph;
69
73
  i18n: I18n;
70
- close(): void;
71
- select(nodeId: string): void;
74
+ select(node: RelationGraph["nodes"][number]): void;
72
75
  }) {
73
- const graph = useMemo(() => createKnowledgeGraph(data), [data]);
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
- <Modal
76
- title={i18n.t("knowledge.graph")}
77
- close={close}
78
- className="h-[calc(100vh-2rem)] max-w-none"
79
- >
80
- <p className="mb-4 text-sm text-muted-foreground">
81
- {i18n.t("knowledge.graphDescription", {
82
- nodes: data.nodes.length,
83
- links: data.links.length,
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 overflow-hidden rounded-lg border bg-muted/20">
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("knowledge.empty")}
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 select={select} />
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
- </Modal>
118
+ </section>
110
119
  );
111
120
  }
@@ -0,0 +1,129 @@
1
+ import type { KnowledgeNode } from "@anchrd/intel-contract";
2
+ import { useMutation, useQuery } from "@tanstack/react-query";
3
+ import { Download } from "lucide-react";
4
+ import { useIntelRouterContext } from "@/router/router-context.ts";
5
+
6
+ /**
7
+ * A table as a grid (#40).
8
+ *
9
+ * Reading is the whole of the first cut: there is no cell editor, and rows arrive through
10
+ * `knowledge_table_append` — the point of the kind is that an agent fills it without a person
11
+ * retyping anything.
12
+ *
13
+ * ⚠️ The rows are the server's answer, parsed by the server's one CSV reader. A second reader here
14
+ * would eventually disagree with it about a quoted comma, and the grid would then show something
15
+ * the export does not contain.
16
+ */
17
+ export function KnowledgeTablePanel({ node }: { node: KnowledgeNode }) {
18
+ const { data, i18n } = useIntelRouterContext();
19
+ const table = useQuery({
20
+ queryKey: ["knowledge-table", node.id],
21
+ queryFn: () => data.getKnowledgeTable(node.id),
22
+ });
23
+ // CSV is the export. The canonical body is already CSV, so downloading is handing over the bytes
24
+ // that are stored rather than rendering a second format that could drift from them.
25
+ const download = useMutation({
26
+ mutationFn: async () => {
27
+ const document_ = await data.getKnowledge(node.id);
28
+ return new Blob([document_.content ?? ""], { type: "text/csv;charset=utf-8" });
29
+ },
30
+ onSuccess: (blob) => {
31
+ const url = URL.createObjectURL(blob);
32
+ const anchor = document.createElement("a");
33
+ anchor.href = url;
34
+ anchor.download = node.title.toLowerCase().endsWith(".csv")
35
+ ? node.title
36
+ : `${node.title}.csv`;
37
+ document.body.append(anchor);
38
+ anchor.click();
39
+ anchor.remove();
40
+ setTimeout(() => URL.revokeObjectURL(url), 0);
41
+ },
42
+ });
43
+
44
+ return (
45
+ <div className="flex min-h-0 flex-1 flex-col">
46
+ <div className="flex items-center justify-between gap-4 border-b px-4 py-2">
47
+ <p className="text-sm text-muted-foreground">
48
+ {table.data
49
+ ? i18n.t("knowledge.table.summary", {
50
+ rows: table.data.rows.length,
51
+ columns: table.data.columns.length,
52
+ })
53
+ : ""}
54
+ </p>
55
+ <button
56
+ type="button"
57
+ onClick={() => download.mutate()}
58
+ disabled={download.isPending || !table.data?.columns.length}
59
+ className="inline-flex items-center gap-2 rounded-md border px-3 py-2 text-sm outline-none hover:bg-muted focus-visible:ring-2 focus-visible:ring-ring disabled:opacity-50"
60
+ >
61
+ <Download aria-hidden="true" className="size-4" />
62
+ {i18n.t("knowledge.table.download")}
63
+ </button>
64
+ </div>
65
+ {download.isError ? (
66
+ <p role="alert" className="mx-6 mt-4 text-sm text-destructive">
67
+ {i18n.t("knowledge.downloadFailed")}
68
+ </p>
69
+ ) : null}
70
+ {table.isPending ? (
71
+ <p className="p-6 text-sm text-muted-foreground">{i18n.t("common.loading")}</p>
72
+ ) : table.isError ? (
73
+ <div role="alert" className="space-y-3 p-6 text-sm">
74
+ <p className="text-destructive">{i18n.t("knowledge.loadFailed")}</p>
75
+ <button
76
+ type="button"
77
+ onClick={() => void table.refetch()}
78
+ className="rounded-md border px-3 py-2 text-sm outline-none hover:bg-muted focus-visible:ring-2 focus-visible:ring-ring"
79
+ >
80
+ {i18n.t("common.retry")}
81
+ </button>
82
+ </div>
83
+ ) : table.data.columns.length === 0 ? (
84
+ <p className="grid flex-1 place-items-center p-8 text-center text-sm text-muted-foreground">
85
+ {i18n.t("knowledge.table.undefined")}
86
+ </p>
87
+ ) : (
88
+ <div className="min-h-0 flex-1 overflow-auto">
89
+ <table className="w-full border-collapse text-sm">
90
+ <caption className="sr-only">{node.title}</caption>
91
+ <thead className="sticky top-0 bg-card">
92
+ <tr>
93
+ {table.data.columns.map((column) => (
94
+ // Column names are distinct by contract (`DefineKnowledgeTableInput`), so the
95
+ // name is the key and nothing has to fall back to a position.
96
+ <th
97
+ key={column}
98
+ scope="col"
99
+ className="border-b border-r px-3 py-2 text-left font-medium whitespace-nowrap"
100
+ >
101
+ {column}
102
+ </th>
103
+ ))}
104
+ </tr>
105
+ </thead>
106
+ <tbody>
107
+ {table.data.rows.map((row, rowIndex) => (
108
+ // ⚠️ Rows carry no identity of their own. A table is an append-only list of text
109
+ // and its position is the only thing that names a row — there is no id to key on,
110
+ // and inventing one would be inventing data.
111
+ // biome-ignore lint/suspicious/noArrayIndexKey: an appended row has no other key
112
+ <tr key={rowIndex} className="even:bg-muted/40">
113
+ {table.data.columns.map((column, cellIndex) => (
114
+ <td key={column} className="border-b border-r px-3 py-2 align-top">
115
+ {row[cellIndex] ?? ""}
116
+ </td>
117
+ ))}
118
+ </tr>
119
+ ))}
120
+ </tbody>
121
+ </table>
122
+ {table.data.rows.length === 0 ? (
123
+ <p className="p-6 text-sm text-muted-foreground">{i18n.t("knowledge.table.empty")}</p>
124
+ ) : null}
125
+ </div>
126
+ )}
127
+ </div>
128
+ );
129
+ }
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("(prefers-color-scheme: dark)"));
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");