@schlessera/brain-ui-react 0.6.3 → 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.
Files changed (60) hide show
  1. package/dist/components/graph/graph-canvas.d.ts +20 -0
  2. package/dist/components/graph/graph-canvas.d.ts.map +1 -0
  3. package/dist/components/graph/graph-canvas.js +310 -0
  4. package/dist/components/graph/graph-canvas.js.map +1 -0
  5. package/dist/components/graph/graph-controls.d.ts +8 -0
  6. package/dist/components/graph/graph-controls.d.ts.map +1 -0
  7. package/dist/components/graph/graph-controls.js +132 -0
  8. package/dist/components/graph/graph-controls.js.map +1 -0
  9. package/dist/components/graph/graph-empty-state.d.ts +15 -0
  10. package/dist/components/graph/graph-empty-state.d.ts.map +1 -0
  11. package/dist/components/graph/graph-empty-state.js +21 -0
  12. package/dist/components/graph/graph-empty-state.js.map +1 -0
  13. package/dist/components/graph/graph-page.d.ts +7 -0
  14. package/dist/components/graph/graph-page.d.ts.map +1 -0
  15. package/dist/components/graph/graph-page.js +400 -0
  16. package/dist/components/graph/graph-page.js.map +1 -0
  17. package/dist/components/graph/lib/graph-helpers.d.ts +90 -0
  18. package/dist/components/graph/lib/graph-helpers.d.ts.map +1 -0
  19. package/dist/components/graph/lib/graph-helpers.js +217 -0
  20. package/dist/components/graph/lib/graph-helpers.js.map +1 -0
  21. package/dist/components/graph/node-popover.d.ts +11 -0
  22. package/dist/components/graph/node-popover.d.ts.map +1 -0
  23. package/dist/components/graph/node-popover.js +38 -0
  24. package/dist/components/graph/node-popover.js.map +1 -0
  25. package/dist/components/graph/use-graph-theme.d.ts +16 -0
  26. package/dist/components/graph/use-graph-theme.d.ts.map +1 -0
  27. package/dist/components/graph/use-graph-theme.js +36 -0
  28. package/dist/components/graph/use-graph-theme.js.map +1 -0
  29. package/dist/components/layout/mobile-tab-bar.d.ts.map +1 -1
  30. package/dist/components/layout/mobile-tab-bar.js +16 -5
  31. package/dist/components/layout/mobile-tab-bar.js.map +1 -1
  32. package/dist/components/layout/side-rail.d.ts.map +1 -1
  33. package/dist/components/layout/side-rail.js +14 -4
  34. package/dist/components/layout/side-rail.js.map +1 -1
  35. package/dist/index.d.ts +3 -1
  36. package/dist/index.d.ts.map +1 -1
  37. package/dist/index.js +2 -0
  38. package/dist/index.js.map +1 -1
  39. package/dist/stores/graph-store.d.ts +85 -0
  40. package/dist/stores/graph-store.d.ts.map +1 -0
  41. package/dist/stores/graph-store.js +236 -0
  42. package/dist/stores/graph-store.js.map +1 -0
  43. package/dist/stores/ui-store.d.ts +5 -0
  44. package/dist/stores/ui-store.d.ts.map +1 -1
  45. package/dist/stores/ui-store.js +2 -0
  46. package/dist/stores/ui-store.js.map +1 -1
  47. package/dist/styles.css +1 -1
  48. package/package.json +5 -2
  49. package/src/components/graph/graph-canvas.tsx +366 -0
  50. package/src/components/graph/graph-controls.tsx +436 -0
  51. package/src/components/graph/graph-empty-state.tsx +45 -0
  52. package/src/components/graph/graph-page.tsx +1045 -0
  53. package/src/components/graph/lib/graph-helpers.ts +288 -0
  54. package/src/components/graph/node-popover.tsx +124 -0
  55. package/src/components/graph/use-graph-theme.ts +46 -0
  56. package/src/components/layout/mobile-tab-bar.tsx +26 -3
  57. package/src/components/layout/side-rail.tsx +28 -4
  58. package/src/index.ts +3 -1
  59. package/src/stores/graph-store.ts +345 -0
  60. package/src/stores/ui-store.ts +8 -0
