@schlessera/brain-ui-react 0.34.0 → 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,15 +1,28 @@
1
1
  import { Brain, WifiOff, ShieldAlert } from "lucide-react";
2
2
  import { motion, AnimatePresence } from "framer-motion";
3
3
  import { useConnectionStore } from "../../stores/connection-store.js";
4
- import { useEffect, useState, type ReactNode } from "react";
4
+ import { useEffect, useRef, useState, type ReactNode } from "react";
5
5
  import { LoginScreen } from "./login-screen.js";
6
6
  import { useVpnStatus } from "../../hooks/use-vpn-status.js";
7
+ import { reconnectWebSocketNow } from "../../hooks/use-websocket.js";
8
+ import {
9
+ deriveConnectionIssue,
10
+ type ConnectionIssue,
11
+ } from "./connection-state.js";
7
12
 
8
13
  export function ConnectionGate({ children }: { children: ReactNode }) {
9
14
  // The gate owns its connectivity probe — composing <ConnectionGate> is all
10
15
  // an embedder needs; the store would otherwise sit on "checking" forever.
11
16
  useVpnStatus();
12
17
  const vpnStatus = useConnectionStore((s) => s.vpnStatus);
18
+ const handshakeFailures = useConnectionStore((s) => s.handshakeFailures);
19
+ const lastCloseCode = useConnectionStore((s) => s.lastCloseCode);
20
+ const reportError = useConnectionStore((s) => s.reportError);
21
+ const issue = deriveConnectionIssue({
22
+ vpnStatus,
23
+ handshakeFailures,
24
+ lastCloseCode,
25
+ });
13
26
 
14
27
  // Once the app has connected, never unmount the UI again — an intermittent
15
28
  // drop must not destroy rendered chat state. Show a banner instead.
@@ -18,9 +31,31 @@ export function ConnectionGate({ children }: { children: ReactNode }) {
18
31
  if (vpnStatus === "connected") setEverConnected(true);
19
32
  }, [vpnStatus]);
20
33
 
34
+ const refused = issue === "refused" || issue === "capacity";
35
+ const wasRefused = useRef(false);
36
+ useEffect(() => {
37
+ if (!refused) {
38
+ wasRefused.current = false;
39
+ return;
40
+ }
41
+ if (wasRefused.current) return;
42
+ wasRefused.current = true;
43
+ if (issue === "capacity") {
44
+ reportError(
45
+ "WEBSOCKET_CAPACITY",
46
+ "The server refused the WebSocket because its connection limit was reached (close code 4008)."
47
+ );
48
+ } else {
49
+ reportError(
50
+ "WEBSOCKET_REFUSED",
51
+ `The server is reachable and the session is valid, but it refused the WebSocket connection (close code ${lastCloseCode ?? 1006}).`
52
+ );
53
+ }
54
+ }, [issue, refused, lastCloseCode, reportError]);
55
+
21
56
  // Auth required (password mode) and no valid session: show the login screen,
22
57
  // even if we were connected before (an expired session must re-prompt).
