@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.
- package/README.md +5 -0
- package/dist/components/activity/activity-page.d.ts.map +1 -1
- package/dist/components/activity/activity-page.js +8 -149
- package/dist/components/activity/activity-page.js.map +1 -1
- package/dist/components/activity/activity-rollups.d.ts +5 -0
- package/dist/components/activity/activity-rollups.d.ts.map +1 -0
- package/dist/components/activity/activity-rollups.js +39 -0
- package/dist/components/activity/activity-rollups.js.map +1 -0
- package/dist/components/activity/activity-run-detail.d.ts +11 -0
- package/dist/components/activity/activity-run-detail.d.ts.map +1 -0
- package/dist/components/activity/activity-run-detail.js +111 -0
- package/dist/components/activity/activity-run-detail.js.map +1 -0
- package/dist/components/activity/activity-run-list.d.ts +15 -0
- package/dist/components/activity/activity-run-list.d.ts.map +1 -0
- package/dist/components/activity/activity-run-list.js +25 -0
- package/dist/components/activity/activity-run-list.js.map +1 -0
- package/dist/components/chat/brain-markdown-code.d.ts +4 -0
- package/dist/components/chat/brain-markdown-code.d.ts.map +1 -0
- package/dist/components/chat/brain-markdown-code.js +47 -0
- package/dist/components/chat/brain-markdown-code.js.map +1 -0
- package/dist/components/chat/brain-markdown-entities.d.ts +14 -0
- package/dist/components/chat/brain-markdown-entities.d.ts.map +1 -0
- package/dist/components/chat/brain-markdown-entities.js +93 -0
- package/dist/components/chat/brain-markdown-entities.js.map +1 -0
- package/dist/components/chat/brain-markdown-links.d.ts +36 -0
- package/dist/components/chat/brain-markdown-links.d.ts.map +1 -0
- package/dist/components/chat/brain-markdown-links.js +176 -0
- package/dist/components/chat/brain-markdown-links.js.map +1 -0
- package/dist/components/chat/brain-markdown-share.d.ts +15 -0
- package/dist/components/chat/brain-markdown-share.d.ts.map +1 -0
- package/dist/components/chat/brain-markdown-share.js +40 -0
- package/dist/components/chat/brain-markdown-share.js.map +1 -0
- package/dist/components/chat/brain-markdown.d.ts +1 -34
- package/dist/components/chat/brain-markdown.d.ts.map +1 -1
- package/dist/components/chat/brain-markdown.js +9 -402
- package/dist/components/chat/brain-markdown.js.map +1 -1
- package/dist/components/chat/composer.d.ts.map +1 -1
- package/dist/components/chat/composer.js +14 -1
- package/dist/components/chat/composer.js.map +1 -1
- package/dist/components/chat/use-markdown-highlight.d.ts +12 -0
- package/dist/components/chat/use-markdown-highlight.d.ts.map +1 -0
- package/dist/components/chat/use-markdown-highlight.js +56 -0
- package/dist/components/chat/use-markdown-highlight.js.map +1 -0
- package/dist/components/connectivity/connection-gate.d.ts.map +1 -1
- package/dist/components/connectivity/connection-gate.js +41 -7
- package/dist/components/connectivity/connection-gate.js.map +1 -1
- package/dist/components/connectivity/connection-state.d.ts +12 -0
- package/dist/components/connectivity/connection-state.d.ts.map +1 -0
- package/dist/components/connectivity/connection-state.js +23 -0
- package/dist/components/connectivity/connection-state.js.map +1 -0
- package/dist/components/graph/graph-canvas-lazy.d.ts +2 -0
- package/dist/components/graph/graph-canvas-lazy.d.ts.map +1 -0
- package/dist/components/graph/graph-canvas-lazy.js +4 -0
- package/dist/components/graph/graph-canvas-lazy.js.map +1 -0
- package/dist/components/graph/graph-controls.js +4 -1
- package/dist/components/graph/graph-controls.js.map +1 -1
- package/dist/components/graph/graph-maintenance.d.ts +8 -0
- package/dist/components/graph/graph-maintenance.d.ts.map +1 -0
- package/dist/components/graph/graph-maintenance.js +115 -0
- package/dist/components/graph/graph-maintenance.js.map +1 -0
- package/dist/components/graph/graph-page.d.ts.map +1 -1
- package/dist/components/graph/graph-page.js +11 -306
- package/dist/components/graph/graph-page.js.map +1 -1
- package/dist/components/graph/graph-scene.d.ts +8 -0
- package/dist/components/graph/graph-scene.d.ts.map +1 -0
- package/dist/components/graph/graph-scene.js +208 -0
- package/dist/components/graph/graph-scene.js.map +1 -0
- package/dist/components/graph/graph-spinner.d.ts +2 -0
- package/dist/components/graph/graph-spinner.d.ts.map +1 -0
- package/dist/components/graph/graph-spinner.js +6 -0
- package/dist/components/graph/graph-spinner.js.map +1 -0
- package/dist/hooks/use-vpn-status.d.ts +5 -0
- package/dist/hooks/use-vpn-status.d.ts.map +1 -1
- package/dist/hooks/use-vpn-status.js +61 -29
- package/dist/hooks/use-vpn-status.js.map +1 -1
- package/dist/hooks/use-websocket.d.ts +3 -2
- package/dist/hooks/use-websocket.d.ts.map +1 -1
- package/dist/hooks/use-websocket.js +61 -252
- package/dist/hooks/use-websocket.js.map +1 -1
- package/dist/hooks/websocket-handlers/activity.d.ts +5 -0
- package/dist/hooks/websocket-handlers/activity.d.ts.map +1 -0
- package/dist/hooks/websocket-handlers/activity.js +10 -0
- package/dist/hooks/websocket-handlers/activity.js.map +1 -0
- package/dist/hooks/websocket-handlers/chat.d.ts +17 -0
- package/dist/hooks/websocket-handlers/chat.d.ts.map +1 -0
- package/dist/hooks/websocket-handlers/chat.js +175 -0
- package/dist/hooks/websocket-handlers/chat.js.map +1 -0
- package/dist/hooks/websocket-handlers/connection.d.ts +6 -0
- package/dist/hooks/websocket-handlers/connection.d.ts.map +1 -0
- package/dist/hooks/websocket-handlers/connection.js +27 -0
- package/dist/hooks/websocket-handlers/connection.js.map +1 -0
- package/dist/hooks/websocket-handlers/index.d.ts +4 -0
- package/dist/hooks/websocket-handlers/index.d.ts.map +1 -0
- package/dist/hooks/websocket-handlers/index.js +19 -0
- package/dist/hooks/websocket-handlers/index.js.map +1 -0
- package/dist/hooks/websocket-handlers/mask.d.ts +4 -0
- package/dist/hooks/websocket-handlers/mask.d.ts.map +1 -0
- package/dist/hooks/websocket-handlers/mask.js +14 -0
- package/dist/hooks/websocket-handlers/mask.js.map +1 -0
- package/dist/hooks/websocket-handlers/provider.d.ts +4 -0
- package/dist/hooks/websocket-handlers/provider.d.ts.map +1 -0
- package/dist/hooks/websocket-handlers/provider.js +31 -0
- package/dist/hooks/websocket-handlers/provider.js.map +1 -0
- package/dist/hooks/websocket-handlers/types.d.ts +24 -0
- package/dist/hooks/websocket-handlers/types.d.ts.map +1 -0
- package/dist/hooks/websocket-handlers/types.js +2 -0
- package/dist/hooks/websocket-handlers/types.js.map +1 -0
- package/dist/stores/activity-store-helpers.d.ts +50 -0
- package/dist/stores/activity-store-helpers.d.ts.map +1 -0
- package/dist/stores/activity-store-helpers.js +143 -0
- package/dist/stores/activity-store-helpers.js.map +1 -0
- package/dist/stores/activity-store-types.d.ts +48 -0
- package/dist/stores/activity-store-types.d.ts.map +1 -0
- package/dist/stores/activity-store-types.js +2 -0
- package/dist/stores/activity-store-types.js.map +1 -0
- package/dist/stores/activity-store.d.ts +2 -78
- package/dist/stores/activity-store.d.ts.map +1 -1
- package/dist/stores/activity-store.js +22 -142
- package/dist/stores/activity-store.js.map +1 -1
- package/dist/stores/connection-store.d.ts +14 -2
- package/dist/stores/connection-store.d.ts.map +1 -1
- package/dist/stores/connection-store.js +17 -0
- package/dist/stores/connection-store.js.map +1 -1
- package/dist/styles.css +1 -1
- package/package.json +2 -2
- package/src/components/activity/activity-page.tsx +6 -477
- package/src/components/activity/activity-rollups.tsx +94 -0
- package/src/components/activity/activity-run-detail.tsx +301 -0
- package/src/components/activity/activity-run-list.tsx +97 -0
- package/src/components/chat/brain-markdown-code.tsx +60 -0
- package/src/components/chat/brain-markdown-entities.tsx +116 -0
- package/src/components/chat/brain-markdown-links.tsx +247 -0
- package/src/components/chat/brain-markdown-share.ts +47 -0
- package/src/components/chat/brain-markdown.tsx +27 -535
- package/src/components/chat/composer.tsx +14 -1
- package/src/components/chat/use-markdown-highlight.ts +63 -0
- package/src/components/connectivity/connection-gate.tsx +64 -13
- package/src/components/connectivity/connection-state.ts +37 -0
- package/src/components/graph/graph-canvas-lazy.tsx +4 -0
- package/src/components/graph/graph-controls.tsx +4 -1
- package/src/components/graph/graph-maintenance.tsx +304 -0
- package/src/components/graph/graph-page.tsx +11 -840
- package/src/components/graph/graph-scene.tsx +548 -0
- package/src/components/graph/graph-spinner.tsx +9 -0
- package/src/hooks/use-vpn-status.ts +64 -31
- package/src/hooks/use-websocket.ts +70 -273
- package/src/hooks/websocket-handlers/activity.ts +13 -0
- package/src/hooks/websocket-handlers/chat.ts +210 -0
- package/src/hooks/websocket-handlers/connection.ts +30 -0
- package/src/hooks/websocket-handlers/index.ts +59 -0
- package/src/hooks/websocket-handlers/mask.ts +17 -0
- package/src/hooks/websocket-handlers/provider.ts +35 -0
- package/src/hooks/websocket-handlers/types.ts +27 -0
- package/src/stores/activity-store-helpers.ts +164 -0
- package/src/stores/activity-store-types.ts +55 -0
- package/src/stores/activity-store.ts +21 -217
- 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 (
|
|
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
|
-
|
|
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
|
-
|
|
134
|
+
issue,
|
|
100
135
|
}: {
|
|
101
136
|
show: boolean;
|
|
102
|
-
|
|
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
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
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
|
+
}
|
|
@@ -384,7 +384,10 @@ function NotePicker({
|
|
|
384
384
|
if (!controller.signal.aborted) setLoading(false);
|
|
385
385
|
}
|
|
386
386
|
}, 250);
|
|
387
|
-
return () =>
|
|
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
|
+
}
|