@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
@@ -0,0 +1,548 @@
1
+ import { Suspense, useEffect, useMemo, 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
+ } from "@schlessera/brain-ui-sdk/protocol";
14
+
15
+ import { useGraphStore } from "../../stores/graph-store.js";
16
+ import { useUIStore } from "../../stores/ui-store.js";
17
+ import { useFileStore } from "../../stores/file-store.js";
18
+ import { apiBase } from "../../lib/backend.js";
19
+ import { GraphControls } from "./graph-controls.js";
20
+ import { NodePopover } from "./node-popover.js";
21
+ import { GraphEmptyState, Mono } from "./graph-empty-state.js";
22
+ import { GraphCanvas } from "./graph-canvas-lazy.js";
23
+ import { CenteredSpinner } from "./graph-spinner.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";
35
+ import { cn } from "../../lib/utils.js";
36
+
37
+ /** Client force layout is a fallback for repos whose FA2 layout was skipped —
38
+ * beyond this it would freeze the tab, so we refuse and say why. */
39
+ const CLIENT_LAYOUT_NODE_CAP = 2000;
40
+
41
+ /** The canvas plus everything overlaid on it. Only mounts with data present. */
42
+ export function SceneBody() {
43
+ const mode = useGraphStore((s) => s.mode);
44
+ const meta = useGraphStore((s) => s.meta);
45
+ const subgraph = useGraphStore((s) => s.subgraph)!;
46
+ const selectedId = useGraphStore((s) => s.selectedId);
47
+ const hoveredId = useGraphStore((s) => s.hoveredId);
48
+ const select = useGraphStore((s) => s.select);
49
+ const hover = useGraphStore((s) => s.hover);
50
+ const sceneQuery = useGraphStore((s) => s.sceneQuery);
51
+ const clustersSizeBy = useGraphStore((s) => s.clustersSizeBy);
52
+ const discoveryColorBy = useGraphStore((s) => s.discoveryColorBy);
53
+ const theme = useGraphTheme();
54
+ const [listOpen, setListOpen] = useState(false);
55
+
56
+ const matchIds = useMemo(
57
+ () => matchScene(subgraph.nodes, sceneQuery),
58
+ [subgraph.nodes, sceneQuery]
59
+ );
60
+
61
+ const layout =
62
+ mode === "clusters"
63
+ ? subgraph.nodes.some((n) => n.x !== undefined)
64
+ ? ("fixed" as const)
65
+ : ("force" as const)
66
+ : mode === "discovery"
67
+ ? ("radial" as const)
68
+ : ("force" as const);
69
+
70
+ const maxDistance = useMemo(
71
+ () => Math.max(1, ...subgraph.nodes.map((n) => n.distance ?? 0)),
72
+ [subgraph.nodes]
73
+ );
74
+
75
+ const folderColors = useMemo(
76
+ () =>
77
+ mode === "discovery" && discoveryColorBy === "folder"
78
+ ? assignFolderColors(subgraph.nodes.map((n) => n.path))
79
+ : null,
80
+ [mode, discoveryColorBy, subgraph.nodes]
81
+ );
82
+
83
+ const nodeColor = useMemo(() => {
84
+ if (mode === "discovery") {
85
+ if (folderColors) {
86
+ return (node: GraphNodePayload) =>
87
+ node.virtual
88
+ ? theme.nodeSelected
89
+ : (folderColors.get(topLevelDir(node.path)) ?? OTHER_COLOR);
90
+ }
91
+ return (node: GraphNodePayload) =>
92
+ node.virtual ? theme.nodeSelected : distanceColor(node.distance ?? maxDistance);
93
+ }
94
+ return (node: GraphNodePayload) =>
95
+ node.community !== undefined ? communityColor(node.community) : theme.node;
96
+ }, [mode, folderColors, maxDistance, theme]);
97
+
98
+ const centerId = useMemo(
99
+ () =>
100
+ mode === "local"
101
+ ? (subgraph.nodes.find((n) => n.distance === 0)?.id ?? null)
102
+ : null,
103
+ [mode, subgraph.nodes]
104
+ );
105
+
106
+ const edgeColor = useMemo(() => {
107
+ if (mode !== "local" || centerId === null) return undefined;
108
+ // Backlinks into the center get the accent so in/out reads at a glance.
109
+ return (edge: { source: number; target: number }) =>
110
+ edge.target === centerId ? theme.edgeHighlight : undefined;
111
+ }, [mode, centerId, theme]);
112
+
113
+ const selectedNode = useMemo(
114
+ () =>
115
+ selectedId === null
116
+ ? null
117
+ : (subgraph.nodes.find((n) => n.id === selectedId) ?? null),
118
+ [selectedId, subgraph.nodes]
119
+ );
120
+
121
+ const communityLabel =
122
+ selectedNode?.community !== undefined
123
+ ? (meta?.communities.find((c) => c.community === selectedNode.community)
124
+ ?.label ?? null)
125
+ : null;
126
+
127
+ // Clusters whose FA2 layout was skipped at index time: client force layout
128
+ // is a bounded courtesy. Over the cap, only the canvas is replaced — the
129
+ // legend and controls stay live so filtering to one topic (a subset under
130
+ // the cap) still works.
131
+ const layoutBlocked =
132
+ mode === "clusters" &&
133
+ layout === "force" &&
134
+ subgraph.nodes.length > CLIENT_LAYOUT_NODE_CAP;
135
+
136
+ return (
137
+ <>
138
+ {layoutBlocked ? (
139
+ <GraphEmptyState icon="warn" title="Too large to lay out here">
140
+ The cluster layout was skipped at index time (corpus over the layout
141
+ cap), and {subgraph.nodes.length.toLocaleString()} notes are too many
142
+ to arrange in the browser. Pick a topic from the legend to view a
143
+ subset.
144
+ </GraphEmptyState>
145
+ ) : (
146
+ <Suspense fallback={<CenteredSpinner />}>
147
+ <GraphCanvas
148
+ data={subgraph}
149
+ layout={layout}
150
+ selectedId={selectedId}
151
+ hoveredId={hoveredId}
152
+ matchIds={matchIds}
153
+ sizeBy={mode === "clusters" ? clustersSizeBy : "degree"}
154
+ rings={mode === "discovery" ? maxDistance : 0}
155
+ nodeColor={nodeColor}
156
+ edgeColor={edgeColor}
157
+ onSelect={select}
158
+ onHover={hover}
159
+ />
160
+ </Suspense>
161
+ )}
162
+
163
+ <GraphControls />
164
+
165
+ {mode === "clusters" && <ClusterLegend />}
166
+ {mode === "discovery" && folderColors && (
167
+ <FolderLegend colors={folderColors} />
168
+ )}
169
+ {mode === "discovery" && (
170
+ <UnreachableTray count={subgraph.unreachableCount} />
171
+ )}
172
+
173
+ {/* Accessible / mobile-friendly node list — the canvas itself is aria-hidden */}
174
+ <button
175
+ onClick={() => setListOpen((v) => !v)}
176
+ 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"
177
+ title="Node list"
178
+ aria-expanded={listOpen}
179
+ >
180
+ <List className="h-4.5 w-4.5" />
181
+ </button>
182
+ {listOpen && <NodeList onClose={() => setListOpen(false)} />}
183
+
184
+ {selectedNode && (
185
+ <NodePopover node={selectedNode} communityLabel={communityLabel} />
186
+ )}
187
+ </>
188
+ );
189
+ }
190
+
191
+ /** Clusters mode: interactive community legend, top-left. */
192
+ function ClusterLegend() {
193
+ const meta = useGraphStore((s) => s.meta);
194
+ const clusters = useGraphStore((s) => s.clusters);
195
+ const setClustersParams = useGraphStore((s) => s.setClustersParams);
196
+ const [collapsed, setCollapsed] = useState(false);
197
+
198
+ const groups = useMemo(
199
+ () => groupCommunities(meta?.communities ?? []),
200
+ [meta?.communities]
201
+ );
202
+ if (groups.major.length === 0) return null;
203
+
204
+ const shown = groups.major.slice(0, 8);
205
+ const foldedCount =
206
+ groups.major.length - shown.length + groups.singletonCount;
207
+
208
+ return (
209
+ <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">
210
+ <button
211
+ onClick={() => setCollapsed((v) => !v)}
212
+ className="flex w-full items-center justify-between px-3 py-2 text-xs font-medium text-foreground"
213
+ aria-expanded={!collapsed}
214
+ >
215
+ Topics
216
+ {collapsed ? (
217
+ <ChevronDown className="h-3.5 w-3.5 text-muted-foreground" />
218
+ ) : (
219
+ <ChevronUp className="h-3.5 w-3.5 text-muted-foreground" />
220
+ )}
221
+ </button>
222
+ {!collapsed && (
223
+ <div className="max-h-64 overflow-y-auto px-1 pb-2">
224
+ {shown.map((c) => {
225
+ const active = clusters.community === c.community;
226
+ return (
227
+ <button
228
+ key={c.community}
229
+ onClick={() =>
230
+ setClustersParams({ community: active ? null : c.community })
231
+ }
232
+ className={cn(
233
+ "flex min-h-9 w-full items-center gap-2 rounded-lg px-2 py-1 text-left transition-colors",
234
+ active ? "bg-surface-raised" : "hover:bg-surface-raised/60"
235
+ )}
236
+ title={
237
+ active
238
+ ? "Show all topics"
239
+ : `Show only this topic (${c.size} notes)`
240
+ }
241
+ >
242
+ <span
243
+ className="h-2.5 w-2.5 shrink-0 rounded-full"
244
+ style={{ backgroundColor: communityColor(c.community) }}
245
+ />
246
+ <span className="min-w-0 flex-1 truncate text-xs text-foreground">
247
+ {c.label ?? `Topic ${c.community + 1}`}
248
+ </span>
249
+ <span className="shrink-0 text-[10px] text-muted-foreground">
250
+ {c.size}
251
+ </span>
252
+ </button>
253
+ );
254
+ })}
255
+ {foldedCount > 0 && (
256
+ <div className="flex items-center gap-2 px-2 py-1.5">
257
+ <span
258
+ className="h-2.5 w-2.5 shrink-0 rounded-full"
259
+ style={{ backgroundColor: OTHER_COLOR }}
260
+ />
261
+ <span className="text-[11px] text-muted-foreground">
262
+ {foldedCount} smaller topic{foldedCount === 1 ? "" : "s"}
263
+ </span>
264
+ </div>
265
+ )}
266
+ {clusters.community !== null && (
267
+ <button
268
+ onClick={() => setClustersParams({ community: null })}
269
+ 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"
270
+ >
271
+ <X className="h-3 w-3" />
272
+ Clear topic filter
273
+ </button>
274
+ )}
275
+ </div>
276
+ )}
277
+ </div>
278
+ );
279
+ }
280
+
281
+ /** Discovery + color-by-folder: which top-level dir wears which color. */
282
+ function FolderLegend({ colors }: { colors: Map<string, string> }) {
283
+ const entries = [...colors.entries()].filter(([, c]) => c !== OTHER_COLOR);
284
+ const otherCount = colors.size - entries.length;
285
+ if (entries.length === 0) return null;
286
+ return (
287
+ <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">
288
+ <div className="mb-1 text-xs font-medium text-foreground">Folders</div>
289
+ {entries.map(([dir, color]) => (
290
+ <div key={dir} className="flex min-h-6 items-center gap-2">
291
+ <span
292
+ className="h-2.5 w-2.5 shrink-0 rounded-full"
293
+ style={{ backgroundColor: color }}
294
+ />
295
+ <span className="truncate font-mono text-[11px] text-muted-foreground">
296
+ {dir || "(root)"}
297
+ </span>
298
+ </div>
299
+ ))}
300
+ {otherCount > 0 && (
301
+ <div className="flex min-h-6 items-center gap-2">
302
+ <span
303
+ className="h-2.5 w-2.5 shrink-0 rounded-full"
304
+ style={{ backgroundColor: OTHER_COLOR }}
305
+ />
306
+ <span className="text-[11px] text-muted-foreground">
307
+ {otherCount} more
308
+ </span>
309
+ </div>
310
+ )}
311
+ </div>
312
+ );
313
+ }
314
+
315
+ /**
316
+ * Discovery: how much of the corpus this root does NOT reach. The count comes
317
+ * from the server (`unreachableCount`) — a scene-size subtraction would lie
318
+ * under depth limits, truncation, and the virtual root. An older server
319
+ * doesn't send it; then the tray only appears for the default root, where the
320
+ * maintenance findings still provide the exact list, and shows no number.
321
+ */
322
+ function UnreachableTray({ count }: { count: number | undefined }) {
323
+ const discovery = useGraphStore((s) => s.discovery);
324
+ const staleDays = useGraphStore((s) => s.maintenance.staleDays);
325
+ const openFile = useFileStore((s) => s.openFile);
326
+ const setFilePanelOpen = useUIStore((s) => s.setFilePanelOpen);
327
+ const [open, setOpen] = useState(false);
328
+ const [nodes, setNodes] = useState<GraphNodePayload[] | null>(null);
329
+
330
+ const isDefaultRoot = !discovery.root;
331
+ // Older server, no count field: the maintenance list is the only honest
332
+ // source, so it must be fetched EAGERLY — the chip may only appear once
333
+ // the list proves there is something to warn about.
334
+ const needsFallbackProof = count === undefined && isDefaultRoot;
335
+
336
+ useEffect(() => {
337
+ const wanted = (open || needsFallbackProof) && isDefaultRoot;
338
+ if (!wanted || nodes !== null) return;
339
+ let cancelled = false;
340
+ void (async () => {
341
+ try {
342
+ const res = await fetch(
343
+ `${apiBase()}/graph/maintenance?${buildQuery({ staleDays })}`
344
+ );
345
+ if (!res.ok) return;
346
+ const data = (await res.json()) as GraphMaintenanceResponse;
347
+ if (!cancelled) setNodes(data.unreachable);
348
+ } catch {
349
+ // The tray degrades to count-only (or stays hidden in fallback mode).
350
+ }
351
+ })();
352
+ return () => {
353
+ cancelled = true;
354
+ };
355
+ }, [open, needsFallbackProof, isDefaultRoot, nodes, staleDays]);
356
+
357
+ if (count === 0) return null;
358
+ // No server count and no exact list available: nothing honest to show.
359
+ if (count === undefined && !isDefaultRoot) return null;
360
+ // Fallback mode: silent until the maintenance list confirms unreachable
361
+ // notes exist — an unproven warning chip would cry wolf on clean repos.
362
+ if (needsFallbackProof && (nodes === null || nodes.length === 0)) return null;
363
+
364
+ return (
365
+ <div className="absolute bottom-3 left-3 z-10 max-w-[calc(100vw-5rem)]">
366
+ {open && (
367
+ <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">
368
+ <div className="border-b border-border px-3 py-2 text-[11px] text-muted-foreground">
369
+ {isDefaultRoot
370
+ ? "Not reachable from the root:"
371
+ : "Count only — pick the default root for the full list."}
372
+ </div>
373
+ {isDefaultRoot && (
374
+ <div className="flex-1 overflow-y-auto">
375
+ {(nodes ?? []).map((node) => (
376
+ <button
377
+ key={node.id}
378
+ onClick={() => {
379
+ setFilePanelOpen(true);
380
+ void openFile(node.path);
381
+ }}
382
+ className="flex min-h-9 w-full flex-col justify-center px-3 py-1 text-left hover:bg-surface-raised/60"
383
+ >
384
+ <span className="truncate text-xs text-foreground">
385
+ {node.title || node.path}
386
+ </span>
387
+ </button>
388
+ ))}
389
+ {nodes === null && (
390
+ <div className="flex justify-center py-3">
391
+ <Loader2 className="h-4 w-4 animate-spin text-primary" />
392
+ </div>
393
+ )}
394
+ </div>
395
+ )}
396
+ </div>
397
+ )}
398
+ <button
399
+ onClick={() => setOpen((v) => !v)}
400
+ aria-expanded={open}
401
+ 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"
402
+ >
403
+ <TriangleAlert className="h-3 w-3" />
404
+ {count !== undefined
405
+ ? `${count} unreachable`
406
+ : `${nodes!.length} unreachable`}
407
+ </button>
408
+ </div>
409
+ );
410
+ }
411
+
412
+ /**
413
+ * Discovery with no root at all (no entry file in the repo): make picking one
414
+ * inviting — index notes are the natural candidates.
415
+ */
416
+ export function DiscoveryStart() {
417
+ const setDiscoveryParams = useGraphStore((s) => s.setDiscoveryParams);
418
+ const [candidates, setCandidates] = useState<
419
+ { path: string; title: string }[] | null
420
+ >(null);
421
+
422
+ useEffect(() => {
423
+ let cancelled = false;
424
+ void (async () => {
425
+ try {
426
+ const res = await fetch(
427
+ `${apiBase()}/brain/list?${buildQuery({ type: "index", limit: 6 })}`
428
+ );
429
+ if (!res.ok) return;
430
+ const data = (await res.json()) as {
431
+ results?: { path: string; title?: string }[];
432
+ };
433
+ if (!cancelled)
434
+ setCandidates(
435
+ (data.results ?? []).map((r) => ({
436
+ path: r.path,
437
+ title: r.title ?? r.path,
438
+ }))
439
+ );
440
+ } catch {
441
+ if (!cancelled) setCandidates([]);
442
+ }
443
+ })();
444
+ return () => {
445
+ cancelled = true;
446
+ };
447
+ }, []);
448
+
449
+ return (
450
+ <>
451
+ <div className="flex h-full items-center justify-center p-6">
452
+ <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">
453
+ <h2 className="font-display text-lg text-foreground">
454
+ Where should discovery start?
455
+ </h2>
456
+ <p className="text-xs leading-relaxed text-muted-foreground">
457
+ Discovery lays your notes out by how many links they are away from
458
+ a starting point. This repo has no <Mono>AGENTS.md</Mono> entry
459
+ file, so pick one — index notes work well:
460
+ </p>
461
+ {candidates === null ? (
462
+ <div className="flex justify-center py-2">
463
+ <Loader2 className="h-4 w-4 animate-spin text-primary" />
464
+ </div>
465
+ ) : (
466
+ <div className="flex flex-col gap-1.5">
467
+ {candidates.map((c) => (
468
+ <button
469
+ key={c.path}
470
+ onClick={() => setDiscoveryParams({ root: c.path })}
471
+ 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"
472
+ >
473
+ <span className="truncate text-xs text-foreground">
474
+ {c.title}
475
+ </span>
476
+ <span className="truncate font-mono text-[10px] text-muted-foreground/60">
477
+ {c.path}
478
+ </span>
479
+ </button>
480
+ ))}
481
+ </div>
482
+ )}
483
+ <p className="text-[11px] text-muted-foreground">
484
+ …or search any note in the options panel.
485
+ </p>
486
+ </div>
487
+ </div>
488
+ <GraphControls />
489
+ </>
490
+ );
491
+ }
492
+
493
+ /** Focusable listing of the current scene, sorted by connectedness. */
494
+ function NodeList({ onClose }: { onClose: () => void }) {
495
+ const subgraph = useGraphStore((s) => s.subgraph)!;
496
+ const select = useGraphStore((s) => s.select);
497
+ const selectedId = useGraphStore((s) => s.selectedId);
498
+
499
+ const sorted = useMemo(
500
+ () =>
501
+ [...subgraph.nodes].sort(
502
+ (a, b) => b.inDegree + b.outDegree - (a.inDegree + a.outDegree)
503
+ ),
504
+ [subgraph.nodes]
505
+ );
506
+
507
+ return (
508
+ <nav
509
+ aria-label="Graph nodes"
510
+ 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"
511
+ >
512
+ <div className="flex items-center justify-between border-b border-border px-3 py-2">
513
+ <span className="text-xs font-medium text-foreground">
514
+ {sorted.length} note{sorted.length === 1 ? "" : "s"}
515
+ </span>
516
+ <button
517
+ onClick={onClose}
518
+ className="rounded p-1 text-muted-foreground hover:text-foreground"
519
+ title="Close list"
520
+ >
521
+ <X className="h-3.5 w-3.5" />
522
+ </button>
523
+ </div>
524
+ <div className="flex-1 overflow-y-auto">
525
+ {sorted.map((node) => (
526
+ <button
527
+ key={node.id}
528
+ onClick={() => select(node.id)}
529
+ className={cn(
530
+ "flex min-h-11 w-full flex-col justify-center px-3 py-1.5 text-left transition-colors",
531
+ node.id === selectedId
532
+ ? "bg-surface-raised"
533
+ : "hover:bg-surface-raised/60"
534
+ )}
535
+ >
536
+ <span className="truncate text-xs text-foreground">
537
+ {node.title || node.path}
538
+ </span>
539
+ <span className="truncate font-mono text-[10px] text-muted-foreground/60">
540
+ {node.inDegree + node.outDegree} link
541
+ {node.inDegree + node.outDegree === 1 ? "" : "s"} · {node.path}
542
+ </span>
543
+ </button>
544
+ ))}
545
+ </div>
546
+ </nav>
547
+ );
548
+ }
@@ -0,0 +1,9 @@
1
+ import { Loader2 } from "lucide-react";
2
+
3
+ export function CenteredSpinner() {
4
+ return (
5
+ <div className="flex h-full items-center justify-center">
6
+ <Loader2 className="h-6 w-6 animate-spin text-primary" />
7
+ </div>
8
+ );
9
+ }
@@ -1,55 +1,86 @@
1
1
  import { useEffect } from "react";
