@anchrd/intel-ui 0.6.0 → 0.7.2

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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@anchrd/intel-ui",
3
- "version": "0.6.0",
3
+ "version": "0.7.2",
4
4
  "type": "module",
5
5
  "license": "UNLICENSED",
6
6
  "repository": {
@@ -28,7 +28,7 @@
28
28
  "typecheck": "tsc --noEmit"
29
29
  },
30
30
  "dependencies": {
31
- "@anchrd/intel-contract": "^0.2.0",
31
+ "@anchrd/intel-contract": "^0.3.0",
32
32
  "@blocknote/core": "^0.52.1",
33
33
  "@blocknote/react": "^0.52.1",
34
34
  "@blocknote/shadcn": "^0.52.1",
@@ -82,6 +82,11 @@ function levelKey(level: Level): readonly unknown[] {
82
82
  return level.type === "flow" ? ["flow-calls", level.id] : treeLevelKey(level.id);
83
83
  }
84
84
 
85
+ // What a row puts on the clipboard for anybody outside the tree. Its own media type rather than
86
+ // `text/plain`, so a drop target that means "move this row" and one that means "make a node for
87
+ // this" cannot be confused by the same payload.
88
+ export const TreeEntryMediaType = "application/x-intel-tree-entry";
89
+
85
90
  export function AppTree() {
86
91
  const { data, i18n } = useIntelRouterContext();
87
92
  const queryClient = useQueryClient();
@@ -176,7 +181,6 @@ export function AppTree() {
176
181
  kind,
177
182
  title,
178
183
  description: null,
179
- contextPolicy: "relevant",
180
184
  idempotencyKey: crypto.randomUUID(),
181
185
  });
182
186
  // ⚠️ Two calls, because they are two things: the node is a row in the tree, the header is the
@@ -212,7 +216,6 @@ export function AppTree() {
212
216
  kind: "attachment",
213
217
  title: file.name,
214
218
  description: null,
215
- contextPolicy: "relevant",
216
219
  idempotencyKey: crypto.randomUUID(),
217
220
  });
