@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.
- package/README.md +12 -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/session-drawer.d.ts.map +1 -1
- package/dist/components/chat/session-drawer.js +25 -10
- package/dist/components/chat/session-drawer.js.map +1 -1
- package/dist/components/chat/tool-views.d.ts +2 -2
- package/dist/components/chat/tool-views.d.ts.map +1 -1
- package/dist/components/chat/tool-views.js +2 -16
- package/dist/components/chat/tool-views.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 +94 -8
- 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/components/layout/app-shell.d.ts.map +1 -1
- package/dist/components/layout/app-shell.js +2 -1
- package/dist/components/layout/app-shell.js.map +1 -1
- package/dist/components/quick-actions/add-modal.d.ts.map +1 -1
- package/dist/components/quick-actions/add-modal.js +47 -4
- package/dist/components/quick-actions/add-modal.js.map +1 -1
- package/dist/components/quick-actions/search-modal.d.ts.map +1 -1
- package/dist/components/quick-actions/search-modal.js +46 -18
- package/dist/components/quick-actions/search-modal.js.map +1 -1
- package/dist/components/quick-actions/streaming-modal.d.ts.map +1 -1
- package/dist/components/quick-actions/streaming-modal.js +4 -1
- package/dist/components/quick-actions/streaming-modal.js.map +1 -1
- package/dist/components/settings/devices-agents-tab.d.ts +5 -0
- package/dist/components/settings/devices-agents-tab.d.ts.map +1 -0
- package/dist/components/settings/devices-agents-tab.js +96 -0
- package/dist/components/settings/devices-agents-tab.js.map +1 -0
- package/dist/components/settings/one-time-agent-credential.d.ts +3 -0
- package/dist/components/settings/one-time-agent-credential.d.ts.map +1 -0
- package/dist/components/settings/one-time-agent-credential.js +55 -0
- package/dist/components/settings/one-time-agent-credential.js.map +1 -0
- package/dist/components/settings/settings-panel.d.ts.map +1 -1
- package/dist/components/settings/settings-panel.js +16 -3
- package/dist/components/settings/settings-panel.js.map +1 -1
- package/dist/hooks/use-vpn-status.d.ts +6 -1
- package/dist/hooks/use-vpn-status.d.ts.map +1 -1
- package/dist/hooks/use-vpn-status.js +67 -30
- 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 +65 -254
- 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/lib/api-client.d.ts +32 -0
- package/dist/lib/api-client.d.ts.map +1 -1
- package/dist/lib/api-client.js +11 -0
- package/dist/lib/api-client.js.map +1 -1
- package/dist/lib/format-time.d.ts +3 -0
- package/dist/lib/format-time.d.ts.map +1 -0
- package/dist/lib/format-time.js +25 -0
- package/dist/lib/format-time.js.map +1 -0
- package/dist/lib/push-registration.d.ts +7 -0
- package/dist/lib/push-registration.d.ts.map +1 -0
- package/dist/lib/push-registration.js +26 -0
- package/dist/lib/push-registration.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/stores/file-store.d.ts.map +1 -1
- package/dist/stores/file-store.js +54 -18
- package/dist/stores/file-store.js.map +1 -1
- package/dist/stores/principal-store.d.ts +16 -0
- package/dist/stores/principal-store.d.ts.map +1 -0
- package/dist/stores/principal-store.js +29 -0
- package/dist/stores/principal-store.js.map +1 -0
- package/dist/stores/ui-store.d.ts +1 -1
- package/dist/stores/ui-store.d.ts.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/session-drawer.tsx +29 -10
- package/src/components/chat/tool-views.tsx +2 -13
- package/src/components/chat/use-markdown-highlight.ts +63 -0
- package/src/components/connectivity/connection-gate.tsx +128 -14
- 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/components/layout/app-shell.tsx +2 -0
- package/src/components/quick-actions/add-modal.tsx +49 -4
- package/src/components/quick-actions/search-modal.tsx +46 -18
- package/src/components/quick-actions/streaming-modal.tsx +3 -1
- package/src/components/settings/devices-agents-tab.tsx +231 -0
- package/src/components/settings/one-time-agent-credential.tsx +131 -0
- package/src/components/settings/settings-panel.tsx +20 -5
- package/src/hooks/use-vpn-status.ts +71 -31
- package/src/hooks/use-websocket.ts +74 -275
- 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/lib/api-client.ts +40 -1
- package/src/lib/format-time.ts +20 -0
- package/src/lib/push-registration.ts +29 -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
- package/src/stores/file-store.ts +42 -12
- package/src/stores/principal-store.ts +38 -0
- 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 (
|
|
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
|
-
|
|
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
|
-
|
|
197
|
+
issue,
|
|
100
198
|
}: {
|
|
101
199
|
show: boolean;
|
|
102
|
-
|
|
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
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
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
|
+
}
|
|
@@ -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
|
+
}
|