2
- import { useConnectionStore } from "../stores/connection-store.js";
2
+ import {
3
+ useConnectionStore,
4
+ type VpnStatus,
5
+ } from "../stores/connection-store.js";
3
6
  import { getBackendUrl } from "../lib/backend.js";
4
7
 
5
8
  const POLL_INTERVAL = 15_000;
6
9
  // Tighter cadence while offline so recovery is near-immediate
7
10
  const OFFLINE_POLL_INTERVAL = 3_000;
8
11
  const TIMEOUT_MS = 5_000;
12
+ let activeRecheck: (() => void) | null = null;
9
13
 
10
- export function useVpnStatus() {
11
- const setVpnStatus = useConnectionStore((s) => s.setVpnStatus);
14
+ async function fetchVpnStatus(): Promise<VpnStatus> {
15
+ const controller = new AbortController();
16
+ const timeout = setTimeout(() => controller.abort(), TIMEOUT_MS);
17
+ try {
18
+ const res = await fetch(getBackendUrl("/api/vpn-check"), {
19
+ signal: controller.signal,
20
+ });
21
+
22
+ if (res.ok) return "connected";
23
+ if (res.status === 401) return "unauthorized";
24
+ if (res.status === 403) return "forbidden";
25
+ return "unreachable";
26
+ } catch {
27
+ return "unreachable";
28
+ } finally {
29
+ clearTimeout(timeout);
30
+ }
31
+ }
32
+
33
+ /**
34
+ * Ask the mounted connectivity poller for a fresh reading. The poller remains
35
+ * the only writer of vpnStatus and serializes this with its scheduled checks.
36
+ */
37
+ export function recheckVpnStatus(): void {
38
+ activeRecheck?.();
39
+ }
12
40
 
41
+ export function useVpnStatus() {
13
42
  useEffect(() => {
14
43
  let timer: ReturnType<typeof setTimeout> | undefined;
15
44
  let inFlight = false;
45
+ let recheckRequested = false;
16
46
  let disposed = false;
17
47
 
18
48
  async function check() {
19
- if (inFlight || disposed) return;
49
+ if (disposed) return;
50
+ if (inFlight) {
51
+ recheckRequested = true;
52
+ return;
53
+ }
20
54
  inFlight = true;
21
- let connected = false;
55
+ const opensAtStart = useConnectionStore.getState().socketOpens;
22
56
  try {
23
- const controller = new AbortController();
24
- const timeout = setTimeout(() => controller.abort(), TIMEOUT_MS);
25
-
26
- const res = await fetch(getBackendUrl("/api/vpn-check"), {
27
- signal: controller.signal,
28
- });
29
- clearTimeout(timeout);
30
-
31
- if (res.ok) {
32
- setVpnStatus("connected");
33
- connected = true;
34
- } else if (res.status === 401) {
35
- // Auth required (password / proxy mode) but no valid session yet.
36
- setVpnStatus("unauthorized");
37
- connected = true; // reachable — poll at the relaxed cadence
38
- } else if (res.status === 403) {
39
- setVpnStatus("forbidden");
40
- } else {
41
- setVpnStatus("unreachable");
57
+ const status = await fetchVpnStatus();
58
+ // A socket that reached `open` while this probe was in flight is newer
59
+ // reachability evidence than a failure the probe was started for.
60
+ // Counting opens rather than sampling wsStatus twice also catches a
61
+ // drop and a replacement open inside one probe, where both samples
62
+ // read "connected".
63
+ const socketOpenedDuringCheck =
64
+ useConnectionStore.getState().socketOpens !== opensAtStart;
65
+ if (!disposed && !(socketOpenedDuringCheck && status !== "connected")) {
66
+ useConnectionStore.getState().setVpnStatus(status);
42
67
  }
43
- } catch {
44
- setVpnStatus("unreachable");
45
68
  } finally {
46
69
  inFlight = false;
47
70
  if (!disposed) {
48
71
  clearTimeout(timer);
49
- timer = setTimeout(
50
- check,
51
- connected ? POLL_INTERVAL : OFFLINE_POLL_INTERVAL
52
- );
72
+ if (recheckRequested) {
73
+ recheckRequested = false;
74
+ void check();
75
+ } else {
76
+ const status = useConnectionStore.getState().vpnStatus;
77
+ // Auth required but no valid session is still a reachable server.
78
+ const reachable = status === "connected" || status === "unauthorized";
79
+ timer = setTimeout(
80
+ check,
81
+ reachable ? POLL_INTERVAL : OFFLINE_POLL_INTERVAL
82
+ );
83
+ }
53
84
  }
54
85
  }
55
86
  }
@@ -57,6 +88,7 @@ export function useVpnStatus() {
57
88
  // Re-check immediately when the network returns or the PWA is foregrounded
58
89
  // instead of waiting out the poll interval.
59
90
  const recheckNow = () => void check();
91
+ activeRecheck = recheckNow;
60
92
  const onVisible = () => {
61
93
  if (document.visibilityState === "visible") void check();
62
94
  };
@@ -68,8 +100,9 @@ export function useVpnStatus() {
68
100
  return () => {
69
101
  disposed = true;
70
102
  clearTimeout(timer);
103
+ if (activeRecheck === recheckNow) activeRecheck = null;
71
104
  window.removeEventListener("online", recheckNow);
72
105
  document.removeEventListener("visibilitychange", onVisible);
73
106
  };
74
- }, [setVpnStatus]);
107
+ }, []);
75
108
  }