@anchrd/intel-ui 0.5.0 → 0.7.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.
@@ -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
+ }