@schlessera/brain-ui-react 0.34.0 → 0.35.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (223) hide show
  1. package/README.md +12 -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/session-drawer.d.ts.map +1 -1
  41. package/dist/components/chat/session-drawer.js +25 -10
  42. package/dist/components/chat/session-drawer.js.map +1 -1
  43. package/dist/components/chat/tool-views.d.ts +2 -2
  44. package/dist/components/chat/tool-views.d.ts.map +1 -1
  45. package/dist/components/chat/tool-views.js +2 -16
  46. package/dist/components/chat/tool-views.js.map +1 -1
  47. package/dist/components/chat/use-markdown-highlight.d.ts +12 -0
  48. package/dist/components/chat/use-markdown-highlight.d.ts.map +1 -0
  49. package/dist/components/chat/use-markdown-highlight.js +56 -0
  50. package/dist/components/chat/use-markdown-highlight.js.map +1 -0
  51. package/dist/components/connectivity/connection-gate.d.ts.map +1 -1
  52. package/dist/components/connectivity/connection-gate.js +94 -8
  53. package/dist/components/connectivity/connection-gate.js.map +1 -1
  54. package/dist/components/connectivity/connection-state.d.ts +12 -0
  55. package/dist/components/connectivity/connection-state.d.ts.map +1 -0
  56. package/dist/components/connectivity/connection-state.js +23 -0
  57. package/dist/components/connectivity/connection-state.js.map +1 -0
  58. package/dist/components/graph/graph-canvas-lazy.d.ts +2 -0
  59. package/dist/components/graph/graph-canvas-lazy.d.ts.map +1 -0
  60. package/dist/components/graph/graph-canvas-lazy.js +4 -0
  61. package/dist/components/graph/graph-canvas-lazy.js.map +1 -0
  62. package/dist/components/graph/graph-controls.js +4 -1
  63. package/dist/components/graph/graph-controls.js.map +1 -1
  64. package/dist/components/graph/graph-maintenance.d.ts +8 -0
  65. package/dist/components/graph/graph-maintenance.d.ts.map +1 -0
  66. package/dist/components/graph/graph-maintenance.js +115 -0
  67. package/dist/components/graph/graph-maintenance.js.map +1 -0
  68. package/dist/components/graph/graph-page.d.ts.map +1 -1
  69. package/dist/components/graph/graph-page.js +11 -306
  70. package/dist/components/graph/graph-page.js.map +1 -1
  71. package/dist/components/graph/graph-scene.d.ts +8 -0
  72. package/dist/components/graph/graph-scene.d.ts.map +1 -0
  73. package/dist/components/graph/graph-scene.js +208 -0
  74. package/dist/components/graph/graph-scene.js.map +1 -0
  75. package/dist/components/graph/graph-spinner.d.ts +2 -0
  76. package/dist/components/graph/graph-spinner.d.ts.map +1 -0
  77. package/dist/components/graph/graph-spinner.js +6 -0
  78. package/dist/components/graph/graph-spinner.js.map +1 -0
  79. package/dist/components/layout/app-shell.d.ts.map +1 -1
  80. package/dist/components/layout/app-shell.js +2 -1
  81. package/dist/components/layout/app-shell.js.map +1 -1
  82. package/dist/components/quick-actions/add-modal.d.ts.map +1 -1
  83. package/dist/components/quick-actions/add-modal.js +47 -4
  84. package/dist/components/quick-actions/add-modal.js.map +1 -1
  85. package/dist/components/quick-actions/search-modal.d.ts.map +1 -1
  86. package/dist/components/quick-actions/search-modal.js +46 -18
  87. package/dist/components/quick-actions/search-modal.js.map +1 -1
  88. package/dist/components/quick-actions/streaming-modal.d.ts.map +1 -1
  89. package/dist/components/quick-actions/streaming-modal.js +4 -1
  90. package/dist/components/quick-actions/streaming-modal.js.map +1 -1
  91. package/dist/components/settings/devices-agents-tab.d.ts +5 -0
  92. package/dist/components/settings/devices-agents-tab.d.ts.map +1 -0
  93. package/dist/components/settings/devices-agents-tab.js +96 -0
  94. package/dist/components/settings/devices-agents-tab.js.map +1 -0
  95. package/dist/components/settings/one-time-agent-credential.d.ts +3 -0
  96. package/dist/components/settings/one-time-agent-credential.d.ts.map +1 -0
  97. package/dist/components/settings/one-time-agent-credential.js +55 -0
  98. package/dist/components/settings/one-time-agent-credential.js.map +1 -0
  99. package/dist/components/settings/settings-panel.d.ts.map +1 -1
  100. package/dist/components/settings/settings-panel.js +16 -3
  101. package/dist/components/settings/settings-panel.js.map +1 -1
  102. package/dist/hooks/use-vpn-status.d.ts +6 -1
  103. package/dist/hooks/use-vpn-status.d.ts.map +1 -1
  104. package/dist/hooks/use-vpn-status.js +67 -30
  105. package/dist/hooks/use-vpn-status.js.map +1 -1
  106. package/dist/hooks/use-websocket.d.ts +3 -2
  107. package/dist/hooks/use-websocket.d.ts.map +1 -1
  108. package/dist/hooks/use-websocket.js +65 -254
  109. package/dist/hooks/use-websocket.js.map +1 -1
  110. package/dist/hooks/websocket-handlers/activity.d.ts +5 -0
  111. package/dist/hooks/websocket-handlers/activity.d.ts.map +1 -0
  112. package/dist/hooks/websocket-handlers/activity.js +10 -0
  113. package/dist/hooks/websocket-handlers/activity.js.map +1 -0
  114. package/dist/hooks/websocket-handlers/chat.d.ts +17 -0
  115. package/dist/hooks/websocket-handlers/chat.d.ts.map +1 -0
  116. package/dist/hooks/websocket-handlers/chat.js +175 -0
  117. package/dist/hooks/websocket-handlers/chat.js.map +1 -0
  118. package/dist/hooks/websocket-handlers/connection.d.ts +6 -0
  119. package/dist/hooks/websocket-handlers/connection.d.ts.map +1 -0
  120. package/dist/hooks/websocket-handlers/connection.js +27 -0
  121. package/dist/hooks/websocket-handlers/connection.js.map +1 -0
  122. package/dist/hooks/websocket-handlers/index.d.ts +4 -0
  123. package/dist/hooks/websocket-handlers/index.d.ts.map +1 -0
  124. package/dist/hooks/websocket-handlers/index.js +19 -0
  125. package/dist/hooks/websocket-handlers/index.js.map +1 -0
  126. package/dist/hooks/websocket-handlers/mask.d.ts +4 -0
  127. package/dist/hooks/websocket-handlers/mask.d.ts.map +1 -0
  128. package/dist/hooks/websocket-handlers/mask.js +14 -0
  129. package/dist/hooks/websocket-handlers/mask.js.map +1 -0
  130. package/dist/hooks/websocket-handlers/provider.d.ts +4 -0
  131. package/dist/hooks/websocket-handlers/provider.d.ts.map +1 -0
  132. package/dist/hooks/websocket-handlers/provider.js +31 -0
  133. package/dist/hooks/websocket-handlers/provider.js.map +1 -0
  134. package/dist/hooks/websocket-handlers/types.d.ts +24 -0
  135. package/dist/hooks/websocket-handlers/types.d.ts.map +1 -0
  136. package/dist/hooks/websocket-handlers/types.js +2 -0
  137. package/dist/hooks/websocket-handlers/types.js.map +1 -0
  138. package/dist/lib/api-client.d.ts +32 -0
  139. package/dist/lib/api-client.d.ts.map +1 -1
  140. package/dist/lib/api-client.js +11 -0
  141. package/dist/lib/api-client.js.map +1 -1
  142. package/dist/lib/format-time.d.ts +3 -0
  143. package/dist/lib/format-time.d.ts.map +1 -0
  144. package/dist/lib/format-time.js +25 -0
  145. package/dist/lib/format-time.js.map +1 -0
  146. package/dist/lib/push-registration.d.ts +7 -0
  147. package/dist/lib/push-registration.d.ts.map +1 -0
  148. package/dist/lib/push-registration.js +26 -0
  149. package/dist/lib/push-registration.js.map +1 -0
  150. package/dist/stores/activity-store-helpers.d.ts +50 -0
  151. package/dist/stores/activity-store-helpers.d.ts.map +1 -0
  152. package/dist/stores/activity-store-helpers.js +143 -0
  153. package/dist/stores/activity-store-helpers.js.map +1 -0
  154. package/dist/stores/activity-store-types.d.ts +48 -0
  155. package/dist/stores/activity-store-types.d.ts.map +1 -0
  156. package/dist/stores/activity-store-types.js +2 -0
  157. package/dist/stores/activity-store-types.js.map +1 -0
  158. package/dist/stores/activity-store.d.ts +2 -78
  159. package/dist/stores/activity-store.d.ts.map +1 -1
  160. package/dist/stores/activity-store.js +22 -142
  161. package/dist/stores/activity-store.js.map +1 -1
  162. package/dist/stores/connection-store.d.ts +14 -2
  163. package/dist/stores/connection-store.d.ts.map +1 -1
  164. package/dist/stores/connection-store.js +17 -0
  165. package/dist/stores/connection-store.js.map +1 -1
  166. package/dist/stores/file-store.d.ts.map +1 -1
  167. package/dist/stores/file-store.js +54 -18
  168. package/dist/stores/file-store.js.map +1 -1
  169. package/dist/stores/principal-store.d.ts +16 -0
  170. package/dist/stores/principal-store.d.ts.map +1 -0
  171. package/dist/stores/principal-store.js +29 -0
  172. package/dist/stores/principal-store.js.map +1 -0
  173. package/dist/stores/ui-store.d.ts +1 -1
  174. package/dist/stores/ui-store.d.ts.map +1 -1
  175. package/dist/styles.css +1 -1
  176. package/package.json +2 -2
  177. package/src/components/activity/activity-page.tsx +6 -477
  178. package/src/components/activity/activity-rollups.tsx +94 -0
  179. package/src/components/activity/activity-run-detail.tsx +301 -0
  180. package/src/components/activity/activity-run-list.tsx +97 -0
  181. package/src/components/chat/brain-markdown-code.tsx +60 -0
  182. package/src/components/chat/brain-markdown-entities.tsx +116 -0
  183. package/src/components/chat/brain-markdown-links.tsx +247 -0
  184. package/src/components/chat/brain-markdown-share.ts +47 -0
  185. package/src/components/chat/brain-markdown.tsx +27 -535
  186. package/src/components/chat/composer.tsx +14 -1
  187. package/src/components/chat/session-drawer.tsx +29 -10
  188. package/src/components/chat/tool-views.tsx +2 -13
  189. package/src/components/chat/use-markdown-highlight.ts +63 -0
  190. package/src/components/connectivity/connection-gate.tsx +128 -14
  191. package/src/components/connectivity/connection-state.ts +37 -0
  192. package/src/components/graph/graph-canvas-lazy.tsx +4 -0
  193. package/src/components/graph/graph-controls.tsx +4 -1
  194. package/src/components/graph/graph-maintenance.tsx +304 -0
  195. package/src/components/graph/graph-page.tsx +11 -840
  196. package/src/components/graph/graph-scene.tsx +548 -0
  197. package/src/components/graph/graph-spinner.tsx +9 -0
  198. package/src/components/layout/app-shell.tsx +2 -0
  199. package/src/components/quick-actions/add-modal.tsx +49 -4
  200. package/src/components/quick-actions/search-modal.tsx +46 -18
  201. package/src/components/quick-actions/streaming-modal.tsx +3 -1
  202. package/src/components/settings/devices-agents-tab.tsx +231 -0
  203. package/src/components/settings/one-time-agent-credential.tsx +131 -0
  204. package/src/components/settings/settings-panel.tsx +20 -5
  205. package/src/hooks/use-vpn-status.ts +71 -31
  206. package/src/hooks/use-websocket.ts +74 -275
  207. package/src/hooks/websocket-handlers/activity.ts +13 -0
  208. package/src/hooks/websocket-handlers/chat.ts +210 -0
  209. package/src/hooks/websocket-handlers/connection.ts +30 -0
  210. package/src/hooks/websocket-handlers/index.ts +59 -0
  211. package/src/hooks/websocket-handlers/mask.ts +17 -0
  212. package/src/hooks/websocket-handlers/provider.ts +35 -0
  213. package/src/hooks/websocket-handlers/types.ts +27 -0
  214. package/src/lib/api-client.ts +40 -1
  215. package/src/lib/format-time.ts +20 -0
  216. package/src/lib/push-registration.ts +29 -0
  217. package/src/stores/activity-store-helpers.ts +164 -0
  218. package/src/stores/activity-store-types.ts +55 -0
  219. package/src/stores/activity-store.ts +21 -217
  220. package/src/stores/connection-store.ts +33 -2
  221. package/src/stores/file-store.ts +42 -12
  222. package/src/stores/principal-store.ts +38 -0
  223. package/src/stores/ui-store.ts +1 -1
