@schlessera/brain-ui-react 0.6.3 → 0.7.1

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 +387 -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 +98 -0
  18. package/dist/components/graph/lib/graph-helpers.d.ts.map +1 -0
  19. package/dist/components/graph/lib/graph-helpers.js +240 -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 +39 -0
  24. package/dist/components/graph/node-popover.js.map +1 -0
  25. package/dist/components/graph/use-graph-theme.d.ts +19 -0
  26. package/dist/components/graph/use-graph-theme.d.ts.map +1 -0
  27. package/dist/components/graph/use-graph-theme.js +38 -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 +450 -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 +311 -0
  54. package/src/components/graph/node-popover.tsx +135 -0
  55. package/src/components/graph/use-graph-theme.ts +51 -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,311 @@
1
+ /**
2
+ * Pure helpers for the graph view. No sigma, no DOM, no fetch — everything
3
+ * here is deterministic and unit-tested. The canvas and store consume these;
4
+ * keeping them pure is what makes the renderer swappable.
5
+ */
6
+ import type {
7
+ GraphNodePayload,
8
+ GraphSubgraphResponse,
9
+ } from "@schlessera/brain-ui-sdk/protocol";
10
+
11
+ // --- Query strings -----------------------------------------------------------
12
+
13
+ /**
14
+ * Build a query string from a param bag. null/undefined/empty-string values
15
+ * are dropped; everything else is encoded. Returns "" when nothing survives.
16
+ */
17
+ export function buildQuery(
18
+ params: Record<string, string | number | boolean | null | undefined>
19
+ ): string {
20
+ const parts: string[] = [];
21
+ for (const [key, value] of Object.entries(params)) {
22
+ if (value === null || value === undefined || value === "") continue;
23
+ parts.push(`${encodeURIComponent(key)}=${encodeURIComponent(String(value))}`);
24
+ }
25
+ return parts.join("&");
26
+ }
27
+
28
+ // --- Subgraph merging --------------------------------------------------------
29
+
30
+ /**
31
+ * Merge an expansion fetch into the current scene. Nodes dedupe by id — the
32
+ * richer record wins (more analytical fields), which in practice means an
33
+ * existing node keeps its coordinates/metrics when the expansion returns a
34
+ * barer copy. Edges dedupe by (source, target).
35
+ */
36
+ export function mergeSubgraphs(
37
+ base: GraphSubgraphResponse,
38
+ addition: GraphSubgraphResponse
39
+ ): GraphSubgraphResponse {
40
+ const nodesById = new Map<number, GraphNodePayload>();
41
+ for (const node of base.nodes) nodesById.set(node.id, node);
42
+ for (const node of addition.nodes) {
43
+ const existing = nodesById.get(node.id);
44
+ nodesById.set(node.id, existing ? { ...node, ...existing } : node);
45
+ }
46
+ const edgeKeys = new Set<string>();
47
+ const edges = [] as GraphSubgraphResponse["edges"];
48
+ for (const edge of [...base.edges, ...addition.edges]) {
49
+ const key = `${edge.source}->${edge.target}`;
50
+ if (edgeKeys.has(key)) continue;
51
+ edgeKeys.add(key);
52
+ edges.push(edge);
53
+ }
54
+ return {
55
+ nodes: [...nodesById.values()],
56
+ edges,
57
+ truncated: base.truncated || addition.truncated,
58
+ };
59
+ }
60
+
61
+ // --- Radial (discovery) layout ----------------------------------------------
62
+
63
+ export interface RadialPoint {
64
+ x: number;
65
+ y: number;
66
+ }
67
+
68
+ /**
69
+ * Deterministic radial layout for discovery mode: radius = BFS distance,
70
+ * nodes ordered within their ring by id (stable across refetches), the root
71
+ * (distance 0) at the origin. Rings get a slight angular offset per ring so
72
+ * spokes don't visually align into artificial lines.
73
+ */
74
+ export function radialLayout(
75
+ nodes: Pick<GraphNodePayload, "id" | "distance">[],
76
+ ringSpacing = 1
77
+ ): Map<number, RadialPoint> {
78
+ const rings = new Map<number, number[]>();
79
+ for (const node of nodes) {
80
+ const d = node.distance ?? 0;
81
+ const ring = rings.get(d) ?? [];
82
+ ring.push(node.id);
83
+ rings.set(d, ring);
84
+ }
85
+ const positions = new Map<number, RadialPoint>();
86
+ for (const [distance, ids] of rings) {
87
+ ids.sort((a, b) => a - b);
88
+ const radius = distance * ringSpacing;
89
+ const offset = distance * 0.5; // radians; de-aligns consecutive rings
90
+ ids.forEach((id, i) => {
91
+ if (distance === 0) {
92
+ positions.set(id, { x: 0, y: 0 });
93
+ return;
94
+ }
95
+ const angle = offset + (2 * Math.PI * i) / ids.length;
96
+ positions.set(id, {
97
+ x: radius * Math.cos(angle),
98
+ y: radius * Math.sin(angle),
99
+ });
100
+ });
101
+ }
102
+ return positions;
103
+ }
104
+
105
+ // --- Node sizing -------------------------------------------------------------
106
+
107
+ export type SizeBy = "degree" | "pagerank";
108
+
109
+ /**
110
+ * Node size in rendered pixels. Sqrt scale keeps hubs prominent without
111
+ * letting a 45-in-degree hub dwarf the scene.
112
+ */
113
+ export function nodeSize(
114
+ node: Pick<GraphNodePayload, "inDegree" | "outDegree" | "pagerank" | "virtual">,
115
+ sizeBy: SizeBy = "degree",
116
+ min = 3,
117
+ max = 14
118
+ ): number {
119
+ if (node.virtual) return max;
120
+ let value: number;
121
+ if (sizeBy === "pagerank" && node.pagerank !== undefined) {
122
+ // Typical pagerank values are ~1/n; normalize into a usable range.
123
+ value = node.pagerank * 1000;
124
+ } else {
125
+ value = node.inDegree + node.outDegree;
126
+ }
127
+ return Math.max(min, Math.min(max, min + Math.sqrt(value) * 1.8));
128
+ }
129
+
130
+ // --- Palettes ----------------------------------------------------------------
131
+ //
132
+ // Both palettes ran through the dataviz validator against the app surface
133
+ // (#0c0e12, dark): the categorical set passes lightness band, chroma floor,
134
+ // CVD separation (worst adjacent ΔE 8.4), normal-vision floor and 3:1
135
+ // contrast; the distance ramp passes monotone-lightness, step-gap, light-end
136
+ // contrast and single-hue checks. Slot ORDER is the CVD-safety mechanism —
137
+ // do not reorder without re-validating.
138
+
139
+ /** Fixed categorical slots (validated, dark). Community/folder identity. */
140
+ export const CATEGORICAL_SLOTS = [
141
+ "#3987e5", // blue
142
+ "#d95926", // orange
143
+ "#199e70", // aqua
144
+ "#c98500", // yellow
145
+ "#d55181", // magenta
146
+ "#008300", // green
147
+ "#9085e9", // violet
148
+ "#e66767", // red
149
+ ] as const;
150
+
151
+ /** Recessive slot for everything past the eight distinguishable ones. */
152
+ export const OTHER_COLOR = "#565b66";
153
+
154
+ /** The root's own color in discovery mode (the app's amber primary). */
155
+ export const ROOT_COLOR = "#e09f3e";
156
+
157
+ /** Ordinal distance ramp, near → far (validated, 5 visible steps). */
158
+ export const DISTANCE_RAMP = [
159
+ "#b7d3f6",
160
+ "#86b6ef",
161
+ "#5598e7",
162
+ "#2a78d6",
163
+ "#184f95",
164
+ ] as const;
165
+
166
+ /**
167
+ * Linear blend of two hex colors: t=0 → a, t=1 → b. Used for the gentle
168
+ * non-match fade during search highlight (half the strength of the hover
169
+ * fade, which jumps straight to the edge color). Falls back to `b` when a
170
+ * color is not parseable hex, so a bad token degrades to the strong fade
171
+ * rather than an invalid color string.
172
+ */
173
+ export function mixColors(a: string, b: string, t: number): string {
174
+ const pa = parseHex(a);
175
+ const pb = parseHex(b);
176
+ if (!pa || !pb) return b;
177
+ const clamp = Math.max(0, Math.min(1, t));
178
+ const channel = (i: number) => Math.round(pa[i]! + (pb[i]! - pa[i]!) * clamp);
179
+ return `#${[0, 1, 2].map((i) => channel(i).toString(16).padStart(2, "0")).join("")}`;
180
+ }
181
+
182
+ function parseHex(color: string): [number, number, number] | null {
183
+ const m = /^#?([0-9a-f]{6})$/i.exec(color.trim());
184
+ if (!m) return null;
185
+ const n = parseInt(m[1]!, 16);
186
+ return [(n >> 16) & 0xff, (n >> 8) & 0xff, n & 0xff];
187
+ }
188
+
189
+ /**
190
+ * Color for a community. Communities are numbered by size (0 = largest) at
191
+ * index time, so the first eight — the ones a legend can carry — get the
192
+ * distinguishable slots and the long tail shares one recessive color.
193
+ */
194
+ export function communityColor(community: number): string {
195
+ return community < CATEGORICAL_SLOTS.length
196
+ ? CATEGORICAL_SLOTS[community]!
197
+ : OTHER_COLOR;
198
+ }
199
+
200
+ /** Color for a BFS distance: amber root, then the blue ramp fading out. */
201
+ export function distanceColor(distance: number): string {
202
+ if (distance <= 0) return ROOT_COLOR;
203
+ return DISTANCE_RAMP[Math.min(distance - 1, DISTANCE_RAMP.length - 1)]!;
204
+ }
205
+
206
+ /** First path segment — "career/opportunities/x.md" → "career". */
207
+ export function topLevelDir(path: string): string {
208
+ const slash = path.indexOf("/");
209
+ return slash === -1 ? "" : path.slice(0, slash);
210
+ }
211
+
212
+ /**
213
+ * Assign categorical slots to top-level directories, biggest first — the
214
+ * same eight-then-other policy as communities. Returns dir → color.
215
+ */
216
+ export function assignFolderColors(paths: string[]): Map<string, string> {
217
+ const counts = new Map<string, number>();
218
+ for (const path of paths) {
219
+ const dir = topLevelDir(path);
220
+ counts.set(dir, (counts.get(dir) ?? 0) + 1);
221
+ }
222
+ const ranked = [...counts.entries()].sort(
223
+ (a, b) => b[1] - a[1] || a[0].localeCompare(b[0])
224
+ );
225
+ const colors = new Map<string, string>();
226
+ ranked.forEach(([dir], i) => {
227
+ colors.set(dir, i < CATEGORICAL_SLOTS.length ? CATEGORICAL_SLOTS[i]! : OTHER_COLOR);
228
+ });
229
+ return colors;
230
+ }
231
+
232
+ // --- Community legend grouping ----------------------------------------------
233
+
234
+ export interface LegendGroups<T extends { community: number; size: number }> {
235
+ /** Multi-note communities, size-descending — what the legend lists. */
236
+ major: T[];
237
+ /** Number of single-note communities folded out of the legend. */
238
+ singletonCount: number;
239
+ }
240
+
241
+ /** Split a community list into legend-worthy entries and the singleton tail. */
242
+ export function groupCommunities<T extends { community: number; size: number }>(
243
+ communities: T[]
244
+ ): LegendGroups<T> {
245
+ const major = communities
246
+ .filter((c) => c.size > 1)
247
+ .sort((a, b) => b.size - a.size || a.community - b.community);
248
+ return { major, singletonCount: communities.length - major.length };
249
+ }
250
+
251
+ // --- Label policy ------------------------------------------------------------
252
+
253
+ export interface LabelPolicyInput {
254
+ nodes: Pick<GraphNodePayload, "id" | "inDegree" | "outDegree" | "pagerank" | "virtual">[];
255
+ selectedId: number | null;
256
+ hoveredId: number | null;
257
+ matchIds: ReadonlySet<number>;
258
+ /** Camera ratio — sigma's zoom, 1 = fit, smaller = zoomed in. */
259
+ cameraRatio: number;
260
+ }
261
+
262
+ /**
263
+ * Which nodes must always carry a label: selection, hover, search matches,
264
+ * the virtual root, plus the top-K nodes by rank where K grows as the user
265
+ * zooms in. Never "all labels" — the renderer's own density threshold handles
266
+ * the rest.
267
+ */
268
+ export function labelSet(input: LabelPolicyInput): Set<number> {
269
+ const forced = new Set<number>();
270
+ if (input.selectedId !== null) forced.add(input.selectedId);
271
+ if (input.hoveredId !== null) forced.add(input.hoveredId);
272
+ for (const id of input.matchIds) forced.add(id);
273
+ for (const node of input.nodes) if (node.virtual) forced.add(node.id);
274
+
275
+ // Zoomed out (ratio >= 1): a handful of anchors. Each halving of the ratio
276
+ // doubles the quota. Capped so a deep zoom cannot force thousands.
277
+ const zoomFactor = Math.max(0.05, Math.min(4, input.cameraRatio));
278
+ const quota = Math.min(60, Math.max(4, Math.round(8 / zoomFactor)));
279
+
280
+ const ranked = [...input.nodes].sort((a, b) => rank(b) - rank(a));
281
+ for (let i = 0; i < Math.min(quota, ranked.length); i++) {
282
+ forced.add(ranked[i]!.id);
283
+ }
284
+ return forced;
285
+ }
286
+
287
+ function rank(
288
+ node: Pick<GraphNodePayload, "inDegree" | "outDegree" | "pagerank">
289
+ ): number {
290
+ return node.pagerank !== undefined
291
+ ? node.pagerank
292
+ : (node.inDegree + node.outDegree) / 1e6;
293
+ }
294
+
295
+ // --- Scene search ------------------------------------------------------------
296
+
297
+ /** Ids of nodes whose title or path matches the in-scene query. */
298
+ export function matchScene(
299
+ nodes: Pick<GraphNodePayload, "id" | "title" | "path">[],
300
+ query: string
301
+ ): Set<number> {
302
+ const q = query.trim().toLowerCase();
303
+ const matches = new Set<number>();
304
+ if (q.length < 2) return matches;
305
+ for (const node of nodes) {
306
+ if (node.title.toLowerCase().includes(q) || node.path.toLowerCase().includes(q)) {
307
+ matches.add(node.id);
308
+ }
309
+ }
310
+ return matches;
311
+ }
@@ -0,0 +1,135 @@
1
+ import { X, FileText, Crosshair, Expand } from "lucide-react";
2
+ import type { GraphNodePayload } from "@schlessera/brain-ui-sdk/protocol";
3
+ import { useFileStore } from "../../stores/file-store.js";
4
+ import { useUIStore } from "../../stores/ui-store.js";
5
+ import { useGraphStore } from "../../stores/graph-store.js";
6
+ import { communityColor } from "./lib/graph-helpers.js";
7
+
8
+ /**
9
+ * Detail card for the selected node, docked to the bottom-left of the canvas
10
+ * (bottom sheet width on phones). "Open note" hands over to the file viewer
11
+ * panel — the same hand-off the search modal uses.
12
+ */
13
+ export function NodePopover({
14
+ node,
15
+ communityLabel,
16
+ }: {
17
+ node: GraphNodePayload;
18
+ communityLabel?: string | null;
19
+ }) {
20
+ const openFile = useFileStore((s) => s.openFile);
21
+ const setFilePanelOpen = useUIStore((s) => s.setFilePanelOpen);
22
+ const mode = useGraphStore((s) => s.mode);
23
+ const select = useGraphStore((s) => s.select);
24
+ const setMode = useGraphStore((s) => s.setMode);
25
+ const setLocalParams = useGraphStore((s) => s.setLocalParams);
26
+ const expandNode = useGraphStore((s) => s.expandNode);
27
+
28
+ const isVirtual = node.virtual === true;
29
+
30
+ function handleOpen() {
31
+ setFilePanelOpen(true);
32
+ void openFile(node.path);
33
+ }
34
+
35
+ function handleFocus() {
36
+ // Re-center the local view on this node (switching mode when needed).
37
+ if (mode !== "local") setMode("local");
38
+ setLocalParams({ center: node.path });
39
+ select(null);
40
+ }
41
+
42
+ return (
43
+ <div className="pointer-events-auto absolute inset-x-2 bottom-2 z-10 rounded-xl border border-border bg-surface-overlay/95 p-4 shadow-2xl backdrop-blur md:inset-x-auto md:left-4 md:bottom-4 md:w-80">
44
+ <div className="flex items-start justify-between gap-2">
45
+ <div className="min-w-0">
46
+ <div className="flex items-center gap-2">
47
+ <span className="shrink-0 rounded bg-surface-raised px-1.5 py-0.5 text-[10px] uppercase tracking-wide text-muted-foreground">
48
+ {isVirtual ? "root" : node.type}
49
+ </span>
50
+ {communityLabel && (
51
+ <span
52
+ title="Topic cluster inferred from this note's links"
53
+ className="flex min-w-0 items-center gap-1 rounded bg-surface-raised px-1.5 py-0.5 text-[10px] text-muted-foreground"
54
+ >
55
+ {node.community !== undefined && (
56
+ <span
57
+ aria-hidden="true"
58
+ className="h-1.5 w-1.5 shrink-0 rounded-full"
59
+ style={{ backgroundColor: communityColor(node.community) }}
60
+ />
61
+ )}
62
+ <span className="truncate">Topic: {communityLabel}</span>
63
+ </span>
64
+ )}
65
+ </div>
66
+ <h3 className="mt-1 truncate text-sm font-medium text-foreground">
67
+ {node.title || node.path}
68
+ </h3>
69
+ <p className="truncate font-mono text-[10px] text-muted-foreground/60">
70
+ {node.path}
71
+ </p>
72
+ </div>
73
+ <button
74
+ onClick={() => select(null)}
75
+ className="shrink-0 rounded p-1 text-muted-foreground transition-colors hover:text-foreground"
76
+ title="Close"
77
+ >
78
+ <X className="h-3.5 w-3.5" />
79
+ </button>
80
+ </div>
81
+
82
+ <div className="mt-2 flex gap-4 text-[11px] text-muted-foreground">
83
+ <span>{node.inDegree} in</span>
84
+ <span>{node.outDegree} out</span>
85
+ {node.distance !== undefined && node.distance > 0 && (
86
+ <span>
87
+ {node.distance} hop{node.distance === 1 ? "" : "s"}
88
+ </span>
89
+ )}
90
+ </div>
91
+
92
+ <div className="mt-3 flex flex-wrap gap-2">
93
+ {!isVirtual && (
94
+ <ActionButton icon={FileText} label="Open note" onClick={handleOpen} primary />
95
+ )}
96
+ {!isVirtual && (mode !== "local" || node.distance !== 0) && (
97
+ <ActionButton icon={Crosshair} label="Focus here" onClick={handleFocus} />
98
+ )}
99
+ {mode === "local" && !isVirtual && node.distance !== 0 && (
100
+ <ActionButton
101
+ icon={Expand}
102
+ label="Expand"
103
+ onClick={() => void expandNode(node.path)}
104
+ />
105
+ )}
106
+ </div>
107
+ </div>
108
+ );
109
+ }
110
+
111
+ function ActionButton({
112
+ icon: Icon,
113
+ label,
114
+ onClick,
115
+ primary,
116
+ }: {
117
+ icon: typeof X;
118
+ label: string;
119
+ onClick: () => void;
120
+ primary?: boolean;
121
+ }) {
122
+ return (
123
+ <button
124
+ onClick={onClick}
125
+ className={
126
+ primary
127
+ ? "flex min-h-9 items-center gap-1.5 rounded-lg bg-primary px-3 py-1.5 text-xs font-medium text-primary-foreground transition-colors hover:bg-primary/90"
128
+ : "flex min-h-9 items-center gap-1.5 rounded-lg bg-surface-raised px-3 py-1.5 text-xs font-medium text-foreground transition-colors hover:bg-surface-overlay"
129
+ }
130
+ >
131
+ <Icon className="h-3.5 w-3.5" />
132
+ {label}
133
+ </button>
134
+ );
135
+ }
@@ -0,0 +1,51 @@
1
+ import { useMemo } from "react";
2
+
3
+ export interface GraphTheme {
4
+ background: string;
5
+ /** Raised surface used for the hover-label plate (sigma's default is #FFF,
6
+ * unreadable under our near-white label text). */
7
+ surfaceOverlay: string;
8
+ node: string;
9
+ nodeSelected: string;
10
+ edge: string;
11
+ edgeHighlight: string;
12
+ label: string;
13
+ labelMuted: string;
14
+ }
15
+
16
+ const FALLBACK: GraphTheme = {
17
+ background: "#0c0e12",
18
+ surfaceOverlay: "#1e2128",
19
+ node: "#8a8691",
20
+ nodeSelected: "#e09f3e",
21
+ edge: "#2a2d35",
22
+ edgeHighlight: "#5bb5a2",
23
+ label: "#e8e4df",
24
+ labelMuted: "#8a8691",
25
+ };
26
+
27
+ /**
28
+ * Resolve the Tailwind theme tokens into concrete colors for the canvas —
29
+ * WebGL cannot read CSS custom properties itself. Read once per mount; the
30
+ * app is dark-only, so tokens do not change at runtime.
31
+ */
32
+ export function useGraphTheme(): GraphTheme {
33
+ return useMemo(() => {
34
+ if (typeof window === "undefined") return FALLBACK;
35
+ const style = getComputedStyle(document.documentElement);
36
+ const token = (name: string, fallback: string) => {
37
+ const value = style.getPropertyValue(name).trim();
38
+ return value || fallback;
39
+ };
40
+ return {
41
+ background: token("--color-background", FALLBACK.background),
42
+ surfaceOverlay: token("--color-surface-overlay", FALLBACK.surfaceOverlay),
43
+ node: token("--color-muted-foreground", FALLBACK.node),
44
+ nodeSelected: token("--color-primary", FALLBACK.nodeSelected),
45
+ edge: token("--color-border", FALLBACK.edge),
46
+ edgeHighlight: token("--color-accent", FALLBACK.edgeHighlight),
47
+ label: token("--color-foreground", FALLBACK.label),
48
+ labelMuted: token("--color-muted-foreground", FALLBACK.labelMuted),
49
+ };
50
+ }, []);
51
+ }
@@ -7,18 +7,29 @@ import {
7
7
  FolderTree,
8
8
  SquarePen,
9
9
  MoreHorizontal,
10
+ Waypoints,
10
11
  } from "lucide-react";