@@ -0,0 +1,345 @@
1
+ import { create } from "zustand";
2
+ import type {
3
+ GraphMetaResponse,
4
+ GraphSubgraphResponse,
5
+ GraphMaintenanceResponse,
6
+ } from "@schlessera/brain-ui-sdk/protocol";
7
+ import { API_BASE } from "../lib/backend.js";
8
+ import { buildQuery, mergeSubgraphs } from "../components/graph/lib/graph-helpers.js";
9
+
10
+ export type GraphMode = "clusters" | "discovery" | "local" | "maintenance";
11
+
12
+ export type LocalDirection = "in" | "out" | "both";
13
+
14
+ /**
15
+ * How a fetch failed. "unavailable" carries the server's gating reason
16
+ * (schema too old / not yet computed); "unsupported" means the server predates
17
+ * the graph API entirely (404 on /graph/meta).
18
+ */
19
+ export type GraphErrorKind = "unavailable" | "unsupported" | "network";
20
+
21
+ interface GraphError {
22
+ kind: GraphErrorKind;
23
+ reason?: "schema" | "not_computed";
24
+ message: string;
25
+ }
26
+
27
+ export interface LocalParams {
28
+ center: string | null;
29
+ depth: 1 | 2 | 3;
30
+ direction: LocalDirection;
31
+ }
32
+
33
+ export interface DiscoveryParams {
34
+ /** null = the precomputed default root (direction is fixed there). */
35
+ root: string | null;
36
+ maxDepth: number;
37
+ }
38
+
39
+ export interface ClustersParams {
40
+ /** null = all communities. */
41
+ community: number | null;
42
+ isolates: boolean;
43
+ }
44
+
45
+ export interface MaintenanceParams {
46
+ staleDays: number;
47
+ }
48
+
49
+ export type SizeBy = "degree" | "pagerank";
50
+ export type DiscoveryColorBy = "distance" | "folder";
51
+
52
+ /** Which finding sections are visible — a client-side filter, no refetch. */
53
+ export interface MaintenanceFilters {
54
+ orphans: boolean;
55
+ unreachable: boolean;
56
+ broken: boolean;
57
+ stale: boolean;
58
+ }
59
+
60
+ type FetchState = "idle" | "loading" | "done" | "error";
61
+
62
+ interface GraphState {
63
+ mode: GraphMode;
64
+ meta: GraphMetaResponse | null;
65
+ metaState: FetchState;
66
+
67
+ local: LocalParams;
68
+ discovery: DiscoveryParams;
69
+ clusters: ClustersParams;
70
+ maintenance: MaintenanceParams;
71
+
72
+ /** Scene for the current mode (subgraph modes). */
73
+ subgraph: GraphSubgraphResponse | null;
74
+ findings: GraphMaintenanceResponse | null;
75
+ dataState: FetchState;
76
+ error: GraphError | null;
77
+
78
+ /** In-scene search query — highlights matching nodes, does not refetch. */
79
+ sceneQuery: string;
80
+
81
+ // Display-only knobs: they restyle the current scene, never refetch.
82
+ clustersSizeBy: SizeBy;
83
+ discoveryColorBy: DiscoveryColorBy;
84
+ maintenanceFilters: MaintenanceFilters;
85
+
86
+ selectedId: number | null;
87
+ hoveredId: number | null;
88
+
89
+ setMode: (mode: GraphMode) => void;
90
+ setLocalParams: (params: Partial<LocalParams>) => void;
91
+ setDiscoveryParams: (params: Partial<DiscoveryParams>) => void;
92
+ setClustersParams: (params: Partial<ClustersParams>) => void;
93
+ setMaintenanceParams: (params: Partial<MaintenanceParams>) => void;
94
+ setClustersSizeBy: (sizeBy: SizeBy) => void;
95
+ setDiscoveryColorBy: (colorBy: DiscoveryColorBy) => void;
96
+ setMaintenanceFilters: (filters: Partial<MaintenanceFilters>) => void;
97
+ setSceneQuery: (q: string) => void;
98
+ select: (id: number | null) => void;
99
+ hover: (id: number | null) => void;
100
+
101
+ fetchMeta: (force?: boolean) => Promise<void>;
102
+ /** Fetch the scene for the current mode + params (cached per query + computedAt). */
103
+ fetchScene: () => Promise<void>;
104
+ /** Local mode: pull the depth-1 ego of a node and merge it into the scene. */
105
+ expandNode: (path: string) => Promise<void>;
106
+ reset: () => void;
107
+ }
108
+
109
+ /** Cache of scene responses, keyed by `${endpoint}?${query}|${computedAt}`. */
110
+ const sceneCache = new Map<string, GraphSubgraphResponse | GraphMaintenanceResponse>();
111
+ const SCENE_CACHE_MAX = 40;
112
+
113
+ /** Test/dev hook: drop every cached scene. */
114
+ export function clearGraphSceneCache(): void {
115
+ sceneCache.clear();
116
+ }
117
+
118
+ /**
119
+ * Monotonic tokens for scene and meta fetches. Every call claims a new
120
+ * token; only the holder of the latest token may write results (success OR
121
+ * error), so a slow older response can never overwrite newer state. Meta
122
+ * needs this too: forced mount-time refetches can overlap when the view is
123
+ * toggled quickly, and an older /meta response landing last would restore a
124
+ * stale computedAt — which is the key every scene-cache lookup hangs off.
125
+ */
126
+ let sceneRequestToken = 0;
127
+ let metaRequestToken = 0;
128
+
129
+ function cachePut(key: string, value: GraphSubgraphResponse | GraphMaintenanceResponse) {
130
+ if (sceneCache.size >= SCENE_CACHE_MAX) {
131
+ const oldest = sceneCache.keys().next().value;
132
+ if (oldest !== undefined) sceneCache.delete(oldest);
133
+ }
134
+ sceneCache.set(key, value);
135
+ }
136
+
137
+ async function fetchGraphJson<T>(pathAndQuery: string): Promise<T> {
138
+ const res = await fetch(`${API_BASE}/graph${pathAndQuery}`);
139
+ if (!res.ok) {
140
+ const body = await res.json().catch(() => ({}) as Record<string, unknown>);
141
+ const err = new Error(
142
+ (body as { error?: string }).error || `HTTP ${res.status}`
143
+ ) as Error & { status: number; reason?: string };
144
+ err.status = res.status;
145
+ err.reason = (body as { reason?: string }).reason;
146
+ throw err;
147
+ }
148
+ return res.json() as Promise<T>;
149
+ }
150
+
151
+ function toGraphError(err: unknown): GraphError {
152
+ const e = err as Error & { status?: number; reason?: string };
153
+ if (e.status === 404) {
154
+ return {
155
+ kind: "unsupported",
156
+ message: "This server does not know the graph API yet.",
157
+ };
158
+ }
159
+ if (e.status === 503) {
160
+ return {
161
+ kind: "unavailable",
162
+ reason: e.reason === "schema" ? "schema" : "not_computed",
163
+ message: e.message || "graph_unavailable",
164
+ };
165
+ }
166
+ return { kind: "network", message: e.message || "Request failed" };
167
+ }
168
+
169
+ function sceneRequest(state: GraphState): { endpoint: string; query: string } | null {
170
+ switch (state.mode) {
171
+ case "clusters":
172
+ return {
173
+ endpoint: "/clusters",
174
+ query: buildQuery({
175
+ community: state.clusters.community,
176
+ isolates: state.clusters.isolates ? 1 : null,
177
+ }),
178
+ };
179
+ case "discovery":
180
+ return {
181
+ endpoint: "/discovery",
182
+ query: buildQuery({
183
+ root: state.discovery.root,
184
+ maxDepth: state.discovery.maxDepth,
185
+ }),
186
+ };
187
+ case "local":
188
+ if (!state.local.center) return null;
189
+ return {
190
+ endpoint: "/neighborhood",
191
+ query: buildQuery({
192
+ center: state.local.center,
193
+ depth: state.local.depth,
194
+ direction: state.local.direction,
195
+ }),
196
+ };
197
+ case "maintenance":
198
+ return {
199
+ endpoint: "/maintenance",
200
+ query: buildQuery({ staleDays: state.maintenance.staleDays }),
201
+ };
202
+ }
203
+ }
204
+
205
+ export const useGraphStore = create<GraphState>((set, get) => ({
206
+ mode: "local",
207
+ meta: null,
208
+ metaState: "idle",
209
+
210
+ local: { center: null, depth: 1, direction: "both" },
211
+ discovery: { root: null, maxDepth: 8 },
212
+ clusters: { community: null, isolates: false },
213
+ maintenance: { staleDays: 180 },
214
+
215
+ subgraph: null,
216
+ findings: null,
217
+ dataState: "idle",
218
+ error: null,
219
+
220
+ sceneQuery: "",
221
+ clustersSizeBy: "degree",
222
+ discoveryColorBy: "distance",
223
+ maintenanceFilters: { orphans: true, unreachable: true, broken: true, stale: true },
224
+ selectedId: null,
225
+ hoveredId: null,
226
+
227
+ setMode: (mode) => {
228
+ if (mode === get().mode) return;
229
+ set({ mode, subgraph: null, findings: null, dataState: "idle", error: null, selectedId: null, hoveredId: null, sceneQuery: "" });
230
+ void get().fetchScene();
231
+ },
232
+ setLocalParams: (params) => {
233
+ set((s) => ({ local: { ...s.local, ...params }, selectedId: null }));
234
+ if (get().mode === "local") void get().fetchScene();
235
+ },
236
+ setDiscoveryParams: (params) => {
237
+ set((s) => ({ discovery: { ...s.discovery, ...params }, selectedId: null }));
238
+ if (get().mode === "discovery") void get().fetchScene();
239
+ },
240
+ setClustersParams: (params) => {
241
+ set((s) => ({ clusters: { ...s.clusters, ...params }, selectedId: null }));
242
+ if (get().mode === "clusters") void get().fetchScene();
243
+ },
244
+ setMaintenanceParams: (params) => {
245
+ set((s) => ({ maintenance: { ...s.maintenance, ...params } }));
246
+ if (get().mode === "maintenance") void get().fetchScene();
247
+ },
248
+ setClustersSizeBy: (sizeBy) => set({ clustersSizeBy: sizeBy }),
249
+ setDiscoveryColorBy: (colorBy) => set({ discoveryColorBy: colorBy }),
250
+ setMaintenanceFilters: (filters) =>
251
+ set((s) => ({ maintenanceFilters: { ...s.maintenanceFilters, ...filters } })),
252
+ setSceneQuery: (q) => set({ sceneQuery: q }),
253
+ select: (id) => set({ selectedId: id }),
254
+ hover: (id) => set({ hoveredId: id }),
255
+
256
+ fetchMeta: async (force) => {
257
+ const { metaState } = get();
258
+ if (!force && (metaState === "loading" || metaState === "done")) return;
259
+ const token = ++metaRequestToken;
260
+ set({ metaState: "loading" });
261
+ try {
262
+ const meta = await fetchGraphJson<GraphMetaResponse>("/meta");
263
+ if (token !== metaRequestToken) return;
264
+ set({ meta, metaState: "done" });
265
+ } catch (err) {
266
+ if (token !== metaRequestToken) return;
267
+ set({ metaState: "error", error: toGraphError(err) });
268
+ }
269
+ },
270
+
271
+ fetchScene: async () => {
272
+ const state = get();
273
+ const token = ++sceneRequestToken;
274
+ const req = sceneRequest(state);
275
+ if (!req) {
276
+ // Local mode without a center: nothing to fetch, the page shows the picker.
277
+ set({ subgraph: null, findings: null, dataState: "idle", error: null });
278
+ return;
279
+ }
280
+ const cacheKey = `${req.endpoint}?${req.query}|${state.meta?.computedAt ?? ""}`;
281
+ const cached = sceneCache.get(cacheKey);
282
+ if (cached) {
283
+ if (state.mode === "maintenance") {
284
+ set({ findings: cached as GraphMaintenanceResponse, subgraph: null, dataState: "done", error: null });
285
+ } else {
286
+ set({ subgraph: cached as GraphSubgraphResponse, findings: null, dataState: "done", error: null });
287
+ }
288
+ return;
289
+ }
290
+ set({ dataState: "loading", error: null });
291
+ const requestedMode = state.mode;
292
+ try {
293
+ const data = await fetchGraphJson<GraphSubgraphResponse | GraphMaintenanceResponse>(
294
+ `${req.endpoint}${req.query ? `?${req.query}` : ""}`
295
+ );
296
+ // The cache is always safe to fill; the visible scene belongs to the
297
+ // latest request only (rapid param changes race their responses).
298
+ cachePut(cacheKey, data);
299
+ if (token !== sceneRequestToken) return;
300
+ if (requestedMode === "maintenance") {
301
+ set({ findings: data as GraphMaintenanceResponse, subgraph: null, dataState: "done" });
302
+ } else {
303
+ set({ subgraph: data as GraphSubgraphResponse, findings: null, dataState: "done" });
304
+ }
305
+ } catch (err) {
306
+ if (token !== sceneRequestToken) return;
307
+ set({ dataState: "error", error: toGraphError(err) });
308
+ }
309
+ },
310
+
311
+ expandNode: async (path) => {
312
+ const state = get();
313
+ if (state.mode !== "local" || !state.subgraph) return;
314
+ try {
315
+ const ego = await fetchGraphJson<GraphSubgraphResponse>(
316
+ `/neighborhood?${buildQuery({ center: path, depth: 1, direction: "both" })}`
317
+ );
318
+ const current = get();
319
+ if (current.mode !== "local" || !current.subgraph) return;
320
+ set({ subgraph: mergeSubgraphs(current.subgraph, ego) });
321
+ } catch {
322
+ // Expansion is additive sugar — a failure leaves the scene as-is.
323
+ }
324
+ },
325
+
326
+ reset: () =>
327
+ set({
328
+ subgraph: null,
329
+ findings: null,
330
+ dataState: "idle",
331
+ error: null,
332
+ selectedId: null,
333
+ hoveredId: null,
334
+ sceneQuery: "",
335
+ }),
336
+ }));
337
+
338
+ // Dev-only handle for exercising the view with injected fixtures (the
339
+ // window.__chatStore precedent). The env read is defensive: outside a Vite
340
+ // build `import.meta.env` does not exist.
341
+ const devEnv = (import.meta as { env?: Record<string, unknown> }).env;
342
+ if (typeof window !== "undefined" && devEnv?.DEV) {
343
+ (window as unknown as { __graphStore?: typeof useGraphStore }).__graphStore =
344
+ useGraphStore;
345
+ }
@@ -3,7 +3,11 @@ import { create } from "zustand";
3
3
  /** Which tab the settings panel opens on. */