@@ -22,9 +22,11 @@ import { MarkdownContent } from "./markdown-content.js";
22
22
  import { isInternalRepoPath } from "../../stores/file-store.js";
23
23
  import { GET_LOCATION_TOOL_NAME, normalizeToolName } from "../../lib/tool-names.js";
24
24
  import { computeDiffRows, type WordToken } from "../../lib/diff.js";
25
+ import { formatRelativeTime } from "../../lib/format-time.js";
25
26
 
26
27
  // Re-exported so existing imports of the diff engine from this module keep working.
27
28
  export { computeDiffRows };
29
+ export { formatRelativeTime };
28
30
 
29
31
  // ============================================================
30
32
  // Shared helpers
@@ -84,19 +86,6 @@ export function formatTokenCount(n: number): string {
84
86
  return String(n);
85
87
  }
86
88
 
87
- /** Relative timestamp for list rows ("3m ago"); dates beyond a week. */
88
- export function formatRelativeTime(ms: number): string {
89
- const diff = Date.now() - ms;
90
- const minutes = Math.floor(diff / 60000);
91
- if (minutes < 1) return "just now";
92
- if (minutes < 60) return `${minutes}m ago`;
93
- const hours = Math.floor(minutes / 60);
94
- if (hours < 24) return `${hours}h ago`;
95
- const days = Math.floor(hours / 24);
96
- if (days < 7) return `${days}d ago`;
97
- return new Date(ms).toLocaleDateString();
98
- }
99
-
100
89
  export function countLines(text: string): number {
101
90
  let n = 1;
102
91
  for (let i = 0; i < text.length; i++) if (text[i] === "\n") n++;
@@ -0,0 +1,63 @@
1
+ import { useEffect, useSyncExternalStore } from "react";
2
+ import type { Options as MarkdownOptions } from "react-markdown";
3
+ import remarkGfm from "remark-gfm";
4
+
5
+ /**
6
+ * Plugin lists, hoisted out of render.
7
+ *
8
+ * react-markdown builds a fresh unified processor on every render and reruns
9
+ * the whole parse — there is no internal memoization to lean on. Fresh array
10
+ * literals in the JSX made that unavoidable; module constants let the memo
11
+ * below actually hold.
12
+ */
13
+ export const REMARK_PLUGINS = [remarkGfm];
14
+ const NO_REHYPE_PLUGINS: NonNullable<MarkdownOptions["rehypePlugins"]> = [];
15
+
16
+ /**
17
+ * Syntax highlighting arrives after first paint.
18
+ *
19
+ * rehype-highlight pulls in highlight.js, 166 KB of the entry bundle — more
20
+ * than every markdown parser here put together — and nothing on the first paint
21
+ * needs it. It is fetched once, on the first mount of a highlighting renderer;
22
+ * until it lands, code blocks render as plain text and then gain their colours.
23
+ * In practice it is loaded long before the first assistant message arrives.
24
+ *
25
+ * The module-level cache means the fetch happens once per page, not once per
26
+ * message, and useSyncExternalStore is what lets every mounted renderer pick up
27
+ * the plugin the moment it does.
28
+ */
29
+ let highlightPlugins: NonNullable<MarkdownOptions["rehypePlugins"]> | null = null;
30
+ let highlightRequested = false;
31
+ const highlightListeners = new Set<() => void>();
32
+
33
+ function subscribeHighlight(onChange: () => void): () => void {
34
+ highlightListeners.add(onChange);
35
+ return () => {
36
+ highlightListeners.delete(onChange);
37
+ };
38
+ }
39
+
40
+ function readHighlightPlugins(): NonNullable<MarkdownOptions["rehypePlugins"]> {
41
+ return highlightPlugins ?? NO_REHYPE_PLUGINS;
42
+ }
43
+
44
+ export function useRehypePlugins(enabled: boolean) {
45
+ const plugins = useSyncExternalStore(
46
+ subscribeHighlight,
47
+ readHighlightPlugins,
48
+ readHighlightPlugins
49
+ );
50
+ useEffect(() => {
51
+ if (!enabled || highlightRequested) return;
52
+ highlightRequested = true;
53
+ import("rehype-highlight")
54
+ .then((mod) => {
55
+ highlightPlugins = [mod.default];
56
+ for (const listener of highlightListeners) listener();
57
+ })
58
+ .catch(() => {
59
+ // Soft-fail: code blocks stay readable, just uncoloured.
60
+ });
61
+ }, [enabled]);
62
+ return enabled ? plugins : NO_REHYPE_PLUGINS;
63
+ }
@@ -1,15 +1,32 @@
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";
12
+ import { rebindPushSubscriptionAfterLogin } from "../../lib/push-registration.js";
13
+
14
+ const INITIAL_PUSH_REBIND_BACKOFF_MS = 1_000;
15
+ const MAX_PUSH_REBIND_BACKOFF_MS = 60_000;
7
16
 
8
17
  export function ConnectionGate({ children }: { children: ReactNode }) {
9
18
  // The gate owns its connectivity probe — composing <ConnectionGate> is all
10
19
  // an embedder needs; the store would otherwise sit on "checking" forever.
11
- useVpnStatus();
20
+ const successfulProbeCount = useVpnStatus();
12
21
  const vpnStatus = useConnectionStore((s) => s.vpnStatus);
22
+ const handshakeFailures = useConnectionStore((s) => s.handshakeFailures);
23
+ const lastCloseCode = useConnectionStore((s) => s.lastCloseCode);
24
+ const reportError = useConnectionStore((s) => s.reportError);
25
+ const issue = deriveConnectionIssue({
26
+ vpnStatus,
27
+ handshakeFailures,
28
+ lastCloseCode,
29
+ });
13
30
 
14
31
  // Once the app has connected, never unmount the UI again — an intermittent
15
32
  // drop must not destroy rendered chat state. Show a banner instead.
@@ -18,9 +35,90 @@ export function ConnectionGate({ children }: { children: ReactNode }) {
18
35
  if (vpnStatus === "connected") setEverConnected(true);
19
36
  }, [vpnStatus]);
20
37
 
38
+ const pushRebind = useRef({
39
+ pending: true,
40
+ inFlight: false,
41
+ retryReady: true,
42
+ backoffMs: INITIAL_PUSH_REBIND_BACKOFF_MS,
43
+ retryTimer: undefined as ReturnType<typeof setTimeout> | undefined,
44
+ disposed: false,
45
+ });
46
+
47
+ useEffect(() => {
48
+ const state = pushRebind.current;
49
+ state.disposed = false;
50
+ return () => {
51
+ state.disposed = true;
52
+ clearTimeout(state.retryTimer);
53
+ };
54
+ }, []);
55
+
56
+ // A successful authenticated probe follows a login reload (and is also the
57
+ // boot path for ambient auth modes). Re-assert any browser-held subscription
58
+ // so legacy/unbound rows acquire the current principal. A failed request
59
+ // stays pending: later successful probes, including foreground-triggered
60
+ // probes, retry it once the exponential backoff has elapsed.
61
+ useEffect(() => {
62
+ const state = pushRebind.current;
63
+ if (
64
+ successfulProbeCount === 0 ||
65
+ vpnStatus !== "connected" ||
66
+ !state.pending ||
67
+ state.inFlight ||
68
+ !state.retryReady
69
+ ) {
70
+ return;
71
+ }
72
+
73
+ state.inFlight = true;
74
+ void rebindPushSubscriptionAfterLogin().then(
75
+ () => {
76
+ if (state.disposed) return;
77
+ state.inFlight = false;
78
+ state.pending = false;
79
+ },
80
+ () => {
81
+ if (state.disposed) return;
82
+ state.inFlight = false;
83
+ state.retryReady = false;
84
+ const delay = state.backoffMs;
85
+ state.backoffMs = Math.min(
86
+ state.backoffMs * 2,
87
+ MAX_PUSH_REBIND_BACKOFF_MS
88
+ );
89
+ state.retryTimer = setTimeout(() => {
90
+ state.retryReady = true;
91
+ state.retryTimer = undefined;
92
+ }, delay);
93
+ }
94
+ );
95
+ }, [successfulProbeCount, vpnStatus]);
96
+
97
+ const refused = issue === "refused" || issue === "capacity";
98
+ const wasRefused = useRef(false);
99
+ useEffect(() => {
100
+ if (!refused) {
101
+ wasRefused.current = false;
102
+ return;
103
+ }
104
+ if (wasRefused.current) return;
105
+ wasRefused.current = true;
106
+ if (issue === "capacity") {
107
+ reportError(
108
+ "WEBSOCKET_CAPACITY",
109
+ "The server refused the WebSocket because its connection limit was reached (close code 4008)."
110
+ );
111
+ } else {
112
+ reportError(
113
+ "WEBSOCKET_REFUSED",
114
+ `The server is reachable and the session is valid, but it refused the WebSocket connection (close code ${lastCloseCode ?? 1006}).`
115
+ );
116
+ }
117
+ }, [issue, refused, lastCloseCode, reportError]);
118
+
21
119
  // Auth required (password mode) and no valid session: show the login screen,
22
120
  // even if we were connected before (an expired session must re-prompt).
23
- if (vpnStatus === "unauthorized") {
121
+ if (issue === "unauthorized") {
24
122
  return <LoginScreen />;
25
123
  }
26
124
 
@@ -28,8 +126,8 @@ export function ConnectionGate({ children }: { children: ReactNode }) {
28
126
  return (
29
127
  <>
30
128
  <OfflineBanner
31
- show={everConnected && vpnStatus !== "connected"}
32
- forbidden={vpnStatus === "forbidden"}
129
+ show={refused || (everConnected && vpnStatus !== "connected")}
130
+ issue={issue}
33
131
  />
34
132
  {children}
35
133
  </>
@@ -96,11 +194,22 @@ export function ConnectionGate({ children }: { children: ReactNode }) {
96
194
  */
97
195
  function OfflineBanner({
98
196
  show,
99
- forbidden,
197
+ issue,
100
198
  }: {
101
199
  show: boolean;
102
- forbidden: boolean;
200
+ issue: ConnectionIssue;
103
201
  }) {
202
+ const message =
203
+ issue === "forbidden"
204
+ ? "VPN required — reconnect Tailscale"
205
+ : issue === "unreachable"
206
+ ? "Connection lost — reconnecting…"
207
+ : issue === "capacity"
208
+ ? "Server connection limit reached"
209
+ : issue === "refused"
210
+ ? "Server refused the live connection"
211
+ : "Connection lost — reconnecting…";
212
+
104
213
  return (
105
214
  <AnimatePresence>
106
215
  {show && (
@@ -113,18 +222,23 @@ function OfflineBanner({
113
222
  >
114
223
  <div
115
224
  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"
225
+ 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
226
  >
118
- {forbidden ? (
227
+ {issue === "forbidden" ? (
119
228
  <ShieldAlert className="h-3.5 w-3.5" />
120
229
  ) : (
121
230
  <WifiOff className="h-3.5 w-3.5" />
122
231
  )}
123
- <span>
124
- {forbidden
125
- ? "VPN required — reconnect Tailscale"
126
- : "Connection lost — reconnecting…"}
127
- </span>
232
+ <span>{message}</span>
233
+ {(issue === "refused" || issue === "capacity") && (
234
+ <button
235
+ type="button"
236
+ onClick={reconnectWebSocketNow}
237
+ className="font-medium underline underline-offset-2 hover:text-amber-100"
238
+ >
239
+ Retry now
240
+ </button>
241
+ )}
128
242
  <span
129
243
  className="inline-block h-1.5 w-1.5 rounded-full bg-amber-400"
130
244
  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
+ }