23
- if (vpnStatus === "unauthorized") {
58
+ if (issue === "unauthorized") {
24
59
  return <LoginScreen />;
25
60
  }
26
61
 
@@ -28,8 +63,8 @@ export function ConnectionGate({ children }: { children: ReactNode }) {
28
63
  return (
29
64
  <>
30
65
  <OfflineBanner
31
- show={everConnected && vpnStatus !== "connected"}
32
- forbidden={vpnStatus === "forbidden"}
66
+ show={refused || (everConnected && vpnStatus !== "connected")}
67
+ issue={issue}
33
68
  />
34
69
  {children}
35
70
  </>
@@ -96,11 +131,22 @@ export function ConnectionGate({ children }: { children: ReactNode }) {
96
131
  */
97
132
  function OfflineBanner({
98
133
  show,
99
- forbidden,
134
+ issue,
100
135
  }: {
101
136
  show: boolean;
102
- forbidden: boolean;
137
+ issue: ConnectionIssue;
103
138
  }) {
139
+ const message =
140
+ issue === "forbidden"
141
+ ? "VPN required — reconnect Tailscale"
142
+ : issue === "unreachable"
143
+ ? "Connection lost — reconnecting…"
144
+ : issue === "capacity"
145
+ ? "Server connection limit reached"
146
+ : issue === "refused"
147
+ ? "Server refused the live connection"
148
+ : "Connection lost — reconnecting…";
149
+
104
150
  return (
105
151
  <AnimatePresence>
106
152
  {show && (
@@ -113,18 +159,23 @@ function OfflineBanner({
113
159
  >
114
160
  <div
115
161
  role="status"
116
- className="flex items-center gap-2 rounded-full border border-amber-500/30 bg-amber-950/90 px-4 py-1.5 text-xs text-amber-200 shadow-lg backdrop-blur"
162
+ className="pointer-events-auto flex items-center gap-2 rounded-full border border-amber-500/30 bg-amber-950/90 px-4 py-1.5 text-xs text-amber-200 shadow-lg backdrop-blur"
117
163
  >
118
- {forbidden ? (
164
+ {issue === "forbidden" ? (
119
165
  <ShieldAlert className="h-3.5 w-3.5" />
120
166
  ) : (
121
167
  <WifiOff className="h-3.5 w-3.5" />
122
168
  )}
123
- <span>
124
- {forbidden
125
- ? "VPN required — reconnect Tailscale"
126
- : "Connection lost — reconnecting…"}
127
- </span>
169
+ <span>{message}</span>
170
+ {(issue === "refused" || issue === "capacity") && (
171
+ <button
172
+ type="button"
173
+ onClick={reconnectWebSocketNow}
174
+ className="font-medium underline underline-offset-2 hover:text-amber-100"
175
+ >
176
+ Retry now
177
+ </button>
178
+ )}
128
179
  <span
129
180
  className="inline-block h-1.5 w-1.5 rounded-full bg-amber-400"
130
181
  style={{ animation: "breathe 2s ease-in-out infinite" }}
@@ -0,0 +1,37 @@
1
+ import type { VpnStatus } from "../../stores/connection-store.js";
2
+
3
+ // Three failed handshakes classify after the existing 1s + 2s backoffs: soon
4
+ // enough to diagnose a refusal, without flashing on one ordinary reconnect.
5
+ export const REFUSAL_ATTEMPTS = 3;
6
+
7
+ /** ws/connection.ts closes with this when ClientSet.add refuses the socket. */
8
+ export const CONNECTION_LIMIT_CLOSE_CODE = 4008;
9
+
10
+ export type ConnectionIssue =
11
+ | "unauthorized"
12
+ | "forbidden"
13
+ | "unreachable"
14
+ | "refused"
15
+ | "capacity"
16
+ | null;
17
+
18
+ /** Derive current connectivity copy from live facts; no conclusion is cached. */
19
+ export function deriveConnectionIssue({
20
+ vpnStatus,
21
+ handshakeFailures,
22
+ lastCloseCode,
23
+ }: {
24
+ vpnStatus: VpnStatus;
25
+ handshakeFailures: number;
26
+ lastCloseCode: number | null;
27
+ }): ConnectionIssue {
28
+ if (vpnStatus === "unauthorized") return "unauthorized";
29
+ if (vpnStatus === "forbidden") return "forbidden";
30
+ if (vpnStatus === "unreachable") return "unreachable";
31
+ if (vpnStatus !== "connected") return null;
32
+ // The cap refusal is explicit, so it does not wait for the inferred
33
+ // threshold: the server accepts the upgrade and closes with 4008, which
34
+ // resets the never-opened counter that "refused" is inferred from.
35
+ if (lastCloseCode === CONNECTION_LIMIT_CLOSE_CODE) return "capacity";
36
+ return handshakeFailures >= REFUSAL_ATTEMPTS ? "refused" : null;
37
+ }
@@ -0,0 +1,4 @@
1
+ import { lazy } from "react";
2
+
3
+ // The one code-split boundary: sigma + graphology load only when a scene renders.
4
+ export const GraphCanvas = lazy(() => import("./graph-canvas.js"));
@@ -384,7 +384,10 @@ function NotePicker({
384
384
  if (!controller.signal.aborted) setLoading(false);
385
385
  }
386
386
  }, 250);
387
- return () => clearTimeout(t);
387
+ return () => {
388
+ clearTimeout(t);
389
+ controllerRef.current?.abort();
390
+ };
388
391
  }, [editing, trimmed]);
389
392
 
390
393
  return (
@@ -0,0 +1,304 @@
1
+ import { Suspense, useEffect, useMemo, useRef } from "react";
2
+ import type {
3
+ GraphNodePayload,
4
+ GraphSubgraphResponse,
5
+ } from "@schlessera/brain-ui-sdk/protocol";
6
+
7
+ import { useGraphStore } from "../../stores/graph-store.js";
8
+ import { useUIStore } from "../../stores/ui-store.js";
9
+ import { useFileStore } from "../../stores/file-store.js";
10
+ import { GraphControls } from "./graph-controls.js";
11
+ import { NodePopover } from "./node-popover.js";
12
+ import { GraphEmptyState } from "./graph-empty-state.js";
13
+ import { GraphCanvas } from "./graph-canvas-lazy.js";
14
+ import { CenteredSpinner } from "./graph-spinner.js";
15
+ import { matchScene } from "./lib/graph-helpers.js";
16
+ import { cn } from "../../lib/utils.js";
17
+
18
+ /** Finding-type colors: dataviz status palette (warning/serious/critical) plus
19
+ * the light end of the distance ramp for "stale" — checked against #0c0e12. */
20
+ const FINDING_COLORS = {
21
+ orphan: "#fab219",
22
+ unreachable: "#ec835a",
23
+ broken: "#d03b3b",
24
+ stale: "#86b6ef",
25
+ } as const;
26
+
27
+ // --- Maintenance -------------------------------------------------------------
28
+
29
+ type FindingKind = "orphan" | "unreachable" | "stale";
30
+
31
+ interface FindingNode extends GraphNodePayload {
32
+ finding: FindingKind;
33
+ }
34
+
35
+ /**
36
+ * Maintenance: findings list ⇄ canvas, linked both ways. The canvas is a
37
+ * type-grouped radial scene (one ring per finding kind — these nodes have no
38
+ * edges between them by definition); broken links are list-only, since their
39
+ * defining feature is a target that does not exist.
40
+ */
41
+ export function MaintenanceBody() {
42
+ const findings = useGraphStore((s) => s.findings);
43
+ const dataState = useGraphStore((s) => s.dataState);
44
+ const error = useGraphStore((s) => s.error);
45
+ const filters = useGraphStore((s) => s.maintenanceFilters);
46
+ const selectedId = useGraphStore((s) => s.selectedId);
47
+ const select = useGraphStore((s) => s.select);
48
+ const hoveredId = useGraphStore((s) => s.hoveredId);
49
+ const hover = useGraphStore((s) => s.hover);
50
+ const sceneQuery = useGraphStore((s) => s.sceneQuery);
51
+ const openFile = useFileStore((s) => s.openFile);
52
+ const setFilePanelOpen = useUIStore((s) => s.setFilePanelOpen);
53
+ const listRef = useRef<HTMLDivElement>(null);
54
+
55
+ // One node per document, first matching section wins (a stale orphan is an
56
+ // orphan); ring index encodes the finding kind.
57
+ const scene = useMemo(() => {
58
+ if (!findings) return null;
59
+ const seen = new Set<number>();
60
+ const nodes: FindingNode[] = [];
61
+ const push = (list: GraphNodePayload[], finding: FindingKind, ring: number) => {
62
+ for (const node of list) {
63
+ if (seen.has(node.id)) continue;
64
+ seen.add(node.id);
65
+ nodes.push({ ...node, finding, distance: ring });
66
+ }
67
+ };
68
+ if (filters.orphans) push(findings.orphans, "orphan", 1);
69
+ if (filters.unreachable) push(findings.unreachable, "unreachable", 2);
70
+ if (filters.stale) push(findings.stale, "stale", 3);
71
+ const subgraph: GraphSubgraphResponse = { nodes, edges: [], truncated: false };
72
+ return { subgraph, nodes };
73
+ }, [findings, filters]);
74
+
75
+ // Canvas → list: keep the selected row in view.
76
+ useEffect(() => {
77
+ if (selectedId === null) return;
78
+ listRef.current
79
+ ?.querySelector(`[data-node-id="${selectedId}"]`)
80
+ ?.scrollIntoView({ block: "nearest" });
81
+ }, [selectedId]);
82
+
83
+ if (dataState === "error") {
84
+ return (
85
+ <GraphEmptyState icon="warn" title="Could not load findings">
86
+ {error?.message ?? "Request failed"}
87
+ </GraphEmptyState>
88
+ );
89
+ }
90
+ if (dataState !== "done" || !findings || !scene) return <CenteredSpinner />;
91
+
92
+ const matchIds = matchScene(scene.nodes, sceneQuery);
93
+ const selectedNode =
94
+ selectedId === null
95
+ ? null
96
+ : (scene.nodes.find((n) => n.id === selectedId) ?? null);
97
+
98
+ function openNote(path: string) {
99
+ setFilePanelOpen(true);
100
+ void openFile(path);
101
+ }
102
+
103
+ const totalShown =
104
+ scene.nodes.length + (filters.broken ? findings.brokenLinks.length : 0);
105
+
106
+ return (
107
+ <div className="flex h-full flex-col md:flex-row">
108
+ {/* Canvas pane */}
109
+ <div className="relative h-56 shrink-0 border-b border-border md:h-auto md:flex-1 md:border-b-0 md:border-r">
110
+ {scene.nodes.length === 0 ? (
111
+ <div className="flex h-full items-center justify-center px-6 text-center text-xs text-muted-foreground">
112
+ {totalShown === 0
113
+ ? "Nothing to show — this brain is clean."
114
+ : "Only broken links match — they live in the list."}
115
+ </div>
116
+ ) : (
117
+ <Suspense fallback={<CenteredSpinner />}>
118
+ <GraphCanvas
119
+ data={scene.subgraph}
120
+ layout="radial"
121
+ selectedId={selectedId}
122
+ hoveredId={hoveredId}
123
+ matchIds={matchIds}
124
+ nodeColor={(node) =>
125
+ FINDING_COLORS[(node as FindingNode).finding ?? "stale"]
126
+ }
127
+ onSelect={select}
128
+ onHover={hover}
129
+ />
130
+ </Suspense>
131
+ )}
132
+ <GraphControls />
133
+ {selectedNode && <NodePopover node={selectedNode} />}
134
+ </div>
135
+
136
+ {/* Findings list pane */}
137
+ <div
138
+ ref={listRef}
139
+ className="flex-1 overflow-y-auto px-4 py-4 md:w-96 md:flex-none md:px-5"
140
+ >
141
+ <div className="flex flex-col gap-5 pb-16">
142
+ {filters.orphans && (
143
+ <FindingSection
144
+ title={`Orphans (${findings.orphans.length})`}
145
+ hint="No links in or out."
146
+ color={FINDING_COLORS.orphan}
147
+ nodes={findings.orphans}
148
+ selectedId={selectedId}
149
+ onSelect={select}
150
+ onOpen={openNote}
151
+ />
152
+ )}
153
+ {filters.unreachable && (
154
+ <FindingSection
155
+ title={`Unreachable from root (${findings.unreachable.length})`}
156
+ hint="No link path from the graph root reaches these."
157
+ color={FINDING_COLORS.unreachable}
158
+ nodes={findings.unreachable}
159
+ selectedId={selectedId}
160
+ onSelect={select}
161
+ onOpen={openNote}
162
+ />
163
+ )}
164
+ {filters.broken && (
165
+ <section>
166
+ <SectionHeading
167
+ title={`Broken links (${findings.brokenLinks.length})`}
168
+ hint="Wiki links whose target resolves to nothing."
169
+ color={FINDING_COLORS.broken}
170
+ />
171
+ <FindingRows
172
+ empty={findings.brokenLinks.length === 0}
173
+ rows={findings.brokenLinks.map((b, i) => ({
174
+ key: `b${i}`,
175
+ title: `[[${b.target}]]`,
176
+ sub: `in ${b.sourcePath}`,
177
+ onClick: () => openNote(b.sourcePath),
178
+ }))}
179
+ />
180
+ </section>
181
+ )}
182
+ {filters.stale && (
183
+ <FindingSection
184
+ title={`Stale notes (${findings.stale.length})`}
185
+ hint={`Untouched for over ${findings.staleDays} days.`}
186
+ color={FINDING_COLORS.stale}
187
+ nodes={findings.stale}
188
+ subOf={(n) => `${(n as GraphNodePayload & { updated: string }).updated} · ${n.path}`}
189
+ selectedId={selectedId}
190
+ onSelect={select}
191
+ onOpen={openNote}
192
+ />
193
+ )}
194
+ </div>
195
+ </div>
196
+ </div>
197
+ );
198
+ }
199
+
200
+ function SectionHeading({
201
+ title,
202
+ hint,
203
+ color,
204
+ }: {
205
+ title: string;
206
+ hint: string;
207
+ color: string;
208
+ }) {
209
+ return (
210
+ <>
211
+ <h2 className="flex items-center gap-2 text-sm font-medium text-foreground">
212
+ <span
213
+ className="h-2.5 w-2.5 rounded-full"
214
+ style={{ backgroundColor: color }}
215
+ />
216
+ {title}
217
+ </h2>
218
+ <p className="mb-2 text-[11px] text-muted-foreground">{hint}</p>
219
+ </>
220
+ );
221
+ }
222
+
223
+ function FindingSection({
224
+ title,
225
+ hint,
226
+ color,
227
+ nodes,
228
+ subOf,
229
+ selectedId,
230
+ onSelect,
231
+ onOpen,
232
+ }: {
233
+ title: string;
234
+ hint: string;
235
+ color: string;
236
+ nodes: GraphNodePayload[];
237
+ subOf?: (node: GraphNodePayload) => string;
238
+ selectedId: number | null;
239
+ onSelect: (id: number) => void;
240
+ onOpen: (path: string) => void;
241
+ }) {
242
+ return (
243
+ <section>
244
+ <SectionHeading title={title} hint={hint} color={color} />
245
+ <FindingRows
246
+ empty={nodes.length === 0}
247
+ rows={nodes.map((node) => ({
248
+ key: String(node.id),
249
+ nodeId: node.id,
250
+ title: node.title || node.path,
251
+ sub: subOf ? subOf(node) : node.path,
252
+ active: node.id === selectedId,
253
+ onClick: () => onSelect(node.id),
254
+ onDoubleClick: () => onOpen(node.path),
255
+ }))}
256
+ />
257
+ </section>
258
+ );
259
+ }
260
+
261
+ function FindingRows({
262
+ rows,
263
+ empty,
264
+ }: {
265
+ empty: boolean;
266
+ rows: {
267
+ key: string;
268
+ nodeId?: number;
269
+ title: string;
270
+ sub: string;
271
+ active?: boolean;
272
+ onClick: () => void;
273
+ onDoubleClick?: () => void;
274
+ }[];
275
+ }) {
276
+ if (empty) {
277
+ return (
278
+ <p className="rounded-lg border border-border-subtle bg-surface px-3 py-2 text-xs text-muted-foreground">
279
+ Nothing found — clean.
280
+ </p>
281
+ );
282
+ }
283
+ return (
284
+ <div className="divide-y divide-border/40 overflow-hidden rounded-lg border border-border bg-surface">
285
+ {rows.map((row) => (
286
+ <button
287
+ key={row.key}
288
+ data-node-id={row.nodeId}
289
+ onClick={row.onClick}
290
+ onDoubleClick={row.onDoubleClick}
291
+ className={cn(
292
+ "flex min-h-11 w-full flex-col justify-center px-3 py-2 text-left transition-colors",
293
+ row.active ? "bg-surface-raised" : "hover:bg-surface-raised/60"
294
+ )}
295
+ >
296
+ <span className="truncate text-xs text-foreground">{row.title}</span>
297
+ <span className="truncate font-mono text-[10px] text-muted-foreground/60">
298
+ {row.sub}
299
+ </span>
300
+ </button>
301
+ ))}
302
+ </div>
303
+ );
304
+ }