@schlessera/brain-ui-react 0.33.1 → 0.34.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 (157) hide show
  1. package/README.md +5 -0
  2. package/dist/components/activity/activity-page.d.ts.map +1 -1
  3. package/dist/components/activity/activity-page.js +8 -149
  4. package/dist/components/activity/activity-page.js.map +1 -1
  5. package/dist/components/activity/activity-rollups.d.ts +5 -0
  6. package/dist/components/activity/activity-rollups.d.ts.map +1 -0
  7. package/dist/components/activity/activity-rollups.js +39 -0
  8. package/dist/components/activity/activity-rollups.js.map +1 -0
  9. package/dist/components/activity/activity-run-detail.d.ts +11 -0
  10. package/dist/components/activity/activity-run-detail.d.ts.map +1 -0
  11. package/dist/components/activity/activity-run-detail.js +111 -0
  12. package/dist/components/activity/activity-run-detail.js.map +1 -0
  13. package/dist/components/activity/activity-run-list.d.ts +15 -0
  14. package/dist/components/activity/activity-run-list.d.ts.map +1 -0
  15. package/dist/components/activity/activity-run-list.js +25 -0
  16. package/dist/components/activity/activity-run-list.js.map +1 -0
  17. package/dist/components/chat/brain-markdown-code.d.ts +4 -0
  18. package/dist/components/chat/brain-markdown-code.d.ts.map +1 -0
  19. package/dist/components/chat/brain-markdown-code.js +47 -0
  20. package/dist/components/chat/brain-markdown-code.js.map +1 -0
  21. package/dist/components/chat/brain-markdown-entities.d.ts +14 -0
  22. package/dist/components/chat/brain-markdown-entities.d.ts.map +1 -0
  23. package/dist/components/chat/brain-markdown-entities.js +93 -0
  24. package/dist/components/chat/brain-markdown-entities.js.map +1 -0
  25. package/dist/components/chat/brain-markdown-links.d.ts +36 -0
  26. package/dist/components/chat/brain-markdown-links.d.ts.map +1 -0
  27. package/dist/components/chat/brain-markdown-links.js +176 -0
  28. package/dist/components/chat/brain-markdown-links.js.map +1 -0
  29. package/dist/components/chat/brain-markdown-share.d.ts +15 -0
  30. package/dist/components/chat/brain-markdown-share.d.ts.map +1 -0
  31. package/dist/components/chat/brain-markdown-share.js +40 -0
  32. package/dist/components/chat/brain-markdown-share.js.map +1 -0
  33. package/dist/components/chat/brain-markdown.d.ts +1 -34
  34. package/dist/components/chat/brain-markdown.d.ts.map +1 -1
  35. package/dist/components/chat/brain-markdown.js +9 -402
  36. package/dist/components/chat/brain-markdown.js.map +1 -1
  37. package/dist/components/chat/composer.d.ts.map +1 -1
  38. package/dist/components/chat/composer.js +14 -1
  39. package/dist/components/chat/composer.js.map +1 -1
  40. package/dist/components/chat/use-markdown-highlight.d.ts +12 -0
  41. package/dist/components/chat/use-markdown-highlight.d.ts.map +1 -0
  42. package/dist/components/chat/use-markdown-highlight.js +56 -0
  43. package/dist/components/chat/use-markdown-highlight.js.map +1 -0
  44. package/dist/components/connectivity/connection-gate.d.ts.map +1 -1
  45. package/dist/components/connectivity/connection-gate.js +41 -7
  46. package/dist/components/connectivity/connection-gate.js.map +1 -1
  47. package/dist/components/connectivity/connection-state.d.ts +12 -0
  48. package/dist/components/connectivity/connection-state.d.ts.map +1 -0
  49. package/dist/components/connectivity/connection-state.js +23 -0
  50. package/dist/components/connectivity/connection-state.js.map +1 -0
  51. package/dist/components/graph/graph-canvas-lazy.d.ts +2 -0
  52. package/dist/components/graph/graph-canvas-lazy.d.ts.map +1 -0
  53. package/dist/components/graph/graph-canvas-lazy.js +4 -0
  54. package/dist/components/graph/graph-canvas-lazy.js.map +1 -0
  55. package/dist/components/graph/graph-controls.js +4 -1
  56. package/dist/components/graph/graph-controls.js.map +1 -1
  57. package/dist/components/graph/graph-maintenance.d.ts +8 -0
  58. package/dist/components/graph/graph-maintenance.d.ts.map +1 -0
  59. package/dist/components/graph/graph-maintenance.js +115 -0
  60. package/dist/components/graph/graph-maintenance.js.map +1 -0
  61. package/dist/components/graph/graph-page.d.ts.map +1 -1
  62. package/dist/components/graph/graph-page.js +11 -306
  63. package/dist/components/graph/graph-page.js.map +1 -1
  64. package/dist/components/graph/graph-scene.d.ts +8 -0
  65. package/dist/components/graph/graph-scene.d.ts.map +1 -0
  66. package/dist/components/graph/graph-scene.js +208 -0
  67. package/dist/components/graph/graph-scene.js.map +1 -0
  68. package/dist/components/graph/graph-spinner.d.ts +2 -0
  69. package/dist/components/graph/graph-spinner.d.ts.map +1 -0
  70. package/dist/components/graph/graph-spinner.js +6 -0
  71. package/dist/components/graph/graph-spinner.js.map +1 -0
  72. package/dist/hooks/use-vpn-status.d.ts +5 -0
  73. package/dist/hooks/use-vpn-status.d.ts.map +1 -1
  74. package/dist/hooks/use-vpn-status.js +61 -29
  75. package/dist/hooks/use-vpn-status.js.map +1 -1
  76. package/dist/hooks/use-websocket.d.ts +3 -2
  77. package/dist/hooks/use-websocket.d.ts.map +1 -1
  78. package/dist/hooks/use-websocket.js +61 -252
  79. package/dist/hooks/use-websocket.js.map +1 -1
  80. package/dist/hooks/websocket-handlers/activity.d.ts +5 -0
  81. package/dist/hooks/websocket-handlers/activity.d.ts.map +1 -0
  82. package/dist/hooks/websocket-handlers/activity.js +10 -0
  83. package/dist/hooks/websocket-handlers/activity.js.map +1 -0
  84. package/dist/hooks/websocket-handlers/chat.d.ts +17 -0
  85. package/dist/hooks/websocket-handlers/chat.d.ts.map +1 -0
  86. package/dist/hooks/websocket-handlers/chat.js +175 -0
  87. package/dist/hooks/websocket-handlers/chat.js.map +1 -0
  88. package/dist/hooks/websocket-handlers/connection.d.ts +6 -0
  89. package/dist/hooks/websocket-handlers/connection.d.ts.map +1 -0
  90. package/dist/hooks/websocket-handlers/connection.js +27 -0
  91. package/dist/hooks/websocket-handlers/connection.js.map +1 -0
  92. package/dist/hooks/websocket-handlers/index.d.ts +4 -0
  93. package/dist/hooks/websocket-handlers/index.d.ts.map +1 -0
  94. package/dist/hooks/websocket-handlers/index.js +19 -0
  95. package/dist/hooks/websocket-handlers/index.js.map +1 -0
  96. package/dist/hooks/websocket-handlers/mask.d.ts +4 -0
  97. package/dist/hooks/websocket-handlers/mask.d.ts.map +1 -0
  98. package/dist/hooks/websocket-handlers/mask.js +14 -0
  99. package/dist/hooks/websocket-handlers/mask.js.map +1 -0
  100. package/dist/hooks/websocket-handlers/provider.d.ts +4 -0
  101. package/dist/hooks/websocket-handlers/provider.d.ts.map +1 -0
  102. package/dist/hooks/websocket-handlers/provider.js +31 -0
  103. package/dist/hooks/websocket-handlers/provider.js.map +1 -0
  104. package/dist/hooks/websocket-handlers/types.d.ts +24 -0
  105. package/dist/hooks/websocket-handlers/types.d.ts.map +1 -0
  106. package/dist/hooks/websocket-handlers/types.js +2 -0
  107. package/dist/hooks/websocket-handlers/types.js.map +1 -0
  108. package/dist/stores/activity-store-helpers.d.ts +50 -0
  109. package/dist/stores/activity-store-helpers.d.ts.map +1 -0
  110. package/dist/stores/activity-store-helpers.js +143 -0
  111. package/dist/stores/activity-store-helpers.js.map +1 -0
  112. package/dist/stores/activity-store-types.d.ts +48 -0
  113. package/dist/stores/activity-store-types.d.ts.map +1 -0
  114. package/dist/stores/activity-store-types.js +2 -0
  115. package/dist/stores/activity-store-types.js.map +1 -0
  116. package/dist/stores/activity-store.d.ts +2 -78
  117. package/dist/stores/activity-store.d.ts.map +1 -1
  118. package/dist/stores/activity-store.js +22 -142
  119. package/dist/stores/activity-store.js.map +1 -1
  120. package/dist/stores/connection-store.d.ts +14 -2
  121. package/dist/stores/connection-store.d.ts.map +1 -1
  122. package/dist/stores/connection-store.js +17 -0
  123. package/dist/stores/connection-store.js.map +1 -1
  124. package/dist/styles.css +1 -1
  125. package/package.json +2 -2
  126. package/src/components/activity/activity-page.tsx +6 -477
  127. package/src/components/activity/activity-rollups.tsx +94 -0
  128. package/src/components/activity/activity-run-detail.tsx +301 -0
  129. package/src/components/activity/activity-run-list.tsx +97 -0
  130. package/src/components/chat/brain-markdown-code.tsx +60 -0
  131. package/src/components/chat/brain-markdown-entities.tsx +116 -0
  132. package/src/components/chat/brain-markdown-links.tsx +247 -0
  133. package/src/components/chat/brain-markdown-share.ts +47 -0
  134. package/src/components/chat/brain-markdown.tsx +27 -535
  135. package/src/components/chat/composer.tsx +14 -1
  136. package/src/components/chat/use-markdown-highlight.ts +63 -0
  137. package/src/components/connectivity/connection-gate.tsx +64 -13
  138. package/src/components/connectivity/connection-state.ts +37 -0
  139. package/src/components/graph/graph-canvas-lazy.tsx +4 -0
  140. package/src/components/graph/graph-controls.tsx +4 -1
  141. package/src/components/graph/graph-maintenance.tsx +304 -0
  142. package/src/components/graph/graph-page.tsx +11 -840
  143. package/src/components/graph/graph-scene.tsx +548 -0
  144. package/src/components/graph/graph-spinner.tsx +9 -0
  145. package/src/hooks/use-vpn-status.ts +64 -31
  146. package/src/hooks/use-websocket.ts +70 -273
  147. package/src/hooks/websocket-handlers/activity.ts +13 -0
  148. package/src/hooks/websocket-handlers/chat.ts +210 -0
  149. package/src/hooks/websocket-handlers/connection.ts +30 -0
  150. package/src/hooks/websocket-handlers/index.ts +59 -0
  151. package/src/hooks/websocket-handlers/mask.ts +17 -0
  152. package/src/hooks/websocket-handlers/provider.ts +35 -0
  153. package/src/hooks/websocket-handlers/types.ts +27 -0
  154. package/src/stores/activity-store-helpers.ts +164 -0
  155. package/src/stores/activity-store-types.ts +55 -0
  156. package/src/stores/activity-store.ts +21 -217
  157. package/src/stores/connection-store.ts +33 -2