218
221
  try {
@@ -445,8 +448,17 @@ export function AppTree() {
445
448
  isActive={isActive}
446
449
  draggable={!derived}
447
450
  onDragStart={(event) => {
448
- event.dataTransfer.effectAllowed = "move";
451
+ // ⚠️ Two formats, one gesture. `text/plain` is what a drop inside the tree reads —
452
+ // that is a MOVE, and it only ever needed the id. The flow canvas needs the kind as
453
+ // well, to know which node to make, and resolving an id there would mean a second
454
+ // read of something the drag already knows. `effectAllowed` says both are allowed;
455
+ // each drop target picks the one it means (#75).
456
+ event.dataTransfer.effectAllowed = "copyMove";
449
457
  event.dataTransfer.setData("text/plain", entry.id);
458
+ event.dataTransfer.setData(
459
+ TreeEntryMediaType,
460
+ JSON.stringify({ id: entry.id, kind: entry.kind, title: entry.title }),
461
+ );
450
462
  draggedRef.current = entry;
451
463
  setDragged(entry);
452
464
  }}
@@ -0,0 +1,82 @@
1
+ import type * as React from "react";
2
+ import { cn } from "@/lib/utils.ts";
3
+
4
+ // The shadcn table primitive. A real `<table>` and not a grid of divs: the row and column
5
+ // relationships are what a screen reader reads out, and no `role` patch on a div reproduces them
6
+ // as reliably as the element that means it.
7
+ //
8
+ // ⚠️ The horizontal scroll lives on the wrapper, not on the page. A wide table has to be able to
9
+ // scroll inside its own box, or the whole layout scrolls sideways with it.
10
+ function Table({ className, ...props }: React.ComponentProps<"table">) {
11
+ return (
12
+ <div data-slot="table-container" className="relative w-full overflow-x-auto">
13
+ <table
14
+ data-slot="table"
15
+ className={cn("w-full caption-bottom text-sm", className)}
16
+ {...props}
17
+ />
18
+ </div>
19
+ );
20
+ }
21
+
22
+ function TableHeader({ className, ...props }: React.ComponentProps<"thead">) {
23
+ return <thead data-slot="table-header" className={cn("[&_tr]:border-b", className)} {...props} />;
24
+ }
25
+
26
+ function TableBody({ className, ...props }: React.ComponentProps<"tbody">) {
27
+ return (
28
+ <tbody
29
+ data-slot="table-body"
30
+ className={cn("[&_tr:last-child]:border-0", className)}
31
+ {...props}
32
+ />
33
+ );
34
+ }
35
+
36
+ function TableRow({ className, ...props }: React.ComponentProps<"tr">) {
37
+ return (
38
+ <tr
39
+ data-slot="table-row"
40
+ className={cn(
41
+ "border-b transition-colors hover:bg-muted/50 data-[state=selected]:bg-muted",
42
+ className,
43
+ )}
44
+ {...props}
45
+ />
46
+ );
47
+ }
48
+
49
+ function TableHead({ className, ...props }: React.ComponentProps<"th">) {
50
+ return (
51
+ <th
52
+ data-slot="table-head"
53
+ className={cn(
54
+ "h-10 px-2 text-left align-middle font-medium text-muted-foreground whitespace-nowrap",
55
+ className,
56
+ )}
57
+ {...props}
58
+ />
59
+ );
60
+ }
61
+
62
+ function TableCell({ className, ...props }: React.ComponentProps<"td">) {
63
+ return (
64
+ <td
65
+ data-slot="table-cell"
66
+ className={cn("p-2 align-middle whitespace-nowrap", className)}
67
+ {...props}
68
+ />
69
+ );
70
+ }
71
+
72
+ function TableCaption({ className, ...props }: React.ComponentProps<"caption">) {
73
+ return (
74
+ <caption
75
+ data-slot="table-caption"
76
+ className={cn("mt-4 text-sm text-muted-foreground", className)}
77
+ {...props}
78
+ />
79
+ );
80
+ }
81
+
82
+ export { Table, TableBody, TableCaption, TableCell, TableHead, TableHeader, TableRow };
@@ -14,6 +14,7 @@ import {
14
14
  FlowRunHistory,
15
15
  FlowRunList,
16
16
  FlowRunStep,
17
+ FlowValidation,
17
18
  KnowledgeDocument,
18
19
  KnowledgeGraph,
19
20
  KnowledgeLinkList,
@@ -316,6 +317,9 @@ export function createIntelDataProvider(
316
317
  async getFlow(flowId) {
317
318
  return await request(`/flows/${encodeURIComponent(flowId)}`, FlowDocument);
318
319
  },
320
+ async validateFlow(flowId) {
321
+ return await request(`/flows/${encodeURIComponent(flowId)}/validation`, FlowValidation);
322
+ },
319
323
  async listFlowCalls(flowId) {
320
324
  return await request(`/flows/${encodeURIComponent(flowId)}/calls`, FlowList);
321
325
  },
@@ -14,6 +14,7 @@ import type {
14
14
  FlowRunHistory,
15
15
  FlowRunList,
16
16
  FlowRunStep,
17
+ FlowValidation,
17
18
  KnowledgeDocument,
18
19
  KnowledgeGraph,
19
20
  KnowledgeLinkList,
@@ -95,6 +96,8 @@ export interface IntelDataProvider {
95
96
  // What a flow calls, read out of its graph. It answers a different question from `listTreeChildren`
96
97
  // and deliberately gives a different answer: a shared sub-flow is listed under every caller.
97
98
  listFlowCalls(flowId: string): Promise<FlowList>;
99
+ // Would this flow start for me, right now. A question, not an action: nothing is created (#72).
100
+ validateFlow(flowId: string): Promise<FlowValidation>;
98
101
  // What accesses what, for one level of the shared tree: a folder for its contents, a flow for
99
102
  // itself. ⚠️ Only nodes the signed-in user may see come back, and one they may not is absent
100
103
  // altogether — never a placeholder, because the edge into one would already say that it exists.
@@ -0,0 +1,166 @@
1
+ import type { KnowledgeNode, KnowledgeNodeKind } from "@anchrd/intel-contract";
2
+ import { useQuery } from "@tanstack/react-query";
3
+ import { ChevronRight, Folder, Search } from "lucide-react";
4
+ import { useMemo, useState } from "react";
5
+ import { useIntelRouterContext } from "@/router/router-context.ts";
6
+
7
+ /**
8
+ * One picker for everything a node can name: search by name, or click down through the tree from
9
+ * the top-level folders.
10
+ *
11
+ * ⚠️ Both halves read ONE source — the authorized flat graph the editor already loads. Not because
12
+ * a search endpoint would be hard, but because `searchKnowledge` answers a different question: it
13
+ * searches PASSAGES and hands back citations. Someone looking for a document by name would get
14
+ * results ranked by what is written inside it, which is the wrong list in a picker. Filtering names
15
+ * here is also instant, and it cannot disagree with the tree about what exists.
16
+ *
17
+ * ⚠️ What the caller may not see is absent from the graph, so it is absent here — and an empty
18
+ * result never says which of the two it is (#17, #19).
19
+ */
20
+ export function EntryPicker({
21
+ kind,
22
+ value,
23
+ onSelect,
24
+ label,
25
+ }: {
26
+ // Which kind of node may be chosen. `null` offers every kind — what the Flow step uses.
27
+ kind: KnowledgeNodeKind | null;
28
+ value: string;
29
+ onSelect(nodeId: string): void;
30
+ label: string;
31
+ }) {
32
+ const { data, i18n } = useIntelRouterContext();
33
+ const [query, setQuery] = useState("");
34
+ const [openFolder, setOpenFolder] = useState<string | null>(null);
35
+ const graph = useQuery({
36
+ queryKey: ["knowledge-graph"],
37
+ queryFn: () => data.getKnowledgeGraph(),
38
+ });
39
+
40
+ const nodes = useMemo(() => graph.data?.nodes ?? [], [graph.data]);
41
+ const eligible = useMemo(
42
+ () => nodes.filter((node) => kind === null || node.kind === kind),
43
+ [nodes, kind],
44
+ );
45
+
46
+ // Searching looks at every eligible node wherever it sits; browsing looks at one level. The two
47
+ // are the same list seen two ways, which is why a result can be picked from either.
48
+ const searching = query.trim().length > 0;
49
+ const shown = useMemo(() => {
50
+ if (searching) {
51
+ const needle = query.trim().toLowerCase();
52
+ return eligible.filter((node) => node.title.toLowerCase().includes(needle)).slice(0, 50);
53
+ }
54
+ return nodes.filter((node) => node.parentId === openFolder);
55
+ }, [searching, query, eligible, nodes, openFolder]);
56
+
57
+ // The way back up, as the chain of folders that leads to the open one.
58
+ const trail = useMemo(() => {
59
+ const chain: KnowledgeNode[] = [];
60
+ let current = openFolder;
61
+ while (current !== null) {
62
+ const folder = nodes.find((node) => node.id === current);
63
+ if (!folder) break;
64
+ chain.unshift(folder);
65
+ current = folder.parentId;
66
+ }
67
+ return chain;
68
+ }, [openFolder, nodes]);
69
+
70
+ const chosen = nodes.find((node) => node.id === value) ?? null;
71
+
72
+ return (
73
+ <div className="mt-4">
74
+ <span className="block text-sm font-medium">{label}</span>
75
+ {chosen ? (
76
+ <p className="mt-1 truncate text-sm text-muted-foreground">{chosen.title}</p>
77
+ ) : (
78
+ <p className="mt-1 text-sm text-muted-foreground">{i18n.t("flows.linkEmpty")}</p>
79
+ )}
80
+
81
+ <div className="mt-2 rounded-lg border">
82
+ <label className="flex items-center gap-2 border-b px-2 py-1.5">
83
+ <Search aria-hidden="true" className="size-4 shrink-0 text-muted-foreground" />
84
+ <span className="sr-only">{i18n.t("picker.search")}</span>
85
+ <input
86
+ type="search"
87
+ value={query}
88
+ onChange={(event) => setQuery(event.target.value)}
89
+ placeholder={i18n.t("picker.search")}
90
+ className="w-full bg-transparent text-sm outline-none"
91
+ />
92
+ </label>
93
+
94
+ {/* The trail is hidden while searching: a result can come from anywhere, so "where you are"
95
+ would be a claim about a place the list is no longer showing. */}
96
+ {!searching && trail.length > 0 ? (
97
+ <div className="flex flex-wrap items-center gap-1 border-b px-2 py-1 text-xs">
98
+ <button
99
+ type="button"
100
+ onClick={() => setOpenFolder(null)}
101
+ className="rounded px-1 outline-none hover:underline focus-visible:ring-2 focus-visible:ring-ring"
102
+ >
103
+ {i18n.t("picker.top")}
104
+ </button>
105
+ {trail.map((folder) => (
106
+ <span key={folder.id} className="flex items-center gap-1">
107
+ <ChevronRight aria-hidden="true" className="size-3 text-muted-foreground" />
108
+ <button
109
+ type="button"
110
+ onClick={() => setOpenFolder(folder.id)}
111
+ className="rounded px-1 outline-none hover:underline focus-visible:ring-2 focus-visible:ring-ring"
112
+ >
113
+ {folder.title}
114
+ </button>
115
+ </span>
116
+ ))}
117
+ </div>
118
+ ) : null}
119
+
120
+ <ul className="max-h-56 overflow-y-auto py-1">
121
+ {shown.map((node) => {
122
+ // A folder is two things while browsing: somewhere to go, and — when folders are what
123
+ // is being picked — something to choose. It gets both, side by side, rather than one
124
+ // control that has to guess which was meant.
125
+ const canOpen = node.kind === "folder" && !searching;
126
+ const canPick = kind === null || node.kind === kind;
127
+ return (
128
+ <li key={node.id} className="flex items-center gap-1 px-1">
129
+ {canPick ? (
130
+ <button
131
+ type="button"
132
+ onClick={() => onSelect(node.id)}
133
+ aria-current={node.id === value}
134
+ className={`min-w-0 flex-1 truncate rounded px-2 py-1.5 text-left text-sm outline-none hover:bg-muted focus-visible:ring-2 focus-visible:ring-ring ${
135
+ node.id === value ? "bg-muted font-medium" : ""
136
+ }`}
137
+ >
138
+ {node.title}
139
+ <span className="sr-only"> — {i18n.t(`knowledge.kind.${node.kind}`)}</span>
140
+ </button>
141
+ ) : (
142
+ <span className="min-w-0 flex-1 truncate px-2 py-1.5 text-sm text-muted-foreground">
143
+ {node.title}
144
+ </span>
145
+ )}
146
+ {canOpen ? (
147
+ <button
148
+ type="button"
149
+ onClick={() => setOpenFolder(node.id)}
150
+ aria-label={i18n.t("picker.open", { title: node.title })}
151
+ className="inline-flex size-7 shrink-0 items-center justify-center rounded outline-none hover:bg-muted focus-visible:ring-2 focus-visible:ring-ring"
152
+ >
153
+ <Folder aria-hidden="true" className="size-4" />
154
+ </button>
155
+ ) : null}
156
+ </li>
157
+ );
158
+ })}
159
+ {shown.length === 0 ? (
160
+ <li className="px-3 py-2 text-sm text-muted-foreground">{i18n.t("picker.nothing")}</li>
161
+ ) : null}
162
+ </ul>
163
+ </div>
164
+ </div>
165
+ );
166
+ }