11
12
  import { useUIStore } from "../../stores/ui-store.js";
12
13
  import { useChatStore } from "../../stores/chat-store.js";
13
14
  import { cn } from "../../lib/utils.js";
14
15
 
15
16
  export function MobileTabBar() {
17
+ const activeView = useUIStore((s) => s.activeView);
18
+ const setActiveView = useUIStore((s) => s.setActiveView);
16
19
  const toggleSessionPanel = useUIStore((s) => s.toggleSessionPanel);
17
20
  const toggleSyncPanel = useUIStore((s) => s.toggleSyncPanel);
18
21
  const toggleFilePanel = useUIStore((s) => s.toggleFilePanel);
19
22
  const toggleSettingsPanel = useUIStore((s) => s.toggleSettingsPanel);
20
23
  const clearMessages = useChatStore((s) => s.clearMessages);
21
24
 
25
+ /** Chat-scoped panels live in the chat page — surface it before opening them. */
26
+ function inChat(toggle: () => void) {
27
+ return () => {
28
+ if (activeView !== "chat") setActiveView("chat");
29
+ toggle();
30
+ };
31
+ }
32
+
22
33
  const [moreOpen, setMoreOpen] = useState(false);
23
34
  const moreRef = useRef<HTMLDivElement>(null);
24
35
 
@@ -40,15 +51,27 @@ export function MobileTabBar() {
40
51
 
41
52
  return (
42
53
  <nav className="md:hidden fixed bottom-0 inset-x-0 z-30 flex h-14 items-center justify-around border-t border-border bg-surface px-1 pb-[env(safe-area-inset-bottom)]">
43
- <TabIcon icon={Brain} label="Chat" active />
54
+ <TabIcon
55
+ icon={Brain}
56
+ label="Chat"
57
+ active={activeView === "chat"}
58
+ onClick={() => setActiveView("chat")}
59
+ />
44
60
  <TabIcon
45
61
  icon={SquarePen}
46
62
  label="New chat"
47
63
  onClick={() => {
48
64
  setMoreOpen(false);
65
+ setActiveView("chat");
49
66
  clearMessages();
50
67
  }}
51
68
  />
69
+ <TabIcon
70
+ icon={Waypoints}
71
+ label="Graph"
72
+ active={activeView === "graph"}
73
+ onClick={() => setActiveView("graph")}
74
+ />
52
75
  <TabIcon icon={FolderTree} label="Files" onClick={toggleFilePanel} />
53
76
  <div ref={moreRef} className="relative">
54
77
  <TabIcon
@@ -67,7 +90,7 @@ export function MobileTabBar() {
67
90
  label="Sync"
68
91
  onClick={() => {
69
92
  setMoreOpen(false);
70
- toggleSyncPanel();
93
+ inChat(toggleSyncPanel)();
71
94
  }}
72
95
  />
73
96
  <MoreItem
@@ -75,7 +98,7 @@ export function MobileTabBar() {
75
98
  label="History"
76
99
  onClick={() => {
77
100
  setMoreOpen(false);
78
- toggleSessionPanel();
101
+ inChat(toggleSessionPanel)();
79
102
  }}
80
103
  />
81
104
  <MoreItem
@@ -6,6 +6,7 @@ import {
6
6
  SlidersHorizontal,
7
7
  SquarePen,
8
8
  FolderTree,
9
+ Waypoints,
9
10
  } from "lucide-react";
10
11
  import { useConnectionStore } from "../../stores/connection-store.js";
11
12
  import { useUIStore } from "../../stores/ui-store.js";
@@ -14,6 +15,8 @@ import { cn } from "../../lib/utils.js";
14
15
 
15
16
  export function SideRail() {
16
17
  const wsStatus = useConnectionStore((s) => s.wsStatus);
18
+ const activeView = useUIStore((s) => s.activeView);
19
+ const setActiveView = useUIStore((s) => s.setActiveView);
17
20
  const toggleSessionPanel = useUIStore((s) => s.toggleSessionPanel);
18
21
  const toggleSyncPanel = useUIStore((s) => s.toggleSyncPanel);
19
22
  const toggleWhatsupPanel = useUIStore((s) => s.toggleWhatsupPanel);
@@ -23,6 +26,14 @@ export function SideRail() {
23
26
  const hasMessages = useChatStore((s) => activeChat(s).messages.length > 0);
24
27
  const isStreaming = useChatStore((s) => activeChat(s).isStreaming);
25
28
 
29
+ /** Chat-scoped panels live in the chat page — surface it before opening them. */
30
+ function inChat(toggle: () => void) {
31
+ return () => {
32
+ if (activeView !== "chat") setActiveView("chat");
33
+ toggle();
34
+ };
35
+ }
36
+
26
37
  return (
27
38
  <nav className="hidden md:flex w-16 shrink-0 flex-col items-center border-r border-border bg-surface py-4 gap-1">
28
39
  {/* Logo */}
@@ -46,13 +57,13 @@ export function SideRail() {
46
57
  <RailButton
47
58
  icon={RefreshCw}
48
59
  label="Sync"
49
- onClick={toggleSyncPanel}
60
+ onClick={inChat(toggleSyncPanel)}
50
61
  disabled={isStreaming}
51
62
  />
52
63
  <RailButton
53
64
  icon={Newspaper}
54
65
  label="Whatsup"
55
- onClick={toggleWhatsupPanel}
66
+ onClick={inChat(toggleWhatsupPanel)}
56
67
  disabled={isStreaming}
57
68
  />
58
69
  <RailButton
@@ -60,6 +71,14 @@ export function SideRail() {
60
71
  label="Files"
61
72
  onClick={toggleFilePanel}
62
73
  />
74
+ <RailButton
75
+ icon={Waypoints}
76
+ label="Graph"
77
+ active={activeView === "graph"}
78
+ onClick={() =>
79
+ setActiveView(activeView === "graph" ? "chat" : "graph")
80
+ }
81
+ />
63
82
 
64
83
  {/* Spacer */}
65
84
  <div className="flex-1" />
@@ -68,7 +87,7 @@ export function SideRail() {
68
87
  <RailButton
69
88
  icon={History}
70
89
  label="Sessions"
71
- onClick={toggleSessionPanel}
90
+ onClick={inChat(toggleSessionPanel)}
72
91
  />
73
92
 
74
93
  {/* Settings (models, passkeys, sign out) */}
@@ -105,18 +124,23 @@ function RailButton({
105
124
  label,
106
125
  onClick,
107
126
  disabled,
127
+ active,
108
128
  }: {
109
129
  icon: typeof Brain;
110
130
  label: string;
111
131
  onClick: () => void;
112
132
  disabled?: boolean;
133
+ active?: boolean;
113
134
  }) {
114
135
  return (
115
136
  <button
116
137
  onClick={onClick}
117
138
  disabled={disabled}
118
139
  title={label}
119
- className="group relative flex h-10 w-10 items-center justify-center rounded-lg text-muted-foreground transition-all duration-150 hover:bg-surface-raised hover:text-foreground hover:scale-110 disabled:opacity-40 disabled:hover:scale-100"
140
+ className={cn(
141
+ "group relative flex h-10 w-10 items-center justify-center rounded-lg transition-all duration-150 hover:bg-surface-raised hover:text-foreground hover:scale-110 disabled:opacity-40 disabled:hover:scale-100",
142
+ active ? "bg-surface-raised text-primary" : "text-muted-foreground"
143
+ )}
120
144
  >
121
145
  <Icon className="h-4.5 w-4.5" />
122
146
  {/* Tooltip */}
package/src/index.ts CHANGED
@@ -15,6 +15,7 @@ export { configureBrainUi, uiConfig, type BrainUiConfig } from "./config.js";
15
15
  export { ConnectionGate } from "./components/connectivity/connection-gate.js";
16
16
  export { AppShell } from "./components/layout/app-shell.js";
17
17
  export { ChatPage } from "./components/chat/chat-page.js";
18
+ export { GraphPage } from "./components/graph/graph-page.js";
18
19
 
19
20
  // Markdown renderer (also useful standalone, e.g. for a dev kitchen sink).
20
21
  export { BrainMarkdown } from "./components/chat/brain-markdown.js";
@@ -32,7 +33,8 @@ export {
32
33
  type MessageAttachment,
33
34
  } from "./stores/chat-store.js";
34
35
  export { useFileStore } from "./stores/file-store.js";
35
- export { useUIStore } from "./stores/ui-store.js";
36
+ export { useUIStore, type ActiveView } from "./stores/ui-store.js";
37
+ export { useGraphStore, type GraphMode } from "./stores/graph-store.js";
36
38
  export { useConnectionStore } from "./stores/connection-store.js";
37
39
  export { useProviderStore } from "./stores/provider-store.js";
38
40
  export { useVoiceStore } from "./voice/voice-store.js";