@@ -1,46 +1,17 @@
1
- import { Suspense, lazy, useEffect, useMemo, useRef, useState } from "react";
2
- import {
3
- ChevronDown,
4
- ChevronUp,
5
- List,
6
- Loader2,
7
- TriangleAlert,
8
- X,
9
- } from "lucide-react";
10
- import type {
11
- GraphMaintenanceResponse,
12
- GraphNodePayload,
13
- GraphSubgraphResponse,
14
- } from "@schlessera/brain-ui-sdk/protocol";
1
+ import { useEffect } from "react";
2
+ import { TriangleAlert } from "lucide-react";
3
+
15
4
  import { useGraphStore, type GraphMode } from "../../stores/graph-store.js";
16
5
  import { useUIStore } from "../../stores/ui-store.js";
17
- import { useFileStore } from "../../stores/file-store.js";
18
- import { apiBase } from "../../lib/backend.js";
19
6
  import { FilePanel } from "../files/file-panel.js";
20
7
  import { SettingsPanel } from "../settings/settings-panel.js";
21
8
  import { GraphControls } from "./graph-controls.js";
22
- import { NodePopover } from "./node-popover.js";
23
9
  import { GraphEmptyState, Mono } from "./graph-empty-state.js";
24
- import {
25
- OTHER_COLOR,
26
- assignFolderColors,
27
- buildQuery,
28
- communityColor,
29
- distanceColor,
30
- groupCommunities,
31
- matchScene,
32
- topLevelDir,
33
- } from "./lib/graph-helpers.js";
34
- import { useGraphTheme } from "./use-graph-theme.js";
10
+ import { MaintenanceBody } from "./graph-maintenance.js";
11
+ import { DiscoveryStart, SceneBody } from "./graph-scene.js";
12
+ import { CenteredSpinner } from "./graph-spinner.js";
35
13
  import { cn } from "../../lib/utils.js";