4
4
  export type SettingsTab = "models" | "security";
5
5
 
6
+ /** Full-screen surface currently shown inside the AppShell. */
7
+ export type ActiveView = "chat" | "graph";
8
+
6
9
  interface UIState {
10
+ activeView: ActiveView;
7
11
  sessionPanelOpen: boolean;
8
12
  syncPanelOpen: boolean;
9
13
  whatsupPanelOpen: boolean;
@@ -12,6 +16,8 @@ interface UIState {
12
16
  filePanelOpen: boolean;
13
17
  settingsPanelOpen: boolean;
14
18
  settingsTab: SettingsTab;
19
+ /** Switch the full-screen view; closes any open panel so the new view starts clean. */
20
+ setActiveView: (view: ActiveView) => void;
15
21
  toggleSessionPanel: () => void;
16
22
  toggleSyncPanel: () => void;
17
23
  toggleWhatsupPanel: () => void;
@@ -44,7 +50,9 @@ const CLOSED = {
44
50
 
45
51
  export const useUIStore = create<UIState>((set) => ({
46
52
  ...CLOSED,
53
+ activeView: "chat",
47
54
  settingsTab: "models",
55
+ setActiveView: (view) => set({ ...CLOSED, activeView: view }),
48
56
  toggleSessionPanel: () =>
49
57
  set((s) => ({ ...CLOSED, sessionPanelOpen: !s.sessionPanelOpen })),
50
58
  toggleSyncPanel: () =>