36
14
 
37
- // The one code-split boundary: sigma + graphology load only when a scene renders.
38
- const GraphCanvas = lazy(() => import("./graph-canvas.js"));
39
-
40
- /** Client force layout is a fallback for repos whose FA2 layout was skipped —
41
- * beyond this it would freeze the tab, so we refuse and say why. */
42
- const CLIENT_LAYOUT_NODE_CAP = 2000;
43
-
44
15
  const MODES: { value: GraphMode; label: string }[] = [
45
16
  { value: "clusters", label: "Clusters" },
46
17
  { value: "discovery", label: "Discovery" },
@@ -48,15 +19,6 @@ const MODES: { value: GraphMode; label: string }[] = [
48
19
  { value: "maintenance", label: "Maintenance" },
49
20
  ];
50
21
 
51
- /** Finding-type colors: dataviz status palette (warning/serious/critical) plus
52
- * the light end of the distance ramp for "stale" — checked against #0c0e12. */
53
- const FINDING_COLORS = {
54
- orphan: "#fab219",
55
- unreachable: "#ec835a",
56
- broken: "#d03b3b",
57
- stale: "#86b6ef",
58
- } as const;
59
-
60
22
  /**
61
23
  * Full-screen knowledge-graph view — a sibling of ChatPage inside the
62
24
  * AppShell. Mounts its own FilePanel/SettingsPanel copies because the chat
@@ -78,14 +40,19 @@ export function GraphPage() {
78
40
  const setSettingsPanelOpen = useUIStore((s) => s.setSettingsPanelOpen);
79
41
 
80
42
  useEffect(() => {
43
+ let active = true;
81
44
  // Always refetch meta on mount (the view unmounts when hidden, so this
82
45
  // also covers "came back after a brain sync"): a changed computedAt is
83
46
  // what invalidates every cached scene. Meta must land before the scene
84
47
  // fetch so the cache is keyed by the fresh generation.
85
48
  void (async () => {
86
49
  await fetchMeta(true);
50
+ if (!active) return;
87
51
  await fetchScene();
88
52
  })();
53
+ return () => {
54
+ active = false;
55
+ };
89
56
  }, [fetchMeta, fetchScene]);
90
57
 
91
58
  return (
@@ -247,799 +214,3 @@ function GraphBody({
247
214
 
248
215
  return <SceneBody />;
249
216
  }
250
-
251
- /** The canvas plus everything overlaid on it. Only mounts with data present. */
252
- function SceneBody() {
253
- const mode = useGraphStore((s) => s.mode);
254
- const meta = useGraphStore((s) => s.meta);
255
- const subgraph = useGraphStore((s) => s.subgraph)!;
256
- const selectedId = useGraphStore((s) => s.selectedId);
257
- const hoveredId = useGraphStore((s) => s.hoveredId);
258
- const select = useGraphStore((s) => s.select);
259
- const hover = useGraphStore((s) => s.hover);
260
- const sceneQuery = useGraphStore((s) => s.sceneQuery);
261
- const clustersSizeBy = useGraphStore((s) => s.clustersSizeBy);
262
- const discoveryColorBy = useGraphStore((s) => s.discoveryColorBy);
263
- const theme = useGraphTheme();
264
- const [listOpen, setListOpen] = useState(false);
265
-
266
- const matchIds = useMemo(
267
- () => matchScene(subgraph.nodes, sceneQuery),
268
- [subgraph.nodes, sceneQuery]
269
- );
270
-
271
- const layout =
272
- mode === "clusters"
273
- ? subgraph.nodes.some((n) => n.x !== undefined)
274
- ? ("fixed" as const)
275
- : ("force" as const)
276
- : mode === "discovery"
277
- ? ("radial" as const)
278
- : ("force" as const);
279
-
280
- const maxDistance = useMemo(
281
- () => Math.max(1, ...subgraph.nodes.map((n) => n.distance ?? 0)),
282
- [subgraph.nodes]
283
- );
284
-
285
- const folderColors = useMemo(
286
- () =>
287
- mode === "discovery" && discoveryColorBy === "folder"
288
- ? assignFolderColors(subgraph.nodes.map((n) => n.path))
289
- : null,
290
- [mode, discoveryColorBy, subgraph.nodes]
291
- );
292
-
293
- const nodeColor = useMemo(() => {
294
- if (mode === "discovery") {
295
- if (folderColors) {
296
- return (node: GraphNodePayload) =>
297
- node.virtual
298
- ? theme.nodeSelected
299
- : (folderColors.get(topLevelDir(node.path)) ?? OTHER_COLOR);
300
- }
301
- return (node: GraphNodePayload) =>
302
- node.virtual ? theme.nodeSelected : distanceColor(node.distance ?? maxDistance);
303
- }
304
- return (node: GraphNodePayload) =>
305
- node.community !== undefined ? communityColor(node.community) : theme.node;
306
- }, [mode, folderColors, maxDistance, theme]);
307
-
308
- const centerId = useMemo(
309
- () =>
310
- mode === "local"
311
- ? (subgraph.nodes.find((n) => n.distance === 0)?.id ?? null)
312
- : null,
313
- [mode, subgraph.nodes]
314
- );
315
-
316
- const edgeColor = useMemo(() => {
317
- if (mode !== "local" || centerId === null) return undefined;
318
- // Backlinks into the center get the accent so in/out reads at a glance.
319
- return (edge: { source: number; target: number }) =>
320
- edge.target === centerId ? theme.edgeHighlight : undefined;
321
- }, [mode, centerId, theme]);
322
-
323
- const selectedNode = useMemo(
324
- () =>
325
- selectedId === null
326
- ? null
327
- : (subgraph.nodes.find((n) => n.id === selectedId) ?? null),
328
- [selectedId, subgraph.nodes]
329
- );
330
-
331
- const communityLabel =
332
- selectedNode?.community !== undefined
333
- ? (meta?.communities.find((c) => c.community === selectedNode.community)
334
- ?.label ?? null)
335
- : null;
336
-
337
- // Clusters whose FA2 layout was skipped at index time: client force layout
338
- // is a bounded courtesy. Over the cap, only the canvas is replaced — the
339
- // legend and controls stay live so filtering to one topic (a subset under
340
- // the cap) still works.
341
- const layoutBlocked =
342
- mode === "clusters" &&
343
- layout === "force" &&
344
- subgraph.nodes.length > CLIENT_LAYOUT_NODE_CAP;
345
-
346
- return (
347
- <>
348
- {layoutBlocked ? (
349
- <GraphEmptyState icon="warn" title="Too large to lay out here">
350
- The cluster layout was skipped at index time (corpus over the layout
351
- cap), and {subgraph.nodes.length.toLocaleString()} notes are too many
352
- to arrange in the browser. Pick a topic from the legend to view a
353
- subset.
354
- </GraphEmptyState>
355
- ) : (
356
- <Suspense fallback={<CenteredSpinner />}>
357
- <GraphCanvas
358
- data={subgraph}
359
- layout={layout}
360
- selectedId={selectedId}
361
- hoveredId={hoveredId}
362
- matchIds={matchIds}
363
- sizeBy={mode === "clusters" ? clustersSizeBy : "degree"}
364
- rings={mode === "discovery" ? maxDistance : 0}
365
- nodeColor={nodeColor}
366
- edgeColor={edgeColor}
367
- onSelect={select}
368
- onHover={hover}
369
- />
370
- </Suspense>
371
- )}
372
-
373
- <GraphControls />
374
-
375
- {mode === "clusters" && <ClusterLegend />}
376
- {mode === "discovery" && folderColors && (
377
- <FolderLegend colors={folderColors} />
378
- )}
379
- {mode === "discovery" && (
380
- <UnreachableTray count={subgraph.unreachableCount} />
381
- )}
382
-
383
- {/* Accessible / mobile-friendly node list — the canvas itself is aria-hidden */}
384
- <button
385
- onClick={() => setListOpen((v) => !v)}
386
- className="absolute bottom-3 right-3 z-10 flex h-11 w-11 items-center justify-center rounded-full border border-border bg-surface-overlay text-foreground shadow-lg"
387
- title="Node list"
388
- aria-expanded={listOpen}
389
- >
390
- <List className="h-4.5 w-4.5" />
391
- </button>
392
- {listOpen && <NodeList onClose={() => setListOpen(false)} />}
393
-
394
- {selectedNode && (
395
- <NodePopover node={selectedNode} communityLabel={communityLabel} />
396
- )}
397
- </>
398
- );
399
- }
400
-
401
- /** Clusters mode: interactive community legend, top-left. */
402
- function ClusterLegend() {
403
- const meta = useGraphStore((s) => s.meta);
404
- const clusters = useGraphStore((s) => s.clusters);
405
- const setClustersParams = useGraphStore((s) => s.setClustersParams);
406
- const [collapsed, setCollapsed] = useState(false);
407
-
408
- const groups = useMemo(
409
- () => groupCommunities(meta?.communities ?? []),
410
- [meta?.communities]
411
- );
412
- if (groups.major.length === 0) return null;
413
-
414
- const shown = groups.major.slice(0, 8);
415
- const foldedCount =
416
- groups.major.length - shown.length + groups.singletonCount;
417
-
418
- return (
419
- <div className="absolute left-3 top-3 z-10 w-56 max-w-[calc(100vw-5rem)] rounded-xl border border-border bg-surface-overlay/95 shadow-xl backdrop-blur">
420
- <button
421
- onClick={() => setCollapsed((v) => !v)}
422
- className="flex w-full items-center justify-between px-3 py-2 text-xs font-medium text-foreground"
423
- aria-expanded={!collapsed}
424
- >
425
- Topics
426
- {collapsed ? (
427
- <ChevronDown className="h-3.5 w-3.5 text-muted-foreground" />
428
- ) : (
429
- <ChevronUp className="h-3.5 w-3.5 text-muted-foreground" />
430
- )}
431
- </button>
432
- {!collapsed && (
433
- <div className="max-h-64 overflow-y-auto px-1 pb-2">
434
- {shown.map((c) => {
435
- const active = clusters.community === c.community;
436
- return (
437
- <button
438
- key={c.community}
439
- onClick={() =>
440
- setClustersParams({ community: active ? null : c.community })
441
- }
442
- className={cn(
443
- "flex min-h-9 w-full items-center gap-2 rounded-lg px-2 py-1 text-left transition-colors",
444
- active ? "bg-surface-raised" : "hover:bg-surface-raised/60"
445
- )}
446
- title={
447
- active
448
- ? "Show all topics"
449
- : `Show only this topic (${c.size} notes)`
450
- }
451
- >
452
- <span
453
- className="h-2.5 w-2.5 shrink-0 rounded-full"
454
- style={{ backgroundColor: communityColor(c.community) }}
455
- />
456
- <span className="min-w-0 flex-1 truncate text-xs text-foreground">
457
- {c.label ?? `Topic ${c.community + 1}`}
458
- </span>
459
- <span className="shrink-0 text-[10px] text-muted-foreground">
460
- {c.size}
461
- </span>
462
- </button>
463
- );
464
- })}
465
- {foldedCount > 0 && (
466
- <div className="flex items-center gap-2 px-2 py-1.5">
467
- <span
468
- className="h-2.5 w-2.5 shrink-0 rounded-full"
469
- style={{ backgroundColor: OTHER_COLOR }}
470
- />
471
- <span className="text-[11px] text-muted-foreground">
472
- {foldedCount} smaller topic{foldedCount === 1 ? "" : "s"}
473
- </span>
474
- </div>
475
- )}
476
- {clusters.community !== null && (
477
- <button
478
- onClick={() => setClustersParams({ community: null })}
479
- className="mx-2 mt-1 flex min-h-8 items-center gap-1 rounded-lg bg-surface-raised px-2 py-1 text-[11px] text-foreground hover:bg-surface"
480
- >
481
- <X className="h-3 w-3" />
482
- Clear topic filter
483
- </button>
484
- )}
485
- </div>
486
- )}
487
- </div>
488
- );
489
- }
490
-
491
- /** Discovery + color-by-folder: which top-level dir wears which color. */
492
- function FolderLegend({ colors }: { colors: Map<string, string> }) {
493
- const entries = [...colors.entries()].filter(([, c]) => c !== OTHER_COLOR);
494
- const otherCount = colors.size - entries.length;
495
- if (entries.length === 0) return null;
496
- return (
497
- <div className="absolute left-3 top-3 z-10 w-48 rounded-xl border border-border bg-surface-overlay/95 px-3 py-2 shadow-xl backdrop-blur">
498
- <div className="mb-1 text-xs font-medium text-foreground">Folders</div>
499
- {entries.map(([dir, color]) => (
500
- <div key={dir} className="flex min-h-6 items-center gap-2">
501
- <span
502
- className="h-2.5 w-2.5 shrink-0 rounded-full"
503
- style={{ backgroundColor: color }}
504
- />
505
- <span className="truncate font-mono text-[11px] text-muted-foreground">
506
- {dir || "(root)"}
507
- </span>
508
- </div>
509
- ))}
510
- {otherCount > 0 && (
511
- <div className="flex min-h-6 items-center gap-2">
512
- <span
513
- className="h-2.5 w-2.5 shrink-0 rounded-full"
514
- style={{ backgroundColor: OTHER_COLOR }}
515
- />
516
- <span className="text-[11px] text-muted-foreground">
517
- {otherCount} more
518
- </span>
519
- </div>
520
- )}
521
- </div>
522
- );
523
- }
524
-
525
- /**
526
- * Discovery: how much of the corpus this root does NOT reach. The count comes
527
- * from the server (`unreachableCount`) — a scene-size subtraction would lie
528
- * under depth limits, truncation, and the virtual root. An older server
529
- * doesn't send it; then the tray only appears for the default root, where the
530
- * maintenance findings still provide the exact list, and shows no number.
531
- */
532
- function UnreachableTray({ count }: { count: number | undefined }) {
533
- const discovery = useGraphStore((s) => s.discovery);
534
- const staleDays = useGraphStore((s) => s.maintenance.staleDays);
535
- const openFile = useFileStore((s) => s.openFile);
536
- const setFilePanelOpen = useUIStore((s) => s.setFilePanelOpen);
537
- const [open, setOpen] = useState(false);
538
- const [nodes, setNodes] = useState<GraphNodePayload[] | null>(null);
539
-
540
- const isDefaultRoot = !discovery.root;
541
- // Older server, no count field: the maintenance list is the only honest
542
- // source, so it must be fetched EAGERLY — the chip may only appear once
543
- // the list proves there is something to warn about.
544
- const needsFallbackProof = count === undefined && isDefaultRoot;
545
-
546
- useEffect(() => {
547
- const wanted = (open || needsFallbackProof) && isDefaultRoot;
548
- if (!wanted || nodes !== null) return;
549
- let cancelled = false;
550
- void (async () => {
551
- try {
552
- const res = await fetch(
553
- `${apiBase()}/graph/maintenance?${buildQuery({ staleDays })}`
554
- );
555
- if (!res.ok) return;
556
- const data = (await res.json()) as GraphMaintenanceResponse;
557
- if (!cancelled) setNodes(data.unreachable);
558
- } catch {
559
- // The tray degrades to count-only (or stays hidden in fallback mode).
560
- }
561
- })();
562
- return () => {
563
- cancelled = true;
564
- };
565
- }, [open, needsFallbackProof, isDefaultRoot, nodes, staleDays]);
566
-
567
- if (count === 0) return null;
568
- // No server count and no exact list available: nothing honest to show.
569
- if (count === undefined && !isDefaultRoot) return null;
570
- // Fallback mode: silent until the maintenance list confirms unreachable
571
- // notes exist — an unproven warning chip would cry wolf on clean repos.
572
- if (needsFallbackProof && (nodes === null || nodes.length === 0)) return null;
573
-
574
- return (
575
- <div className="absolute bottom-3 left-3 z-10 max-w-[calc(100vw-5rem)]">
576
- {open && (
577
- <div className="mb-2 flex max-h-56 w-64 flex-col overflow-hidden rounded-xl border border-border bg-surface-overlay/95 shadow-2xl backdrop-blur">
578
- <div className="border-b border-border px-3 py-2 text-[11px] text-muted-foreground">
579
- {isDefaultRoot
580
- ? "Not reachable from the root:"
581
- : "Count only — pick the default root for the full list."}
582
- </div>
583
- {isDefaultRoot && (
584
- <div className="flex-1 overflow-y-auto">
585
- {(nodes ?? []).map((node) => (
586
- <button
587
- key={node.id}
588
- onClick={() => {
589
- setFilePanelOpen(true);
590
- void openFile(node.path);
591
- }}
592
- className="flex min-h-9 w-full flex-col justify-center px-3 py-1 text-left hover:bg-surface-raised/60"
593
- >
594
- <span className="truncate text-xs text-foreground">
595
- {node.title || node.path}
596
- </span>
597
- </button>
598
- ))}
599
- {nodes === null && (
600
- <div className="flex justify-center py-3">
601
- <Loader2 className="h-4 w-4 animate-spin text-primary" />
602
- </div>
603
- )}
604
- </div>
605
- )}
606
- </div>
607
- )}
608
- <button
609
- onClick={() => setOpen((v) => !v)}
610
- aria-expanded={open}
611
- className="flex min-h-9 items-center gap-1.5 rounded-full border border-border bg-surface-overlay px-3 py-1.5 text-[11px] text-muted-foreground shadow-lg transition-colors hover:text-foreground"
612
- >
613
- <TriangleAlert className="h-3 w-3" />
614
- {count !== undefined
615
- ? `${count} unreachable`
616
- : `${nodes!.length} unreachable`}
617
- </button>
618
- </div>
619
- );
620
- }
621
-
622
- /**
623
- * Discovery with no root at all (no entry file in the repo): make picking one
624
- * inviting — index notes are the natural candidates.
625
- */
626
- function DiscoveryStart() {
627
- const setDiscoveryParams = useGraphStore((s) => s.setDiscoveryParams);
628
- const [candidates, setCandidates] = useState<
629
- { path: string; title: string }[] | null
630
- >(null);
631
-
632
- useEffect(() => {
633
- let cancelled = false;
634
- void (async () => {
635
- try {
636
- const res = await fetch(
637
- `${apiBase()}/brain/list?${buildQuery({ type: "index", limit: 6 })}`
638
- );
639
- if (!res.ok) return;
640
- const data = (await res.json()) as {
641
- results?: { path: string; title?: string }[];
642
- };
643
- if (!cancelled)
644
- setCandidates(
645
- (data.results ?? []).map((r) => ({
646
- path: r.path,
647
- title: r.title ?? r.path,
648
- }))
649
- );
650
- } catch {
651
- if (!cancelled) setCandidates([]);
652
- }
653
- })();
654
- return () => {
655
- cancelled = true;
656
- };
657
- }, []);
658
-
659
- return (
660
- <>
661
- <div className="flex h-full items-center justify-center p-6">
662
- <div className="flex w-full max-w-md flex-col gap-3 rounded-xl border border-border bg-surface px-8 py-10 text-center">
663
- <h2 className="font-display text-lg text-foreground">
664
- Where should discovery start?
665
- </h2>
666
- <p className="text-xs leading-relaxed text-muted-foreground">
667
- Discovery lays your notes out by how many links they are away from
668
- a starting point. This repo has no <Mono>AGENTS.md</Mono> entry
669
- file, so pick one — index notes work well:
670
- </p>
671
- {candidates === null ? (
672
- <div className="flex justify-center py-2">
673
- <Loader2 className="h-4 w-4 animate-spin text-primary" />
674
- </div>
675
- ) : (
676
- <div className="flex flex-col gap-1.5">
677
- {candidates.map((c) => (
678
- <button
679
- key={c.path}
680
- onClick={() => setDiscoveryParams({ root: c.path })}
681
- className="flex min-h-10 flex-col justify-center rounded-lg border border-border bg-surface-raised px-3 py-1.5 text-left transition-colors hover:border-primary/40"
682
- >
683
- <span className="truncate text-xs text-foreground">
684
- {c.title}
685
- </span>
686
- <span className="truncate font-mono text-[10px] text-muted-foreground/60">
687
- {c.path}
688
- </span>
689
- </button>
690
- ))}
691
- </div>
692
- )}
693
- <p className="text-[11px] text-muted-foreground">
694
- …or search any note in the options panel.
695
- </p>
696
- </div>
697
- </div>
698
- <GraphControls />
699
- </>
700
- );
701
- }
702
-
703
- /** Focusable listing of the current scene, sorted by connectedness. */
704
- function NodeList({ onClose }: { onClose: () => void }) {
705
- const subgraph = useGraphStore((s) => s.subgraph)!;
706
- const select = useGraphStore((s) => s.select);
707
- const selectedId = useGraphStore((s) => s.selectedId);
708
-
709
- const sorted = useMemo(
710
- () =>
711
- [...subgraph.nodes].sort(
712
- (a, b) => b.inDegree + b.outDegree - (a.inDegree + a.outDegree)
713
- ),
714
- [subgraph.nodes]
715
- );
716
-
717
- return (
718
- <nav
719
- aria-label="Graph nodes"
720
- className="absolute bottom-16 right-3 z-10 flex max-h-[55dvh] w-72 max-w-[calc(100vw-1.5rem)] flex-col overflow-hidden rounded-xl border border-border bg-surface-overlay/95 shadow-2xl backdrop-blur"
721
- >
722
- <div className="flex items-center justify-between border-b border-border px-3 py-2">
723
- <span className="text-xs font-medium text-foreground">
724
- {sorted.length} note{sorted.length === 1 ? "" : "s"}
725
- </span>
726
- <button
727
- onClick={onClose}
728
- className="rounded p-1 text-muted-foreground hover:text-foreground"
729
- title="Close list"
730
- >
731
- <X className="h-3.5 w-3.5" />
732
- </button>
733
- </div>
734
- <div className="flex-1 overflow-y-auto">
735
- {sorted.map((node) => (
736
- <button
737
- key={node.id}
738
- onClick={() => select(node.id)}
739
- className={cn(
740
- "flex min-h-11 w-full flex-col justify-center px-3 py-1.5 text-left transition-colors",
741
- node.id === selectedId
742
- ? "bg-surface-raised"
743
- : "hover:bg-surface-raised/60"
744
- )}
745
- >
746
- <span className="truncate text-xs text-foreground">
747
- {node.title || node.path}
748
- </span>
749
- <span className="truncate font-mono text-[10px] text-muted-foreground/60">
750
- {node.inDegree + node.outDegree} link
751
- {node.inDegree + node.outDegree === 1 ? "" : "s"} · {node.path}
752
- </span>
753
- </button>
754
- ))}
755
- </div>
756
- </nav>
757
- );
758
- }
759
-
760
- // --- Maintenance -------------------------------------------------------------
761
-
762
- type FindingKind = "orphan" | "unreachable" | "stale";
763
-
764
- interface FindingNode extends GraphNodePayload {
765
- finding: FindingKind;
766
- }
767
-
768
- /**
769
- * Maintenance: findings list ⇄ canvas, linked both ways. The canvas is a
770
- * type-grouped radial scene (one ring per finding kind — these nodes have no
771
- * edges between them by definition); broken links are list-only, since their
772
- * defining feature is a target that does not exist.
773
- */
774
- function MaintenanceBody() {
775
- const findings = useGraphStore((s) => s.findings);
776
- const dataState = useGraphStore((s) => s.dataState);
777
- const error = useGraphStore((s) => s.error);
778
- const filters = useGraphStore((s) => s.maintenanceFilters);
779
- const selectedId = useGraphStore((s) => s.selectedId);
780
- const select = useGraphStore((s) => s.select);
781
- const hoveredId = useGraphStore((s) => s.hoveredId);
782
- const hover = useGraphStore((s) => s.hover);
783
- const sceneQuery = useGraphStore((s) => s.sceneQuery);
784
- const openFile = useFileStore((s) => s.openFile);
785
- const setFilePanelOpen = useUIStore((s) => s.setFilePanelOpen);
786
- const listRef = useRef<HTMLDivElement>(null);
787
-
788
- // One node per document, first matching section wins (a stale orphan is an
789
- // orphan); ring index encodes the finding kind.
790
- const scene = useMemo(() => {
791
- if (!findings) return null;
792
- const seen = new Set<number>();
793
- const nodes: FindingNode[] = [];
794
- const push = (list: GraphNodePayload[], finding: FindingKind, ring: number) => {
795
- for (const node of list) {
796
- if (seen.has(node.id)) continue;
797
- seen.add(node.id);
798
- nodes.push({ ...node, finding, distance: ring });
799
- }
800
- };
801
- if (filters.orphans) push(findings.orphans, "orphan", 1);
802
- if (filters.unreachable) push(findings.unreachable, "unreachable", 2);
803
- if (filters.stale) push(findings.stale, "stale", 3);
804
- const subgraph: GraphSubgraphResponse = { nodes, edges: [], truncated: false };
805
- return { subgraph, nodes };
806
- }, [findings, filters]);
807
-
808
- // Canvas → list: keep the selected row in view.
809
- useEffect(() => {
810
- if (selectedId === null) return;
811
- listRef.current
812
- ?.querySelector(`[data-node-id="${selectedId}"]`)
813
- ?.scrollIntoView({ block: "nearest" });
814
- }, [selectedId]);
815
-
816
- if (dataState === "error") {
817
- return (
818
- <GraphEmptyState icon="warn" title="Could not load findings">
819
- {error?.message ?? "Request failed"}
820
- </GraphEmptyState>
821
- );
822
- }
823
- if (dataState !== "done" || !findings || !scene) return <CenteredSpinner />;
824
-
825
- const matchIds = matchScene(scene.nodes, sceneQuery);
826
- const selectedNode =
827
- selectedId === null
828
- ? null
829
- : (scene.nodes.find((n) => n.id === selectedId) ?? null);
830
-
831
- function openNote(path: string) {
832
- setFilePanelOpen(true);
833
- void openFile(path);
834
- }
835
-
836
- const totalShown =
837
- scene.nodes.length + (filters.broken ? findings.brokenLinks.length : 0);
838
-
839
- return (
840
- <div className="flex h-full flex-col md:flex-row">
841
- {/* Canvas pane */}
842
- <div className="relative h-56 shrink-0 border-b border-border md:h-auto md:flex-1 md:border-b-0 md:border-r">
843
- {scene.nodes.length === 0 ? (
844
- <div className="flex h-full items-center justify-center px-6 text-center text-xs text-muted-foreground">
845
- {totalShown === 0
846
- ? "Nothing to show — this brain is clean."
847
- : "Only broken links match — they live in the list."}
848
- </div>
849
- ) : (
850
- <Suspense fallback={<CenteredSpinner />}>
851
- <GraphCanvas
852
- data={scene.subgraph}
853
- layout="radial"
854
- selectedId={selectedId}
855
- hoveredId={hoveredId}
856
- matchIds={matchIds}
857
- nodeColor={(node) =>
858
- FINDING_COLORS[(node as FindingNode).finding ?? "stale"]
859
- }
860
- onSelect={select}
861
- onHover={hover}
862
- />
863
- </Suspense>
864
- )}
865
- <GraphControls />
866
- {selectedNode && <NodePopover node={selectedNode} />}
867
- </div>
868
-
869
- {/* Findings list pane */}
870
- <div
871
- ref={listRef}
872
- className="flex-1 overflow-y-auto px-4 py-4 md:w-96 md:flex-none md:px-5"
873
- >
874
- <div className="flex flex-col gap-5 pb-16">
875
- {filters.orphans && (
876
- <FindingSection
877
- title={`Orphans (${findings.orphans.length})`}
878
- hint="No links in or out."
879
- color={FINDING_COLORS.orphan}
880
- nodes={findings.orphans}
881
- selectedId={selectedId}
882
- onSelect={select}
883
- onOpen={openNote}
884
- />
885
- )}
886
- {filters.unreachable && (
887
- <FindingSection
888
- title={`Unreachable from root (${findings.unreachable.length})`}
889
- hint="No link path from the graph root reaches these."
890
- color={FINDING_COLORS.unreachable}
891
- nodes={findings.unreachable}
892
- selectedId={selectedId}
893
- onSelect={select}
894
- onOpen={openNote}
895
- />
896
- )}
897
- {filters.broken && (
898
- <section>
899
- <SectionHeading
900
- title={`Broken links (${findings.brokenLinks.length})`}
901
- hint="Wiki links whose target resolves to nothing."
902
- color={FINDING_COLORS.broken}
903
- />
904
- <FindingRows
905
- empty={findings.brokenLinks.length === 0}
906
- rows={findings.brokenLinks.map((b, i) => ({
907
- key: `b${i}`,
908
- title: `[[${b.target}]]`,
909
- sub: `in ${b.sourcePath}`,
910
- onClick: () => openNote(b.sourcePath),
911
- }))}
912
- />
913
- </section>
914
- )}
915
- {filters.stale && (
916
- <FindingSection
917
- title={`Stale notes (${findings.stale.length})`}
918
- hint={`Untouched for over ${findings.staleDays} days.`}
919
- color={FINDING_COLORS.stale}
920
- nodes={findings.stale}
921
- subOf={(n) => `${(n as GraphNodePayload & { updated: string }).updated} · ${n.path}`}
922
- selectedId={selectedId}
923
- onSelect={select}
924
- onOpen={openNote}
925
- />
926
- )}
927
- </div>
928
- </div>
929
- </div>
930
- );
931
- }
932
-
933
- function SectionHeading({
934
- title,
935
- hint,
936
- color,
937
- }: {
938
- title: string;
939
- hint: string;
940
- color: string;
941
- }) {
942
- return (
943
- <>
944
- <h2 className="flex items-center gap-2 text-sm font-medium text-foreground">
945
- <span
946
- className="h-2.5 w-2.5 rounded-full"
947
- style={{ backgroundColor: color }}
948
- />
949
- {title}
950
- </h2>
951
- <p className="mb-2 text-[11px] text-muted-foreground">{hint}</p>
952
- </>
953
- );
954
- }
955
-
956
- function FindingSection({
957
- title,
958
- hint,
959
- color,
960
- nodes,
961
- subOf,
962
- selectedId,
963
- onSelect,
964
- onOpen,
965
- }: {
966
- title: string;
967
- hint: string;
968
- color: string;
969
- nodes: GraphNodePayload[];
970
- subOf?: (node: GraphNodePayload) => string;
971
- selectedId: number | null;
972
- onSelect: (id: number) => void;
973
- onOpen: (path: string) => void;
974
- }) {
975
- return (
976
- <section>
977
- <SectionHeading title={title} hint={hint} color={color} />
978
- <FindingRows
979
- empty={nodes.length === 0}
980
- rows={nodes.map((node) => ({
981
- key: String(node.id),
982
- nodeId: node.id,
983
- title: node.title || node.path,
984
- sub: subOf ? subOf(node) : node.path,
985
- active: node.id === selectedId,
986
- onClick: () => onSelect(node.id),
987
- onDoubleClick: () => onOpen(node.path),
988
- }))}
989
- />
990
- </section>
991
- );
992
- }
993
-
994
- function FindingRows({
995
- rows,
996
- empty,
997
- }: {
998
- empty: boolean;
999
- rows: {
1000
- key: string;
1001
- nodeId?: number;
1002
- title: string;
1003
- sub: string;
1004
- active?: boolean;
1005
- onClick: () => void;
1006
- onDoubleClick?: () => void;
1007
- }[];
1008
- }) {
1009
- if (empty) {
1010
- return (
1011
- <p className="rounded-lg border border-border-subtle bg-surface px-3 py-2 text-xs text-muted-foreground">
1012
- Nothing found — clean.
1013
- </p>
1014
- );
1015
- }
1016
- return (
1017
- <div className="divide-y divide-border/40 overflow-hidden rounded-lg border border-border bg-surface">
1018
- {rows.map((row) => (
1019
- <button
1020
- key={row.key}
1021
- data-node-id={row.nodeId}
1022
- onClick={row.onClick}
1023
- onDoubleClick={row.onDoubleClick}
1024
- className={cn(
1025
- "flex min-h-11 w-full flex-col justify-center px-3 py-2 text-left transition-colors",
1026
- row.active ? "bg-surface-raised" : "hover:bg-surface-raised/60"
1027
- )}
1028
- >
1029
- <span className="truncate text-xs text-foreground">{row.title}</span>
1030
- <span className="truncate font-mono text-[10px] text-muted-foreground/60">
1031
- {row.sub}
1032
- </span>
1033
- </button>
1034
- ))}
1035
- </div>
1036
- );
1037
- }
1038
-
1039
- function CenteredSpinner() {
1040
- return (
1041
- <div className="flex h-full items-center justify-center">
1042
- <Loader2 className="h-6 w-6 animate-spin text-primary" />
1043
- </div>
1044
- );
1045
- }