mirror of
https://github.com/abhigyanpatwari/GitNexus.git
synced 2026-08-28 05:25:25 +00:00
* feat(web): add graph-load skip decision helper and node threshold (#2178) * feat(web): skip graph download in connectToServer for chat-only mode (#2178) * feat(web): add graphMode state and empty-graph chat-only handling (#2178) * feat(web): read and thread ?skipGraph URL param through connect flow (#2178) * feat(web): chat-only empty state with load-graph-anyway escape hatch (#2178) * style(web): apply prettier formatting to graph-load files (#2178) * fix(review): apply autofix feedback - Fail-safe confirm + authoritative node count (P1: prevent re-triggering the hang via Load-graph-anyway when count unknown) - In-flight guard on loadGraphAnyway (P1: double-fire) - Honor explicit ?skipGraph in onAnalyzeComplete and DropZone (R6/U4) - Extract buildGraphFromConnectResult shared helper (DRY across 3 connect sites) - Add tests: switchRepo skip path, threshold config override, loadGraphAnyway error path, confirm fail-safe, in-flight guard * fix(review): address tri-review findings - P1 (correctness+adversarial+risk): stop the cross-repo / F5 chat-only leak. loadGraphAnyway no longer persists ?skipGraph=0, and onAnalyzeComplete + DropZone no longer inherit a stale ?skipGraph for a different repo — both could bypass auto-detect and re-trigger the #2178 hang. ?skipGraph is now a bookmark hint honored only by the initial auto-connect; in-session repo changes auto-detect. - P2 (performance): auto-detect now also skips on edge count (edge-driven force-layout cliff), not just nodes; LARGE_GRAPH_EDGE_THRESHOLD default 50K. - P2 (julik): reset graphMode/chatOnlyNodeCount at the top of switchRepo so a failed switch can't leave a stale chat-only overlay. - P2 (julik): set serverBaseUrl before awaiting handleServerConnect in auto-connect so the Load-graph-anyway button isn't briefly a no-op. - P2 (risk): hide the misleading '0 nodes / 0 edges' stats in chat-only mode (Header + StatusBar). - P2 (performance): guard the GraphCanvas layout effect against the empty chat-only graph. - Tests: edge-threshold decision + connectToServer edge-trigger; load-anyway no longer asserts URL persistence. * fix(web): make Load-graph-anyway cancellable, unmount-safe, fail-safe confirm (#2178) - AbortController + mountedRef: cancel the in-flight download on unmount and guard every post-await setState by the mounted ref (an abort surfaces as a BackendError, not a DOMException AbortError, so name-checks would miss it) - Stale-result guard: a load-anyway that resolves after a concurrent switchRepo no longer clobbers the new repo's graph/mode/count - GraphCanvas confirm fails SAFE (treat as declined) when window.confirm is unavailable or throws, instead of silently proceeding into a large download * fix(web): make the AI agent and chat surface aware of chat-only mode (#2178) - buildDynamicSystemPrompt + createGraphRAGAgent take a chatOnly flag and append a note (both prompt branches) that supersedes VISUAL GROUNDING: the graph isn't loaded, [[Type:Name]] node citations won't highlight, prefer [[path:START-END]] - initializeAgent resolves chatOnly = opts ?? graphModeRef.current==='chatOnly': connect-flow callers (handleServerConnect, switchRepo, loadGraphAnyway re-init) pass it explicitly; lazy/settings re-inits fall back to live mode via the ref - loadGraphAnyway re-inits the agent (chatOnly:false) after a full load so the prompt drops the note - RightPanel shows a chat-only banner so the degradation is visible where AI output renders (en + zh-CN) * fix(web): streaming circuit breaker for graphs with missing size stats (#2178) - GraphTooLargeError + a mid-stream breaker in parseNdjsonGraphResponse: count nodes/relationships as they arrive and abort (cancel reader in try/finally, then throw) the moment either crosses its limit — reusing the existing node/ edge thresholds, no new magic constant. Throwing right after the offending push means a later error record in the same chunk can't pre-empt it. - fetchGraph gains optional maxNodes/maxEdges (off by default → existing callers unchanged). connectToServer arms them only for auto-detect downloads (skipGraph !== false) and catches GraphTooLargeError → chat-only, re-throwing every other error. This backstops the no-stats fail-open path that could otherwise re-trigger the original hang. * chore(autofix): apply prettier + eslint fixes via /autofix command --------- Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
This commit is contained in:
parent
9ff7337f1e
commit
6dc6544365
24 changed files with 1387 additions and 47 deletions
|
|
@ -10,7 +10,7 @@ import { StatusBar } from './components/StatusBar';
|
|||
import { FileTreePanel } from './components/FileTreePanel';
|
||||
import { CodeReferencesPanel } from './components/CodeReferencesPanel';
|
||||
import { getActiveProviderConfig } from './core/llm/settings-service';
|
||||
import { createKnowledgeGraph } from './core/graph/graph';
|
||||
import { buildGraphFromConnectResult } from './lib/apply-connect-result';
|
||||
import {
|
||||
connectToServer,
|
||||
fetchRepos,
|
||||
|
|
@ -21,6 +21,7 @@ import {
|
|||
type BackendRepo,
|
||||
} from './services/backend-client';
|
||||
import { ERROR_RESET_DELAY_MS } from './config/ui-constants';
|
||||
import { parseSkipGraphParam } from './lib/graph-load-decision';
|
||||
import { formatBackendError } from './i18n/error-messages';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
|
|
@ -30,6 +31,8 @@ const AppContent = () => {
|
|||
viewMode,
|
||||
setViewMode,
|
||||
setGraph,
|
||||
setGraphMode,
|
||||
setChatOnlyNodeCount,
|
||||
setProgress,
|
||||
setProjectName,
|
||||
progress,
|
||||
|
|
@ -66,15 +69,14 @@ const AppContent = () => {
|
|||
setProjectName(projectName);
|
||||
setCurrentRepo(projectName);
|
||||
|
||||
// Build KnowledgeGraph from server data for visualization
|
||||
const graph = createKnowledgeGraph();
|
||||
for (const node of result.nodes) {
|
||||
graph.addNode(node);
|
||||
}
|
||||
for (const rel of result.relationships) {
|
||||
graph.addRelationship(rel);
|
||||
}
|
||||
setGraph(graph);
|
||||
// Build KnowledgeGraph from server data for visualization. In chat-only
|
||||
// mode the graph download was skipped, so the shared builder keeps an
|
||||
// empty (but non-null) graph and flags the mode so the UI shows the
|
||||
// chat-only empty state, with the node count captured for its notice.
|
||||
const built = buildGraphFromConnectResult(result);
|
||||
setGraph(built.graph);
|
||||
setGraphMode(built.graphMode);
|
||||
setChatOnlyNodeCount(built.graphMode === 'chatOnly' ? built.nodeCount : null);
|
||||
|
||||
// Persist the active project in the URL for bookmarkability and F5 refresh resilience
|
||||
const urlObj = new URL(window.location.href);
|
||||
|
|
@ -84,10 +86,11 @@ const AppContent = () => {
|
|||
// Transition directly to exploring view
|
||||
setViewMode('exploring');
|
||||
|
||||
// Initialize agent with backend queries, then start embeddings
|
||||
// Initialize agent with backend queries, then start embeddings. Pass the
|
||||
// chat-only flag so the agent's prompt matches the loaded/skipped graph (#2178).
|
||||
try {
|
||||
if (getActiveProviderConfig()) {
|
||||
await initializeAgent(projectName);
|
||||
await initializeAgent(projectName, { chatOnly: result.graphSkipped });
|
||||
}
|
||||
startEmbeddingsWithFallback();
|
||||
} catch (err) {
|
||||
|
|
@ -97,6 +100,8 @@ const AppContent = () => {
|
|||
[
|
||||
setViewMode,
|
||||
setGraph,
|
||||
setGraphMode,
|
||||
setChatOnlyNodeCount,
|
||||
setProjectName,
|
||||
setCurrentRepo,
|
||||
initializeAgent,
|
||||
|
|
@ -116,6 +121,9 @@ const AppContent = () => {
|
|||
const params = new URLSearchParams(window.location.search);
|
||||
const serverUrlParam = params.get('server');
|
||||
const projectParam = params.get('project');
|
||||
// `?skipGraph=1` forces chat-only, `?skipGraph=0` forces a full graph;
|
||||
// absent → auto-detect by node count. Bookmarkable / survives F5 (#2178).
|
||||
const skipGraphParam = parseSkipGraphParam(params.get('skipGraph'));
|
||||
|
||||
if (!serverUrlParam && !projectParam) return;
|
||||
autoConnectRan.current = true;
|
||||
|
|
@ -162,15 +170,19 @@ const AppContent = () => {
|
|||
},
|
||||
undefined,
|
||||
projectParam || undefined,
|
||||
{ awaitAnalysis: true }, // enable backend hold-queue for repos still being analyzed
|
||||
{ awaitAnalysis: true, skipGraph: skipGraphParam }, // hold-queue + chat-only control (#2178)
|
||||
);
|
||||
};
|
||||
|
||||
tryConnect()
|
||||
.then(async (result) => {
|
||||
// Set serverBaseUrl BEFORE handleServerConnect: the latter transitions
|
||||
// to 'exploring' (rendering the chat-only overlay + its "Load graph
|
||||
// anyway" button) and then awaits agent init, leaving a window where
|
||||
// loadGraphAnyway would silently no-op on a still-null serverBaseUrl.
|
||||
setServerBaseUrl(baseUrl);
|
||||
await handleServerConnect(result);
|
||||
setProgress(null);
|
||||
setServerBaseUrl(baseUrl);
|
||||
fetchRepos()
|
||||
.then((repos) => setAvailableRepos(repos))
|
||||
.catch((e) => console.warn('Failed to fetch repo list:', e));
|
||||
|
|
@ -261,6 +273,9 @@ const AppContent = () => {
|
|||
try {
|
||||
const repos = await fetchRepos();
|
||||
setAvailableRepos(repos);
|
||||
// Auto-detect by size for a freshly-analyzed repo (#2178). A stale
|
||||
// ?skipGraph from a previously-viewed repo must NOT leak in here —
|
||||
// that would bypass the size guard and could re-trigger the hang.
|
||||
const result = await connectToServer(url, undefined, undefined, repoName);
|
||||
await handleServerConnect(result);
|
||||
setServerBaseUrl(normalizeServerUrl(url));
|
||||
|
|
|
|||
|
|
@ -206,6 +206,10 @@ export const DropZone = ({ onServerConnect }: DropZoneProps) => {
|
|||
const abortController = new AbortController();
|
||||
abortControllerRef.current = abortController;
|
||||
try {
|
||||
// Landing-screen repo selection auto-detects by size (#2178). The
|
||||
// ?skipGraph URL param is a bookmark hint for the initial auto-connect
|
||||
// only; honoring a stale value for a different repo here would risk the
|
||||
// hang it is meant to prevent.
|
||||
const result = await connectToServer(
|
||||
detectedBackendUrl,
|
||||
(p, downloaded, total) => {
|
||||
|
|
|
|||
|
|
@ -27,6 +27,8 @@ import type { GraphNode } from 'gitnexus-shared';
|
|||
import { QueryFAB } from './QueryFAB';
|
||||
import Graph from 'graphology';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { LARGE_GRAPH_NODE_THRESHOLD } from '../config/ui-constants';
|
||||
import { shouldConfirmGraphLoad } from '../lib/graph-load-decision';
|
||||
|
||||
export interface GraphCanvasHandle {
|
||||
focusNode: (nodeId: string) => void;
|
||||
|
|
@ -55,6 +57,9 @@ export const GraphCanvas = forwardRef<GraphCanvasHandle>((_, ref) => {
|
|||
animatedNodes,
|
||||
graphViewMode,
|
||||
setGraphViewMode,
|
||||
graphMode,
|
||||
chatOnlyNodeCount,
|
||||
loadGraphAnyway,
|
||||
} = useAppState();
|
||||
const [hoveredNodeName, setHoveredNodeName] = useState<string | null>(null);
|
||||
|
||||
|
|
@ -193,7 +198,10 @@ export const GraphCanvas = forwardRef<GraphCanvasHandle>((_, ref) => {
|
|||
|
||||
// Update Sigma graph when KnowledgeGraph changes
|
||||
useEffect(() => {
|
||||
if (!graph) return;
|
||||
// Skip layout work in chat-only mode: `graph` is non-null but empty, the
|
||||
// overlay covers the canvas, and this guard also future-proofs against a
|
||||
// transient where a populated graph is set while mode is still chat-only.
|
||||
if (!graph || graphMode === 'chatOnly') return;
|
||||
|
||||
let sigmaGraph: Graph<SigmaNodeAttributes, SigmaEdgeAttributes>;
|
||||
|
||||
|
|
@ -218,7 +226,7 @@ export const GraphCanvas = forwardRef<GraphCanvasHandle>((_, ref) => {
|
|||
}
|
||||
|
||||
setSigmaGraph(sigmaGraph);
|
||||
}, [graph, nodeById, setSigmaGraph, graphViewMode]);
|
||||
}, [graph, graphMode, nodeById, setSigmaGraph, graphViewMode]);
|
||||
|
||||
// Update node visibility when filters change
|
||||
useEffect(() => {
|
||||
|
|
@ -256,6 +264,37 @@ export const GraphCanvas = forwardRef<GraphCanvasHandle>((_, ref) => {
|
|||
resetZoom();
|
||||
}, [setSelectedNode, setSigmaSelectedNode, resetZoom]);
|
||||
|
||||
// Chat-only mode (#2178): the graph download was skipped. `chatOnlyNodeCount`
|
||||
// comes from app state (captured at connect time), so it is authoritative and
|
||||
// available immediately — not derived from the async `availableRepos` list.
|
||||
const handleLoadGraphAnyway = useCallback(() => {
|
||||
// Warn before re-triggering a potentially browser-hanging download. Confirm
|
||||
// whenever the count is large OR unknown — never silently re-load a graph we
|
||||
// can't size, which would risk re-introducing the original #2178 hang. Skip
|
||||
// the prompt only when the count is known to be below the threshold (a small
|
||||
// repo force-skipped via ?skipGraph=1).
|
||||
const needsConfirm = shouldConfirmGraphLoad(chatOnlyNodeCount, LARGE_GRAPH_NODE_THRESHOLD);
|
||||
if (needsConfirm) {
|
||||
// Fail SAFE, not open: if there's no usable confirm dialog (some embedded
|
||||
// webviews) or it throws, treat it as declined rather than loading a
|
||||
// graph we couldn't warn about (#2178).
|
||||
const canPrompt = typeof window !== 'undefined' && typeof window.confirm === 'function';
|
||||
if (!canPrompt) return;
|
||||
let confirmed = false;
|
||||
try {
|
||||
confirmed = window.confirm(
|
||||
chatOnlyNodeCount != null
|
||||
? t('canvas.chatOnly.loadAnywayWarning', { count: chatOnlyNodeCount.toLocaleString() })
|
||||
: t('canvas.chatOnly.loadAnywayWarningUnknown'),
|
||||
);
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
if (!confirmed) return;
|
||||
}
|
||||
void loadGraphAnyway();
|
||||
}, [chatOnlyNodeCount, loadGraphAnyway, t]);
|
||||
|
||||
return (
|
||||
<div className="relative h-full w-full bg-void">
|
||||
{/* Background gradient */}
|
||||
|
|
@ -324,6 +363,32 @@ export const GraphCanvas = forwardRef<GraphCanvasHandle>((_, ref) => {
|
|||
className="sigma-container h-full w-full cursor-grab active:cursor-grabbing"
|
||||
/>
|
||||
|
||||
{/* Chat-only empty state (#2178): graph download was skipped for a large
|
||||
project. Chat works normally; offer an explicit "load anyway" escape. */}
|
||||
{graphMode === 'chatOnly' && (
|
||||
<div className="absolute inset-0 z-20 flex items-center justify-center p-6">
|
||||
<div className="max-w-md rounded-xl border border-border-subtle bg-elevated/95 p-6 text-center shadow-lg backdrop-blur-sm">
|
||||
<h3 className="text-lg font-semibold text-text-primary">
|
||||
{t('canvas.chatOnly.title')}
|
||||
</h3>
|
||||
<p className="mt-2 text-sm text-text-secondary">
|
||||
{chatOnlyNodeCount != null
|
||||
? t('canvas.chatOnly.descriptionWithCount', {
|
||||
count: chatOnlyNodeCount.toLocaleString(),
|
||||
})
|
||||
: t('canvas.chatOnly.description')}
|
||||
</p>
|
||||
<p className="mt-2 text-xs text-text-muted">{t('canvas.chatOnly.citationNote')}</p>
|
||||
<button
|
||||
onClick={handleLoadGraphAnyway}
|
||||
className="mt-4 rounded-md border border-accent/30 bg-accent/20 px-4 py-2 text-sm font-medium text-accent transition-colors hover:bg-accent/30"
|
||||
>
|
||||
{t('canvas.chatOnly.loadAnyway')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Hovered node tooltip - only show when NOT selected */}
|
||||
{hoveredNodeName && !sigmaSelectedNode && (
|
||||
<div className="pointer-events-none absolute top-4 left-1/2 z-20 -translate-x-1/2 animate-fade-in rounded-lg border border-border-subtle bg-elevated/95 px-3 py-1.5 backdrop-blur-sm">
|
||||
|
|
|
|||
|
|
@ -63,6 +63,7 @@ export const Header = ({
|
|||
const {
|
||||
projectName,
|
||||
graph,
|
||||
graphMode,
|
||||
openChatPanel,
|
||||
isRightPanelOpen,
|
||||
rightPanelTab,
|
||||
|
|
@ -467,8 +468,9 @@ export const Header = ({
|
|||
<span className="hidden sm:inline">✨</span>
|
||||
</a>
|
||||
|
||||
{/* Stats */}
|
||||
{graph && (
|
||||
{/* Stats — hidden in chat-only mode, where the empty-but-non-null graph
|
||||
would otherwise show a misleading "0 nodes / 0 edges" (#2178). */}
|
||||
{graph && graphMode !== 'chatOnly' && (
|
||||
<div className="mr-2 flex items-center gap-4 text-xs text-text-muted">
|
||||
<span>{t('common:counts.nodes', { count: nodeCount })}</span>
|
||||
<span>{t('common:counts.edges', { count: edgeCount })}</span>
|
||||
|
|
|
|||
|
|
@ -23,6 +23,7 @@ export const RightPanel = () => {
|
|||
isRightPanelOpen,
|
||||
setRightPanelOpen,
|
||||
graph,
|
||||
graphMode,
|
||||
addCodeReference,
|
||||
// LLM / chat state
|
||||
chatMessages,
|
||||
|
|
@ -283,6 +284,14 @@ export const RightPanel = () => {
|
|||
</div>
|
||||
</div>
|
||||
|
||||
{/* Chat-only notice: the graph wasn't loaded for this large project, so
|
||||
inline node citations won't pin in the (absent) graph view (#2178). */}
|
||||
{graphMode === 'chatOnly' && (
|
||||
<div className="border-b border-amber-500/20 bg-amber-500/10 px-4 py-2 text-[11px] text-amber-200/90">
|
||||
{t('chat:chatOnly.banner')}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Status / errors */}
|
||||
{agentError && (
|
||||
<div className="flex items-center gap-2 border-b border-rose-500/30 bg-rose-500/10 px-4 py-3 text-sm text-rose-100">
|
||||
|
|
|
|||
|
|
@ -5,7 +5,7 @@ import { useTranslation } from 'react-i18next';
|
|||
import { translateProgressMessage } from '../i18n/progress';
|
||||
|
||||
export const StatusBar = () => {
|
||||
const { graph, progress } = useAppState();
|
||||
const { graph, graphMode, progress } = useAppState();
|
||||
const { t } = useTranslation(['common', 'graph']);
|
||||
|
||||
const nodeCount = graph?.nodes.length ?? 0;
|
||||
|
|
@ -68,7 +68,9 @@ export const StatusBar = () => {
|
|||
|
||||
{/* Right - Stats */}
|
||||
<div className="flex items-center gap-3" data-testid="graph-stats">
|
||||
{graph && (
|
||||
{/* Suppress counts in chat-only mode: the empty-but-non-null graph would
|
||||
otherwise show a misleading "0 nodes / 0 edges" for a large repo (#2178). */}
|
||||
{graph && graphMode !== 'chatOnly' && (
|
||||
<>
|
||||
<span>{t('common:counts.nodes', { count: nodeCount })}</span>
|
||||
<span className="text-border-default">•</span>
|
||||
|
|
|
|||
|
|
@ -8,6 +8,40 @@ export const DEFAULT_BACKEND_URL =
|
|||
export const DEFAULT_OLLAMA_BASE_URL = 'http://localhost:11434';
|
||||
export const DEFAULT_OPENROUTER_BASE_URL = 'https://openrouter.ai/api/v1';
|
||||
|
||||
/**
|
||||
* Default node-count above which the WebUI connects in chat-only mode (skips
|
||||
* the full graph download). Grounded in sigma.js/graphology prior art: ~10K
|
||||
* nodes render smoothly, complex-styled rendering struggles past ~5K, and the
|
||||
* force-layout degrades beyond ~50K edges. GitNexus renders labeled nodes with
|
||||
* force layout and has ~1.7x more edges than nodes, so the edge cliff is crossed
|
||||
* around ~25-30K nodes. Override at deploy time via
|
||||
* window.__GITNEXUS_CONFIG__.largeGraphNodeThreshold. See issue #2178.
|
||||
*/
|
||||
const DEFAULT_LARGE_GRAPH_NODE_THRESHOLD = 25_000;
|
||||
|
||||
/**
|
||||
* Default edge-count above which the WebUI connects in chat-only mode. The
|
||||
* browser force-layout cliff is edge-driven (degrades beyond ~50K edges), and
|
||||
* GitNexus graphs carry more edges than nodes, so an edge-heavy but node-light
|
||||
* repo can still hang even when under the node threshold. Override via
|
||||
* window.__GITNEXUS_CONFIG__.largeGraphEdgeThreshold. See issue #2178.
|
||||
*/
|
||||
const DEFAULT_LARGE_GRAPH_EDGE_THRESHOLD = 50_000;
|
||||
|
||||
const resolveThreshold = (override: number | undefined, fallback: number): number =>
|
||||
// Ignore non-finite, NaN, or non-positive overrides — fall back to the default.
|
||||
typeof override === 'number' && Number.isFinite(override) && override > 0 ? override : fallback;
|
||||
|
||||
export const LARGE_GRAPH_NODE_THRESHOLD = resolveThreshold(
|
||||
typeof window !== 'undefined' ? window.__GITNEXUS_CONFIG__?.largeGraphNodeThreshold : undefined,
|
||||
DEFAULT_LARGE_GRAPH_NODE_THRESHOLD,
|
||||
);
|
||||
|
||||
export const LARGE_GRAPH_EDGE_THRESHOLD = resolveThreshold(
|
||||
typeof window !== 'undefined' ? window.__GITNEXUS_CONFIG__?.largeGraphEdgeThreshold : undefined,
|
||||
DEFAULT_LARGE_GRAPH_EDGE_THRESHOLD,
|
||||
);
|
||||
|
||||
/** Minimum Node.js version required by the gitnexus CLI (injected by Vite from package.json engines). */
|
||||
declare const __REQUIRED_NODE_VERSION__: string;
|
||||
export const REQUIRED_NODE_VERSION = __REQUIRED_NODE_VERSION__;
|
||||
|
|
|
|||
|
|
@ -33,7 +33,11 @@ import type {
|
|||
AgentStreamChunk,
|
||||
AgentHistoryMessage,
|
||||
} from './types';
|
||||
import { type CodebaseContext, buildDynamicSystemPrompt } from './context-builder';
|
||||
import {
|
||||
type CodebaseContext,
|
||||
buildDynamicSystemPrompt,
|
||||
CHAT_ONLY_PROMPT_NOTE,
|
||||
} from './context-builder';
|
||||
import { DEFAULT_OLLAMA_BASE_URL, DEFAULT_OPENROUTER_BASE_URL } from '../../config/ui-constants';
|
||||
import {
|
||||
DeepSeekChatOpenAI,
|
||||
|
|
@ -357,14 +361,20 @@ export const createGraphRAGAgent = (
|
|||
config: ProviderConfig,
|
||||
backend: GraphRAGBackend,
|
||||
codebaseContext?: CodebaseContext,
|
||||
chatOnly = false,
|
||||
) => {
|
||||
const model = createChatModel(config);
|
||||
const tools = createGraphRAGTools(backend);
|
||||
|
||||
// Use dynamic prompt if context is provided, otherwise use base prompt
|
||||
// Use dynamic prompt if context is provided, otherwise use base prompt. The
|
||||
// chat-only note (graph not loaded, #2178) must apply in BOTH branches — when
|
||||
// codebaseContext is absent, buildDynamicSystemPrompt is never called, so
|
||||
// append the note here too.
|
||||
const systemPrompt = codebaseContext
|
||||
? buildDynamicSystemPrompt(BASE_SYSTEM_PROMPT, codebaseContext)
|
||||
: BASE_SYSTEM_PROMPT;
|
||||
? buildDynamicSystemPrompt(BASE_SYSTEM_PROMPT, codebaseContext, chatOnly)
|
||||
: chatOnly
|
||||
? `${BASE_SYSTEM_PROMPT}${CHAT_ONLY_PROMPT_NOTE}`
|
||||
: BASE_SYSTEM_PROMPT;
|
||||
|
||||
// Log the full prompt for debugging
|
||||
if (import.meta.env.DEV) {
|
||||
|
|
|
|||
|
|
@ -413,7 +413,27 @@ export function formatContextForPrompt(context: CodebaseContext): string {
|
|||
* Build the complete dynamic system prompt
|
||||
* Context is appended at the END so core instructions remain at the top
|
||||
*/
|
||||
export function buildDynamicSystemPrompt(basePrompt: string, context: CodebaseContext): string {
|
||||
/**
|
||||
* Note appended in chat-only mode (graph download skipped for a large project,
|
||||
* #2178). It supersedes the static VISUAL GROUNDING section in BASE_SYSTEM_PROMPT
|
||||
* so the agent stops claiming the user sees a graph or that node citations
|
||||
* highlight — neither is true when the in-memory graph is empty.
|
||||
*/
|
||||
export const CHAT_ONLY_PROMPT_NOTE = `
|
||||
|
||||
---
|
||||
|
||||
## ⚠️ CHAT-ONLY MODE (graph not loaded)
|
||||
The knowledge graph is NOT loaded in the UI for this project (it was too large to render). This OVERRIDES the VISUAL GROUNDING section above:
|
||||
- \`[[Type:Name]]\` node citations will NOT highlight anything — avoid relying on them.
|
||||
- Prefer \`[[path:START-END]]\` file citations, which still resolve and open the file.
|
||||
- All your tools (search, cypher, grep, read) work normally against the backend; only the visual graph is absent.`;
|
||||
|
||||
export function buildDynamicSystemPrompt(
|
||||
basePrompt: string,
|
||||
context: CodebaseContext,
|
||||
chatOnly = false,
|
||||
): string {
|
||||
const contextSection = formatContextForPrompt(context);
|
||||
|
||||
// Append context at the END - keeps core instructions at top for better adherence
|
||||
|
|
@ -422,5 +442,5 @@ export function buildDynamicSystemPrompt(basePrompt: string, context: CodebaseCo
|
|||
---
|
||||
|
||||
## 📦 CURRENT CODEBASE
|
||||
${contextSection}`;
|
||||
${contextSection}${chatOnly ? CHAT_ONLY_PROMPT_NOTE : ''}`;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -19,4 +19,21 @@ describe('GraphState', () => {
|
|||
});
|
||||
expect(result.current.graphViewMode).toBe('tree');
|
||||
});
|
||||
|
||||
it('should default graphMode to "full"', () => {
|
||||
const { result } = renderHook(() => useGraphState(), { wrapper });
|
||||
expect(result.current.graphMode).toBe('full');
|
||||
});
|
||||
|
||||
it('should switch graphMode to "chatOnly" and back', () => {
|
||||
const { result } = renderHook(() => useGraphState(), { wrapper });
|
||||
act(() => {
|
||||
result.current.setGraphMode('chatOnly');
|
||||
});
|
||||
expect(result.current.graphMode).toBe('chatOnly');
|
||||
act(() => {
|
||||
result.current.setGraphMode('full');
|
||||
});
|
||||
expect(result.current.graphMode).toBe('full');
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -2,6 +2,9 @@ import { createContext, useContext, useCallback, useMemo, useState, ReactNode }
|
|||
import type { GraphNode, NodeLabel } from 'gitnexus-shared';
|
||||
import type { KnowledgeGraph } from '../../core/graph/types';
|
||||
import { DEFAULT_VISIBLE_LABELS, DEFAULT_VISIBLE_EDGES, type EdgeType } from '../../lib/constants';
|
||||
import type { GraphMode } from '../../lib/apply-connect-result';
|
||||
|
||||
export type { GraphMode };
|
||||
|
||||
interface GraphStateContextValue {
|
||||
graph: KnowledgeGraph | null;
|
||||
|
|
@ -18,6 +21,22 @@ interface GraphStateContextValue {
|
|||
setHighlightedNodeIds: (ids: Set<string>) => void;
|
||||
graphViewMode: 'force' | 'tree' | 'circles';
|
||||
setGraphViewMode: (mode: 'force' | 'tree' | 'circles') => void;
|
||||
/**
|
||||
* Whether the in-memory graph was downloaded ('full') or skipped for a large
|
||||
* project ('chatOnly'). In chat-only mode `graph` is an empty-but-non-null
|
||||
* KnowledgeGraph so existing `graph?.` consumers keep working; this flag is
|
||||
* the explicit signal that drives the chat-only empty-state UI. See #2178.
|
||||
*/
|
||||
graphMode: GraphMode;
|
||||
setGraphMode: (mode: GraphMode) => void;
|
||||
/**
|
||||
* Node count of the connected repo when in chat-only mode (from the connect
|
||||
* result's repo stats), or null when unknown. Used to size and gate the
|
||||
* chat-only empty-state notice and its "load anyway" warning without waiting
|
||||
* on the async `availableRepos` list. See #2178.
|
||||
*/
|
||||
chatOnlyNodeCount: number | null;
|
||||
setChatOnlyNodeCount: (count: number | null) => void;
|
||||
}
|
||||
|
||||
const GraphStateContext = createContext<GraphStateContextValue | null>(null);
|
||||
|
|
@ -30,6 +49,8 @@ export const GraphStateProvider = ({ children }: { children: ReactNode }) => {
|
|||
const [depthFilter, setDepthFilter] = useState<number | null>(null);
|
||||
const [highlightedNodeIds, setHighlightedNodeIds] = useState<Set<string>>(new Set());
|
||||
const [graphViewMode, setGraphViewMode] = useState<'force' | 'tree' | 'circles'>('force');
|
||||
const [graphMode, setGraphMode] = useState<GraphMode>('full');
|
||||
const [chatOnlyNodeCount, setChatOnlyNodeCount] = useState<number | null>(null);
|
||||
|
||||
const toggleLabelVisibility = useCallback((label: NodeLabel) => {
|
||||
setVisibleLabels((prev) =>
|
||||
|
|
@ -59,6 +80,10 @@ export const GraphStateProvider = ({ children }: { children: ReactNode }) => {
|
|||
setHighlightedNodeIds,
|
||||
graphViewMode,
|
||||
setGraphViewMode,
|
||||
graphMode,
|
||||
setGraphMode,
|
||||
chatOnlyNodeCount,
|
||||
setChatOnlyNodeCount,
|
||||
}),
|
||||
[
|
||||
graph,
|
||||
|
|
@ -68,6 +93,8 @@ export const GraphStateProvider = ({ children }: { children: ReactNode }) => {
|
|||
depthFilter,
|
||||
highlightedNodeIds,
|
||||
graphViewMode,
|
||||
graphMode,
|
||||
chatOnlyNodeCount,
|
||||
],
|
||||
);
|
||||
|
||||
|
|
|
|||
|
|
@ -10,7 +10,7 @@ import {
|
|||
} from 'react';
|
||||
import type { GraphNode, NodeLabel, PipelineProgress } from 'gitnexus-shared';
|
||||
import type { KnowledgeGraph } from '../core/graph/types';
|
||||
import { createKnowledgeGraph } from '../core/graph/graph';
|
||||
import { buildGraphFromConnectResult } from '../lib/apply-connect-result';
|
||||
import type {
|
||||
LLMSettings,
|
||||
AgentStreamChunk,
|
||||
|
|
@ -43,7 +43,7 @@ import { ERROR_RESET_DELAY_MS } from '../config/ui-constants';
|
|||
import i18n from '../i18n';
|
||||
import { normalizePath } from '../lib/path-resolution';
|
||||
import { FILE_REF_REGEX, NODE_REF_REGEX } from '../lib/grounding-patterns';
|
||||
import { GraphStateProvider, useGraphState } from './app-state/graph';
|
||||
import { GraphStateProvider, useGraphState, type GraphMode } from './app-state/graph';
|
||||
|
||||
export const AUTO_START_EMBEDDINGS_STORAGE_KEY = 'gitnexus.autoStartEmbeddings';
|
||||
|
||||
|
|
@ -127,6 +127,13 @@ interface AppState {
|
|||
graphViewMode: 'force' | 'tree' | 'circles';
|
||||
setGraphViewMode: (mode: 'force' | 'tree' | 'circles') => void;
|
||||
|
||||
// Graph load mode (full download vs chat-only / skipped graph)
|
||||
graphMode: GraphMode;
|
||||
setGraphMode: (mode: GraphMode) => void;
|
||||
// Connected repo's node count while in chat-only mode (null when unknown)
|
||||
chatOnlyNodeCount: number | null;
|
||||
setChatOnlyNodeCount: (count: number | null) => void;
|
||||
|
||||
// Query state
|
||||
highlightedNodeIds: Set<string>;
|
||||
setHighlightedNodeIds: (ids: Set<string>) => void;
|
||||
|
|
@ -163,6 +170,8 @@ interface AppState {
|
|||
setAvailableRepos: (repos: BackendRepo[]) => void;
|
||||
switchRepo: (repoName: string) => Promise<void>;
|
||||
setCurrentRepo: (repoName: string) => void;
|
||||
/** Download the full graph for the current repo after a chat-only connect (#2178). */
|
||||
loadGraphAnyway: () => Promise<void>;
|
||||
|
||||
// Worker API (shared across app)
|
||||
runQuery: (cypher: string) => Promise<any[]>;
|
||||
|
|
@ -195,7 +204,7 @@ interface AppState {
|
|||
|
||||
// LLM methods
|
||||
refreshLLMSettings: () => void;
|
||||
initializeAgent: (overrideProjectName?: string) => Promise<void>;
|
||||
initializeAgent: (overrideProjectName?: string, opts?: { chatOnly?: boolean }) => Promise<void>;
|
||||
sendChatMessage: (message: string) => Promise<void>;
|
||||
stopChatResponse: () => void;
|
||||
clearChat: () => void;
|
||||
|
|
@ -238,6 +247,10 @@ const AppStateProviderInner = ({ children }: { children: ReactNode }) => {
|
|||
setHighlightedNodeIds,
|
||||
graphViewMode,
|
||||
setGraphViewMode,
|
||||
graphMode,
|
||||
setGraphMode,
|
||||
chatOnlyNodeCount,
|
||||
setChatOnlyNodeCount,
|
||||
} = useGraphState();
|
||||
|
||||
// Right Panel
|
||||
|
|
@ -591,13 +604,24 @@ const AppStateProviderInner = ({ children }: { children: ReactNode }) => {
|
|||
const chatAbortRef = useRef<AbortController | null>(null);
|
||||
const chatStateRef = useRef<'idle' | 'streaming' | 'aborting'>('idle');
|
||||
|
||||
// Mirror graphMode into a ref so initializeAgent's deferred callers (lazy chat
|
||||
// init, settings-driven re-init) can read the current mode without re-creating
|
||||
// the callback; connect-flow callers pass an explicit chatOnly flag. (#2178)
|
||||
const graphModeRef = useRef(graphMode);
|
||||
useEffect(() => {
|
||||
graphModeRef.current = graphMode;
|
||||
}, [graphMode]);
|
||||
|
||||
const initializeAgent = useCallback(
|
||||
async (overrideProjectName?: string): Promise<void> => {
|
||||
async (overrideProjectName?: string, opts?: { chatOnly?: boolean }): Promise<void> => {
|
||||
const config = getActiveProviderConfig();
|
||||
if (!config) {
|
||||
setAgentError('Please configure an LLM provider in settings');
|
||||
return;
|
||||
}
|
||||
// Explicit flag from connect-flow callers (race-safe); otherwise fall back
|
||||
// to live mode via the ref so deferred callers stay correct too. (#2178)
|
||||
const chatOnly = opts?.chatOnly ?? graphModeRef.current === 'chatOnly';
|
||||
|
||||
setIsAgentInitializing(true);
|
||||
setAgentError(null);
|
||||
|
|
@ -628,7 +652,7 @@ const AppStateProviderInner = ({ children }: { children: ReactNode }) => {
|
|||
backendReadFile(filePath, { repo }).then((r) => r.content),
|
||||
};
|
||||
|
||||
agentRef.current = createGraphRAGAgent(config, backend, codebaseContext);
|
||||
agentRef.current = createGraphRAGAgent(config, backend, codebaseContext, chatOnly);
|
||||
setIsAgentReady(true);
|
||||
setAgentError(null);
|
||||
if (import.meta.env.DEV) {
|
||||
|
|
@ -1153,9 +1177,15 @@ const AppStateProviderInner = ({ children }: { children: ReactNode }) => {
|
|||
setCodeReferences([]);
|
||||
setCodePanelOpen(false);
|
||||
setCodeReferenceFocus(null);
|
||||
// Reset graph-load mode up front so a FAILED switch can't leave the
|
||||
// previous repo's stale chat-only overlay showing (#2178). The success
|
||||
// path re-derives the mode from the connect result below.
|
||||
setGraphMode('full');
|
||||
setChatOnlyNodeCount(null);
|
||||
|
||||
let connectedRepo: BackendRepo | undefined;
|
||||
let pNameStr = repoName || 'server-project';
|
||||
let connectedChatOnly = false;
|
||||
|
||||
try {
|
||||
const result: ConnectResult = await connectToServer(
|
||||
|
|
@ -1205,10 +1235,14 @@ const AppStateProviderInner = ({ children }: { children: ReactNode }) => {
|
|||
connectedRepo = result.repoInfo;
|
||||
pNameStr = pName;
|
||||
|
||||
const newGraph = createKnowledgeGraph();
|
||||
for (const node of result.nodes) newGraph.addNode(node);
|
||||
for (const rel of result.relationships) newGraph.addRelationship(rel);
|
||||
setGraph(newGraph);
|
||||
// In chat-only mode the graph download was skipped; the shared builder
|
||||
// keeps an empty (but non-null) graph so existing `graph?.` consumers
|
||||
// stay happy, and reports the mode + node count in lockstep.
|
||||
const built = buildGraphFromConnectResult(result);
|
||||
setGraph(built.graph);
|
||||
setGraphMode(built.graphMode);
|
||||
setChatOnlyNodeCount(built.graphMode === 'chatOnly' ? built.nodeCount : null);
|
||||
connectedChatOnly = built.graphMode === 'chatOnly';
|
||||
} catch (err: unknown) {
|
||||
console.error('Repo switch failed:', err);
|
||||
setProgress({
|
||||
|
|
@ -1227,9 +1261,13 @@ const AppStateProviderInner = ({ children }: { children: ReactNode }) => {
|
|||
}
|
||||
|
||||
if (pNameStr) {
|
||||
// Persist the selected project in the URL so a refresh re-opens it
|
||||
// Persist the selected project in the URL so a refresh re-opens it.
|
||||
// Drop any `?skipGraph` override: a deliberate repo switch should make a
|
||||
// fresh per-repo decision (auto-detect) on the next refresh rather than
|
||||
// carry the previous repo's forced mode (#2178).
|
||||
const urlObj = new URL(window.location.href);
|
||||
urlObj.searchParams.set('project', pNameStr);
|
||||
urlObj.searchParams.delete('skipGraph');
|
||||
window.history.replaceState(null, '', urlObj.toString());
|
||||
}
|
||||
|
||||
|
|
@ -1241,7 +1279,7 @@ const AppStateProviderInner = ({ children }: { children: ReactNode }) => {
|
|||
// Re-initialize agent with the new repo's graph context
|
||||
try {
|
||||
if (getActiveProviderConfig()) {
|
||||
await initializeAgent(pNameStr);
|
||||
await initializeAgent(pNameStr, { chatOnly: connectedChatOnly });
|
||||
}
|
||||
setViewMode('exploring');
|
||||
startEmbeddingsWithFallback();
|
||||
|
|
@ -1261,6 +1299,8 @@ const AppStateProviderInner = ({ children }: { children: ReactNode }) => {
|
|||
setViewMode,
|
||||
setProjectName,
|
||||
setGraph,
|
||||
setGraphMode,
|
||||
setChatOnlyNodeCount,
|
||||
initializeAgent,
|
||||
startEmbeddingsWithFallback,
|
||||
setHighlightedNodeIds,
|
||||
|
|
@ -1276,6 +1316,102 @@ const AppStateProviderInner = ({ children }: { children: ReactNode }) => {
|
|||
],
|
||||
);
|
||||
|
||||
// Load the full graph for the current repo after a chat-only connection.
|
||||
// This is the escape hatch behind the chat-only empty state (#2178). It
|
||||
// forces `skipGraph: false` so the size-based auto-detect cannot re-skip it.
|
||||
// The override is session-scoped (deliberately NOT persisted to the URL): a
|
||||
// persisted `?skipGraph=0` would leak onto a different repo via the other
|
||||
// connect entry points and could silently re-trigger the hang on refresh.
|
||||
const loadGraphInFlightRef = useRef(false);
|
||||
// Cancels the in-flight load-anyway download; mountedRef gates post-await
|
||||
// state writes so an unmount mid-download can't setState on a dead instance.
|
||||
const loadGraphAbortRef = useRef<AbortController | null>(null);
|
||||
const loadGraphMountedRef = useRef(true);
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
loadGraphMountedRef.current = false;
|
||||
loadGraphAbortRef.current?.abort();
|
||||
};
|
||||
}, []);
|
||||
const loadGraphAnyway = useCallback(async (): Promise<void> => {
|
||||
if (!serverBaseUrl) return;
|
||||
// Guard against a double-trigger (rapid double-click or a racing
|
||||
// programmatic call) starting two concurrent full-graph downloads.
|
||||
if (loadGraphInFlightRef.current) return;
|
||||
loadGraphInFlightRef.current = true;
|
||||
const repo = repoRef.current;
|
||||
const controller = new AbortController();
|
||||
loadGraphAbortRef.current = controller;
|
||||
|
||||
setProgress({
|
||||
phase: 'extracting',
|
||||
percent: 0,
|
||||
message: i18n.t('common:progress.downloadingGraph'),
|
||||
detail: i18n.t('common:progress.validating'),
|
||||
});
|
||||
setViewMode('loading');
|
||||
|
||||
try {
|
||||
const result = await connectToServer(
|
||||
serverBaseUrl,
|
||||
(phase, downloaded, total) => {
|
||||
if (phase === 'downloading') {
|
||||
const pct = total ? Math.round((downloaded / total) * 90) + 5 : 50;
|
||||
const mb = (downloaded / (1024 * 1024)).toFixed(1);
|
||||
setProgress({
|
||||
phase: 'extracting',
|
||||
percent: pct,
|
||||
message: i18n.t('common:progress.downloadingGraph'),
|
||||
detail: i18n.t('common:progress.downloadedMb', { mb }),
|
||||
});
|
||||
}
|
||||
},
|
||||
controller.signal,
|
||||
repo,
|
||||
{ awaitAnalysis: true, skipGraph: false },
|
||||
);
|
||||
|
||||
// Bail if we unmounted, or if a concurrent switchRepo changed the active
|
||||
// repo while this load was in flight (the late result must not clobber the
|
||||
// new repo's state). Guard keyed on the ref — an abort surfaces as a
|
||||
// BackendError, not a DOMException AbortError.
|
||||
if (!loadGraphMountedRef.current || repoRef.current !== repo) return;
|
||||
|
||||
const built = buildGraphFromConnectResult(result);
|
||||
setGraph(built.graph);
|
||||
setGraphMode(built.graphMode);
|
||||
// Full download succeeded → leave chat-only mode; clear the cached count.
|
||||
setChatOnlyNodeCount(built.graphMode === 'chatOnly' ? built.nodeCount : null);
|
||||
|
||||
setProgress(null);
|
||||
setViewMode('exploring');
|
||||
|
||||
// The graph is now loaded — re-init the agent so its system prompt drops
|
||||
// the chat-only note (#2178, KTD2). Guarded on a configured provider, like
|
||||
// switchRepo; runs inside the mounted/stale guard above.
|
||||
if (getActiveProviderConfig()) {
|
||||
await initializeAgent(repo, { chatOnly: false });
|
||||
}
|
||||
} catch (err) {
|
||||
if (!loadGraphMountedRef.current || repoRef.current !== repo) return;
|
||||
console.error('Load graph anyway failed:', err);
|
||||
// Stay in chat-only mode (the overlay reappears) and return to the view.
|
||||
setProgress(null);
|
||||
setViewMode('exploring');
|
||||
} finally {
|
||||
if (loadGraphAbortRef.current === controller) loadGraphAbortRef.current = null;
|
||||
loadGraphInFlightRef.current = false;
|
||||
}
|
||||
}, [
|
||||
serverBaseUrl,
|
||||
setProgress,
|
||||
setViewMode,
|
||||
setGraph,
|
||||
setGraphMode,
|
||||
setChatOnlyNodeCount,
|
||||
initializeAgent,
|
||||
]);
|
||||
|
||||
const removeCodeReference = useCallback(
|
||||
(id: string) => {
|
||||
setCodeReferences((prev) => {
|
||||
|
|
@ -1334,6 +1470,10 @@ const AppStateProviderInner = ({ children }: { children: ReactNode }) => {
|
|||
setDepthFilter,
|
||||
graphViewMode,
|
||||
setGraphViewMode,
|
||||
graphMode,
|
||||
setGraphMode,
|
||||
chatOnlyNodeCount,
|
||||
setChatOnlyNodeCount,
|
||||
highlightedNodeIds,
|
||||
setHighlightedNodeIds,
|
||||
aiCitationHighlightedNodeIds,
|
||||
|
|
@ -1362,6 +1502,7 @@ const AppStateProviderInner = ({ children }: { children: ReactNode }) => {
|
|||
setAvailableRepos,
|
||||
switchRepo,
|
||||
setCurrentRepo,
|
||||
loadGraphAnyway,
|
||||
runQuery,
|
||||
isDatabaseReady,
|
||||
// Embedding state and methods
|
||||
|
|
|
|||
46
gitnexus-web/src/lib/apply-connect-result.ts
Normal file
46
gitnexus-web/src/lib/apply-connect-result.ts
Normal file
|
|
@ -0,0 +1,46 @@
|
|||
import type { ConnectResult } from '../services/backend-client';
|
||||
import type { KnowledgeGraph } from '../core/graph/types';
|
||||
import { createKnowledgeGraph } from '../core/graph/graph';
|
||||
|
||||
/**
|
||||
* Whether the in-memory graph was downloaded ('full') or skipped for a large
|
||||
* project ('chatOnly'). Defined here (not in the graph state slice) so the
|
||||
* shared connect-result builder and the state slice agree on one source. In
|
||||
* chat-only mode `graph` is an empty-but-non-null KnowledgeGraph so existing
|
||||
* `graph?.` consumers keep working; the flag drives the chat-only UI. See #2178.
|
||||
*/
|
||||
export type GraphMode = 'full' | 'chatOnly';
|
||||
|
||||
export interface BuiltGraph {
|
||||
graph: KnowledgeGraph;
|
||||
graphMode: GraphMode;
|
||||
/**
|
||||
* Node count for the connected repo (from `repoInfo.stats.nodes`), or null
|
||||
* when the backend did not report it. Captured here at connect time so the
|
||||
* chat-only notice and its size warning have an authoritative value that does
|
||||
* not depend on the async `availableRepos` list having loaded yet.
|
||||
*/
|
||||
nodeCount: number | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the in-memory KnowledgeGraph from a connect result and derive the
|
||||
* graph mode + node count. In chat-only mode (`graphSkipped`) the node/relation
|
||||
* loops are skipped, leaving an empty-but-non-null graph.
|
||||
*
|
||||
* Shared by every connect entry point — App.handleServerConnect, switchRepo,
|
||||
* and loadGraphAnyway — so the build, the mode flag, and the node count stay in
|
||||
* lockstep instead of drifting across three near-identical copies. See #2178.
|
||||
*/
|
||||
export function buildGraphFromConnectResult(result: ConnectResult): BuiltGraph {
|
||||
const graph = createKnowledgeGraph();
|
||||
if (!result.graphSkipped) {
|
||||
for (const node of result.nodes) graph.addNode(node);
|
||||
for (const rel of result.relationships) graph.addRelationship(rel);
|
||||
}
|
||||
return {
|
||||
graph,
|
||||
graphMode: result.graphSkipped ? 'chatOnly' : 'full',
|
||||
nodeCount: result.repoInfo.stats?.nodes ?? null,
|
||||
};
|
||||
}
|
||||
84
gitnexus-web/src/lib/graph-load-decision.ts
Normal file
84
gitnexus-web/src/lib/graph-load-decision.ts
Normal file
|
|
@ -0,0 +1,84 @@
|
|||
/**
|
||||
* Pure decision logic for the WebUI's chat-only / skip-graph connection mode
|
||||
* (issue #2178). Kept free of React and network concerns so it can be unit
|
||||
* tested directly and reused at every connect entry point.
|
||||
*
|
||||
* The WebUI hangs on very large projects because the connect flow downloads the
|
||||
* entire knowledge graph into memory. The AI chat does not need that graph (it
|
||||
* calls the backend HTTP API directly), so we skip the download when the user
|
||||
* asked for chat-only mode or when the project is large enough to auto-detect.
|
||||
*/
|
||||
|
||||
export interface SkipGraphDecisionInput {
|
||||
/**
|
||||
* Explicit user/URL choice, if any. `true` forces chat-only, `false` forces a
|
||||
* full graph download, `undefined` defers to auto-detection by size.
|
||||
*/
|
||||
explicit: boolean | undefined;
|
||||
/** Node count reported by the backend (`repoInfo.stats.nodes`), if known. */
|
||||
nodeCount: number | null | undefined;
|
||||
/** Node auto-detect threshold (LARGE_GRAPH_NODE_THRESHOLD). */
|
||||
threshold: number;
|
||||
/** Edge count reported by the backend (`repoInfo.stats.edges`), if known. */
|
||||
edgeCount?: number | null | undefined;
|
||||
/** Edge auto-detect threshold (LARGE_GRAPH_EDGE_THRESHOLD). */
|
||||
edgeThreshold?: number;
|
||||
}
|
||||
|
||||
const isOver = (count: number | null | undefined, threshold: number | undefined): boolean =>
|
||||
typeof threshold === 'number' &&
|
||||
typeof count === 'number' &&
|
||||
Number.isFinite(count) &&
|
||||
count > threshold;
|
||||
|
||||
/**
|
||||
* Decide whether to skip the graph download.
|
||||
*
|
||||
* - An explicit boolean choice always wins (override in both directions).
|
||||
* - Otherwise auto-detect: skip when EITHER the node count OR the edge count is
|
||||
* known and strictly greater than its threshold. Edges matter because the
|
||||
* browser force-layout cliff is edge-driven and GitNexus graphs carry more
|
||||
* edges than nodes — an edge-heavy but node-light repo can still hang.
|
||||
* - Missing/unknown counts fail open to a full download (we never skip purely
|
||||
* because we couldn't read the size).
|
||||
*/
|
||||
export function decideSkipGraph({
|
||||
explicit,
|
||||
nodeCount,
|
||||
threshold,
|
||||
edgeCount,
|
||||
edgeThreshold,
|
||||
}: SkipGraphDecisionInput): boolean {
|
||||
if (typeof explicit === 'boolean') return explicit;
|
||||
return isOver(nodeCount, threshold) || isOver(edgeCount, edgeThreshold);
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether to prompt for confirmation before loading the full graph from the
|
||||
* chat-only escape hatch ("Load graph anyway"). Confirm whenever the node count
|
||||
* is large OR unknown — never silently re-load a graph we cannot size, which
|
||||
* would risk re-introducing the original browser hang (#2178). Skip the prompt
|
||||
* only when the count is known to be at or below the threshold (a small repo
|
||||
* that was force-skipped via `?skipGraph=1`).
|
||||
*/
|
||||
export function shouldConfirmGraphLoad(
|
||||
nodeCount: number | null | undefined,
|
||||
threshold: number,
|
||||
): boolean {
|
||||
if (typeof nodeCount !== 'number' || !Number.isFinite(nodeCount)) return true;
|
||||
return nodeCount > threshold;
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse the `?skipGraph` URL parameter into the tri-state used by
|
||||
* {@link decideSkipGraph}. Accepts `1`/`true` (chat-only) and `0`/`false`
|
||||
* (full graph), case-insensitively. Anything else — including a missing
|
||||
* parameter — yields `undefined` (auto-detect).
|
||||
*/
|
||||
export function parseSkipGraphParam(value: string | null | undefined): boolean | undefined {
|
||||
if (value == null) return undefined;
|
||||
const normalized = value.trim().toLowerCase();
|
||||
if (normalized === '1' || normalized === 'true') return true;
|
||||
if (normalized === '0' || normalized === 'false') return false;
|
||||
return undefined;
|
||||
}
|
||||
|
|
@ -29,6 +29,9 @@
|
|||
"configureAI": "Configure AI",
|
||||
"connecting": "Connecting"
|
||||
},
|
||||
"chatOnly": {
|
||||
"banner": "Graph not loaded (large project). Chat works normally; inline node citations won't highlight in the graph view."
|
||||
},
|
||||
"roles": {
|
||||
"you": "You",
|
||||
"assistant": "Nexus AI"
|
||||
|
|
|
|||
|
|
@ -129,7 +129,16 @@
|
|||
"runLayout": "Run Layout Again",
|
||||
"layoutOptimizing": "Layout optimizing...",
|
||||
"turnOffHighlights": "Turn off all highlights",
|
||||
"turnOnHighlights": "Turn on AI highlights"
|
||||
"turnOnHighlights": "Turn on AI highlights",
|
||||
"chatOnly": {
|
||||
"title": "Graph not loaded",
|
||||
"description": "This is a large project, so the graph was skipped to keep the browser responsive. AI chat works normally.",
|
||||
"descriptionWithCount": "This project has {{count}} nodes, so the graph was skipped to keep the browser responsive. AI chat works normally.",
|
||||
"citationNote": "While the graph is unloaded, inline file citations from chat won't auto-open in the Code panel.",
|
||||
"loadAnyway": "Load graph anyway",
|
||||
"loadAnywayWarning": "This project has {{count}} nodes. Loading the full graph may make the browser slow or unresponsive. Continue?",
|
||||
"loadAnywayWarningUnknown": "This may be a large project. Loading the full graph may make the browser slow or unresponsive. Continue?"
|
||||
}
|
||||
},
|
||||
"processes": {
|
||||
"unknownStep": "Unknown",
|
||||
|
|
|
|||
|
|
@ -29,6 +29,9 @@
|
|||
"configureAI": "配置 AI",
|
||||
"connecting": "连接中"
|
||||
},
|
||||
"chatOnly": {
|
||||
"banner": "图谱未加载(大型项目)。对话功能正常;内联节点引用不会在图谱视图中高亮。"
|
||||
},
|
||||
"roles": {
|
||||
"you": "你",
|
||||
"assistant": "Nexus AI"
|
||||
|
|
|
|||
|
|
@ -129,7 +129,16 @@
|
|||
"runLayout": "重新运行布局",
|
||||
"layoutOptimizing": "正在优化布局...",
|
||||
"turnOffHighlights": "关闭全部高亮",
|
||||
"turnOnHighlights": "开启 AI 高亮"
|
||||
"turnOnHighlights": "开启 AI 高亮",
|
||||
"chatOnly": {
|
||||
"title": "图谱未加载",
|
||||
"description": "这是一个大型项目,已跳过图谱加载以保持浏览器响应。AI 对话功能正常可用。",
|
||||
"descriptionWithCount": "该项目包含 {{count}} 个节点,已跳过图谱加载以保持浏览器响应。AI 对话功能正常可用。",
|
||||
"citationNote": "图谱未加载时,对话中的内联文件引用不会自动在代码面板中打开。",
|
||||
"loadAnyway": "仍然加载图谱",
|
||||
"loadAnywayWarning": "该项目包含 {{count}} 个节点。加载完整图谱可能导致浏览器变慢或无响应。是否继续?",
|
||||
"loadAnywayWarningUnknown": "这可能是一个大型项目。加载完整图谱可能导致浏览器变慢或无响应。是否继续?"
|
||||
}
|
||||
},
|
||||
"processes": {
|
||||
"unknownStep": "未知",
|
||||
|
|
|
|||
|
|
@ -8,6 +8,8 @@
|
|||
|
||||
import type { GraphNode, GraphRelationship } from 'gitnexus-shared';
|
||||
import { CircuitOpenError, ResilientFetchExhaustedError, resilientFetch } from 'gitnexus-shared';
|
||||
import { LARGE_GRAPH_NODE_THRESHOLD, LARGE_GRAPH_EDGE_THRESHOLD } from '../config/ui-constants';
|
||||
import { decideSkipGraph } from '../lib/graph-load-decision';
|
||||
|
||||
// ── Types ──────────────────────────────────────────────────────────────────
|
||||
|
||||
|
|
@ -96,6 +98,24 @@ export class BackendError extends Error {
|
|||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Thrown by the graph stream parser when the streamed node/relationship count
|
||||
* crosses the size limit mid-download (#2178). It is the backstop for the case
|
||||
* pre-fetch stats can't cover (absent/stale `stats.nodes`/`stats.edges` on a
|
||||
* genuinely large repo). `connectToServer` catches it and falls into chat-only
|
||||
* mode instead of letting the full graph hang the browser.
|
||||
*/
|
||||
export class GraphTooLargeError extends Error {
|
||||
constructor(
|
||||
message: string,
|
||||
public readonly nodeCount: number,
|
||||
public readonly relationshipCount: number,
|
||||
) {
|
||||
super(message);
|
||||
this.name = 'GraphTooLargeError';
|
||||
}
|
||||
}
|
||||
|
||||
// ── SSE Utility ────────────────────────────────────────────────────────────
|
||||
|
||||
export interface SSEHandlers<T = unknown> {
|
||||
|
|
@ -539,13 +559,18 @@ export const fetchRepoInfo = async (
|
|||
return { ...data, repoPath: data.repoPath ?? data.path };
|
||||
};
|
||||
|
||||
/** Fetch the graph (nodes + relationships). Content stripped by default. */
|
||||
/** Fetch the graph (nodes + relationships). Content stripped by default.
|
||||
* `maxNodes`/`maxEdges` arm a streaming circuit breaker (#2178): if the streamed
|
||||
* count crosses either limit, the download aborts with a GraphTooLargeError
|
||||
* instead of materializing a graph that would hang the browser. Off by default. */
|
||||
export const fetchGraph = async (
|
||||
repo?: string,
|
||||
opts?: {
|
||||
includeContent?: boolean;
|
||||
signal?: AbortSignal;
|
||||
onProgress?: (downloaded: number, total: number | null) => void;
|
||||
maxNodes?: number;
|
||||
maxEdges?: number;
|
||||
},
|
||||
): Promise<{ nodes: GraphNode[]; relationships: GraphRelationship[] }> => {
|
||||
const params = [repoParam(repo), opts?.includeContent ? 'includeContent=true' : '', 'stream=true']
|
||||
|
|
@ -558,7 +583,7 @@ export const fetchGraph = async (
|
|||
|
||||
const contentType = response.headers.get('Content-Type') || '';
|
||||
if (contentType.includes('application/x-ndjson')) {
|
||||
return parseNdjsonGraphResponse(response, opts?.onProgress);
|
||||
return parseNdjsonGraphResponse(response, opts?.onProgress, opts?.maxNodes, opts?.maxEdges);
|
||||
}
|
||||
|
||||
if (!opts?.onProgress || !response.body) {
|
||||
|
|
@ -592,6 +617,8 @@ export const fetchGraph = async (
|
|||
const parseNdjsonGraphResponse = async (
|
||||
response: Response,
|
||||
onProgress?: (downloaded: number, total: number | null) => void,
|
||||
maxNodes?: number,
|
||||
maxEdges?: number,
|
||||
): Promise<{ nodes: GraphNode[]; relationships: GraphRelationship[] }> => {
|
||||
if (!response.body) {
|
||||
throw new BackendError('No response body', response.status, 'server');
|
||||
|
|
@ -606,6 +633,14 @@ const parseNdjsonGraphResponse = async (
|
|||
let buffer = '';
|
||||
let downloaded = 0;
|
||||
|
||||
// Streaming circuit breaker (#2178): enforce the size limits mid-download as a
|
||||
// backstop when pre-fetch stats were missing. Same `> threshold` comparison as
|
||||
// decideSkipGraph. Throwing immediately after the offending push means a later
|
||||
// error record in the same chunk is never reached — the breaker wins.
|
||||
const overLimit = (): boolean =>
|
||||
(typeof maxNodes === 'number' && nodes.length > maxNodes) ||
|
||||
(typeof maxEdges === 'number' && relationships.length > maxEdges);
|
||||
|
||||
const parseLine = (line: string) => {
|
||||
const trimmed = line.trim();
|
||||
if (!trimmed) return;
|
||||
|
|
@ -628,6 +663,20 @@ const parseNdjsonGraphResponse = async (
|
|||
}
|
||||
};
|
||||
|
||||
const tripBreaker = async () => {
|
||||
// Free the socket promptly; never let a cancel rejection mask the breaker.
|
||||
try {
|
||||
await reader.cancel();
|
||||
} catch {
|
||||
// ignore — we're aborting anyway
|
||||
}
|
||||
throw new GraphTooLargeError(
|
||||
`Graph exceeds the size limit (nodes=${nodes.length}, relationships=${relationships.length})`,
|
||||
nodes.length,
|
||||
relationships.length,
|
||||
);
|
||||
};
|
||||
|
||||
while (true) {
|
||||
const { done, value } = await reader.read();
|
||||
if (done) break;
|
||||
|
|
@ -640,11 +689,13 @@ const parseNdjsonGraphResponse = async (
|
|||
buffer = lines.pop() || '';
|
||||
for (const line of lines) {
|
||||
parseLine(line);
|
||||
if (overLimit()) await tripBreaker();
|
||||
}
|
||||
}
|
||||
|
||||
buffer += decoder.decode();
|
||||
parseLine(buffer);
|
||||
if (overLimit()) await tripBreaker();
|
||||
|
||||
return { nodes, relationships };
|
||||
};
|
||||
|
|
@ -904,6 +955,14 @@ export interface ConnectResult {
|
|||
nodes: GraphNode[];
|
||||
relationships: GraphRelationship[];
|
||||
repoInfo: BackendRepo;
|
||||
/**
|
||||
* True when the graph download was skipped (chat-only mode) — either because
|
||||
* the caller asked for it or because the project exceeded the auto-detect
|
||||
* node threshold. When true, `nodes`/`relationships` are empty and graph
|
||||
* visualization is unavailable, but AI chat and all backend-API features
|
||||
* work normally. See issue #2178.
|
||||
*/
|
||||
graphSkipped: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -911,13 +970,15 @@ export interface ConnectResult {
|
|||
* Content is NOT included (use readFile/grep for file access).
|
||||
* Pass `awaitAnalysis: true` when the repo may still be cloning/analyzing —
|
||||
* this enables the backend hold-queue and a 5-minute fetch timeout.
|
||||
* Pass `skipGraph: true`/`false` to force chat-only / full-graph mode; omit it
|
||||
* to auto-detect from the project's node count (LARGE_GRAPH_NODE_THRESHOLD).
|
||||
*/
|
||||
export async function connectToServer(
|
||||
url: string,
|
||||
onProgress?: (phase: string, downloaded: number, total: number | null) => void,
|
||||
signal?: AbortSignal,
|
||||
repoName?: string,
|
||||
opts?: { awaitAnalysis?: boolean },
|
||||
opts?: { awaitAnalysis?: boolean; skipGraph?: boolean },
|
||||
): Promise<ConnectResult> {
|
||||
const baseUrl = normalizeServerUrl(url);
|
||||
setBackendUrl(baseUrl);
|
||||
|
|
@ -925,11 +986,45 @@ export async function connectToServer(
|
|||
onProgress?.('validating', 0, null);
|
||||
const repoInfo = await fetchRepoInfo(repoName, { awaitAnalysis: opts?.awaitAnalysis });
|
||||
|
||||
onProgress?.('downloading', 0, null);
|
||||
const { nodes, relationships } = await fetchGraph(repoName, {
|
||||
signal,
|
||||
onProgress: (downloaded, total) => onProgress?.('downloading', downloaded, total),
|
||||
// Decide whether to skip the (potentially huge) graph download. The AI chat
|
||||
// talks to the backend HTTP API directly and does not need the in-memory
|
||||
// graph, so for large projects — or when the caller explicitly asked for
|
||||
// chat-only mode — we connect instantly without materializing the graph.
|
||||
// repoInfo is already fetched above, so the node-count check costs no extra
|
||||
// round-trip. See issue #2178.
|
||||
const skipGraph = decideSkipGraph({
|
||||
explicit: opts?.skipGraph,
|
||||
nodeCount: repoInfo.stats?.nodes,
|
||||
threshold: LARGE_GRAPH_NODE_THRESHOLD,
|
||||
edgeCount: repoInfo.stats?.edges,
|
||||
edgeThreshold: LARGE_GRAPH_EDGE_THRESHOLD,
|
||||
});
|
||||
|
||||
return { nodes, relationships, repoInfo };
|
||||
if (skipGraph) {
|
||||
return { nodes: [], relationships: [], repoInfo, graphSkipped: true };
|
||||
}
|
||||
|
||||
// Arm the streaming circuit breaker for auto-detect downloads as a backstop
|
||||
// for the no-stats fail-open case (#2178). An explicit "load anyway"
|
||||
// (skipGraph === false) opts out — the user has accepted the cost.
|
||||
const enforceLimits = opts?.skipGraph !== false;
|
||||
|
||||
onProgress?.('downloading', 0, null);
|
||||
try {
|
||||
const { nodes, relationships } = await fetchGraph(repoName, {
|
||||
signal,
|
||||
onProgress: (downloaded, total) => onProgress?.('downloading', downloaded, total),
|
||||
maxNodes: enforceLimits ? LARGE_GRAPH_NODE_THRESHOLD : undefined,
|
||||
maxEdges: enforceLimits ? LARGE_GRAPH_EDGE_THRESHOLD : undefined,
|
||||
});
|
||||
return { nodes, relationships, repoInfo, graphSkipped: false };
|
||||
} catch (err) {
|
||||
// The breaker tripped mid-stream → fall into chat-only, the same result the
|
||||
// pre-fetch skip path produces. Re-throw every other error (genuine
|
||||
// BackendErrors must still surface to the caller's catch).
|
||||
if (err instanceof GraphTooLargeError) {
|
||||
return { nodes: [], relationships: [], repoInfo, graphSkipped: true };
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
14
gitnexus-web/src/vite-env.d.ts
vendored
14
gitnexus-web/src/vite-env.d.ts
vendored
|
|
@ -3,5 +3,19 @@
|
|||
interface Window {
|
||||
__GITNEXUS_CONFIG__?: {
|
||||
backendUrl?: string;
|
||||
/**
|
||||
* Node-count above which the WebUI connects in chat-only mode by default
|
||||
* (skips the full graph download to avoid hanging the browser on very
|
||||
* large projects). Override at deploy time; falls back to
|
||||
* LARGE_GRAPH_NODE_THRESHOLD in config/ui-constants.ts. See issue #2178.
|
||||
*/
|
||||
largeGraphNodeThreshold?: number;
|
||||
/**
|
||||
* Edge-count above which the WebUI connects in chat-only mode by default.
|
||||
* The browser force-layout cliff is edge-driven, so this guards edge-heavy
|
||||
* repos that fall under the node threshold. Falls back to
|
||||
* LARGE_GRAPH_EDGE_THRESHOLD in config/ui-constants.ts. See issue #2178.
|
||||
*/
|
||||
largeGraphEdgeThreshold?: number;
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
import { describe, expect, it } from 'vitest';
|
||||
import { BASE_SYSTEM_PROMPT } from '../../src/core/llm/agent';
|
||||
import { buildDynamicSystemPrompt, type CodebaseContext } from '../../src/core/llm/context-builder';
|
||||
import {
|
||||
createGraphRAGTools,
|
||||
GRAPH_RAG_TOOL_NAMES,
|
||||
|
|
@ -7,6 +8,19 @@ import {
|
|||
} from '../../src/core/llm/tools';
|
||||
import { NODE_REF_REGEX } from '../../src/lib/grounding-patterns';
|
||||
|
||||
const MINIMAL_CONTEXT: CodebaseContext = {
|
||||
stats: {
|
||||
projectName: 'proj',
|
||||
fileCount: 0,
|
||||
functionCount: 0,
|
||||
classCount: 0,
|
||||
interfaceCount: 0,
|
||||
methodCount: 0,
|
||||
},
|
||||
hotspots: [],
|
||||
folderTree: '',
|
||||
};
|
||||
|
||||
/** Legacy or phantom tool names that must not appear in the system prompt. */
|
||||
const FORBIDDEN_TOOL_NAMES = [
|
||||
'hybrid_search',
|
||||
|
|
@ -80,3 +94,19 @@ describe('BASE_SYSTEM_PROMPT tool parity', () => {
|
|||
expect(BASE_SYSTEM_PROMPT).not.toMatch(/\b(?:use|call|invoke)\s+`?highlight_in_graph/i);
|
||||
});
|
||||
});
|
||||
|
||||
describe('buildDynamicSystemPrompt chat-only mode (#2178)', () => {
|
||||
it('appends a chat-only note that overrides VISUAL GROUNDING when chatOnly', () => {
|
||||
const prompt = buildDynamicSystemPrompt(BASE_SYSTEM_PROMPT, MINIMAL_CONTEXT, true);
|
||||
expect(prompt).toContain('CHAT-ONLY MODE');
|
||||
expect(prompt).toMatch(/node citations will NOT highlight/i);
|
||||
expect(prompt).toContain('[[path:START-END]]');
|
||||
});
|
||||
|
||||
it('leaves the prompt unchanged when chatOnly is false/omitted', () => {
|
||||
const full = buildDynamicSystemPrompt(BASE_SYSTEM_PROMPT, MINIMAL_CONTEXT);
|
||||
const explicitFalse = buildDynamicSystemPrompt(BASE_SYSTEM_PROMPT, MINIMAL_CONTEXT, false);
|
||||
expect(full).toBe(explicitFalse);
|
||||
expect(full).not.toContain('CHAT-ONLY MODE');
|
||||
});
|
||||
});
|
||||
|
|
|
|||
145
gitnexus-web/test/unit/graph-load-decision.test.ts
Normal file
145
gitnexus-web/test/unit/graph-load-decision.test.ts
Normal file
|
|
@ -0,0 +1,145 @@
|
|||
import { describe, expect, it } from 'vitest';
|
||||
import {
|
||||
decideSkipGraph,
|
||||
parseSkipGraphParam,
|
||||
shouldConfirmGraphLoad,
|
||||
} from '../../src/lib/graph-load-decision';
|
||||
|
||||
const THRESHOLD = 25_000;
|
||||
const EDGE_THRESHOLD = 50_000;
|
||||
|
||||
describe('decideSkipGraph', () => {
|
||||
it('auto-detects: skips when node count exceeds the threshold', () => {
|
||||
expect(decideSkipGraph({ explicit: undefined, nodeCount: 300_000, threshold: THRESHOLD })).toBe(
|
||||
true,
|
||||
);
|
||||
});
|
||||
|
||||
it('auto-detects: keeps the full graph for small projects', () => {
|
||||
expect(decideSkipGraph({ explicit: undefined, nodeCount: 500, threshold: THRESHOLD })).toBe(
|
||||
false,
|
||||
);
|
||||
});
|
||||
|
||||
it('explicit choice overrides auto-detection in both directions', () => {
|
||||
// Force chat-only even for a tiny repo.
|
||||
expect(decideSkipGraph({ explicit: true, nodeCount: 10, threshold: THRESHOLD })).toBe(true);
|
||||
// Force a full graph even for a huge repo.
|
||||
expect(decideSkipGraph({ explicit: false, nodeCount: 999_999, threshold: THRESHOLD })).toBe(
|
||||
false,
|
||||
);
|
||||
});
|
||||
|
||||
it('uses strictly-greater comparison at the threshold boundary', () => {
|
||||
expect(
|
||||
decideSkipGraph({ explicit: undefined, nodeCount: THRESHOLD, threshold: THRESHOLD }),
|
||||
).toBe(false);
|
||||
expect(
|
||||
decideSkipGraph({ explicit: undefined, nodeCount: THRESHOLD + 1, threshold: THRESHOLD }),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it('fails open to a full download when the node count is unknown', () => {
|
||||
expect(
|
||||
decideSkipGraph({ explicit: undefined, nodeCount: undefined, threshold: THRESHOLD }),
|
||||
).toBe(false);
|
||||
expect(decideSkipGraph({ explicit: undefined, nodeCount: null, threshold: THRESHOLD })).toBe(
|
||||
false,
|
||||
);
|
||||
expect(decideSkipGraph({ explicit: undefined, nodeCount: NaN, threshold: THRESHOLD })).toBe(
|
||||
false,
|
||||
);
|
||||
});
|
||||
|
||||
it('skips on the edge count even when nodes are under the node threshold', () => {
|
||||
// Edge-heavy, node-light repo: 20K nodes (< 25K) but 80K edges (> 50K).
|
||||
expect(
|
||||
decideSkipGraph({
|
||||
explicit: undefined,
|
||||
nodeCount: 20_000,
|
||||
threshold: THRESHOLD,
|
||||
edgeCount: 80_000,
|
||||
edgeThreshold: EDGE_THRESHOLD,
|
||||
}),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it('does not skip when both node and edge counts are under their thresholds', () => {
|
||||
expect(
|
||||
decideSkipGraph({
|
||||
explicit: undefined,
|
||||
nodeCount: 5_000,
|
||||
threshold: THRESHOLD,
|
||||
edgeCount: 10_000,
|
||||
edgeThreshold: EDGE_THRESHOLD,
|
||||
}),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it('explicit choice overrides the edge auto-detect too', () => {
|
||||
expect(
|
||||
decideSkipGraph({
|
||||
explicit: false,
|
||||
nodeCount: 1,
|
||||
threshold: THRESHOLD,
|
||||
edgeCount: 999_999,
|
||||
edgeThreshold: EDGE_THRESHOLD,
|
||||
}),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it('fails open when edge count is unknown and nodes are under threshold', () => {
|
||||
expect(
|
||||
decideSkipGraph({
|
||||
explicit: undefined,
|
||||
nodeCount: 5_000,
|
||||
threshold: THRESHOLD,
|
||||
edgeCount: undefined,
|
||||
edgeThreshold: EDGE_THRESHOLD,
|
||||
}),
|
||||
).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('parseSkipGraphParam', () => {
|
||||
it('parses affirmative values to true', () => {
|
||||
expect(parseSkipGraphParam('1')).toBe(true);
|
||||
expect(parseSkipGraphParam('true')).toBe(true);
|
||||
expect(parseSkipGraphParam('TRUE')).toBe(true);
|
||||
expect(parseSkipGraphParam(' true ')).toBe(true);
|
||||
});
|
||||
|
||||
it('parses negative values to false', () => {
|
||||
expect(parseSkipGraphParam('0')).toBe(false);
|
||||
expect(parseSkipGraphParam('false')).toBe(false);
|
||||
expect(parseSkipGraphParam('False')).toBe(false);
|
||||
});
|
||||
|
||||
it('returns undefined for missing or unrecognized values', () => {
|
||||
expect(parseSkipGraphParam(null)).toBeUndefined();
|
||||
expect(parseSkipGraphParam(undefined)).toBeUndefined();
|
||||
expect(parseSkipGraphParam('')).toBeUndefined();
|
||||
expect(parseSkipGraphParam('yes')).toBeUndefined();
|
||||
expect(parseSkipGraphParam('2')).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('shouldConfirmGraphLoad', () => {
|
||||
it('confirms for a large repo', () => {
|
||||
expect(shouldConfirmGraphLoad(300_000, THRESHOLD)).toBe(true);
|
||||
expect(shouldConfirmGraphLoad(THRESHOLD + 1, THRESHOLD)).toBe(true);
|
||||
});
|
||||
|
||||
it('does NOT confirm for a small repo at or below the threshold', () => {
|
||||
expect(shouldConfirmGraphLoad(500, THRESHOLD)).toBe(false);
|
||||
expect(shouldConfirmGraphLoad(THRESHOLD, THRESHOLD)).toBe(false);
|
||||
});
|
||||
|
||||
it('confirms (fail-safe) when the node count is unknown', () => {
|
||||
// The key regression guard: an unknown count must NOT silently re-load,
|
||||
// which would risk re-introducing the #2178 hang.
|
||||
expect(shouldConfirmGraphLoad(null, THRESHOLD)).toBe(true);
|
||||
expect(shouldConfirmGraphLoad(undefined, THRESHOLD)).toBe(true);
|
||||
expect(shouldConfirmGraphLoad(NaN, THRESHOLD)).toBe(true);
|
||||
});
|
||||
});
|
||||
242
gitnexus-web/test/unit/load-graph-anyway.test.tsx
Normal file
242
gitnexus-web/test/unit/load-graph-anyway.test.tsx
Normal file
|
|
@ -0,0 +1,242 @@
|
|||
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||
import { renderHook, act } from '@testing-library/react';
|
||||
import { AppStateProvider, useAppState } from '../../src/hooks/useAppState';
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
// Reset the URL mutated by loadGraphAnyway's persistence.
|
||||
window.history.replaceState(null, '', '/');
|
||||
});
|
||||
|
||||
const repoInfoResponse = () =>
|
||||
new Response(
|
||||
JSON.stringify({
|
||||
name: 'big-repo',
|
||||
path: '/r/big-repo',
|
||||
repoPath: '/r/big-repo',
|
||||
indexedAt: '2026-06-13T00:00:00Z',
|
||||
stats: { nodes: 300_000, edges: 600_000 },
|
||||
}),
|
||||
{ status: 200, headers: { 'Content-Type': 'application/json' } },
|
||||
);
|
||||
|
||||
const graphNdjsonResponse = () => {
|
||||
const body =
|
||||
'{"type":"node","data":{"id":"File:a.ts","label":"File","properties":{"name":"a.ts","filePath":"a.ts"}}}\n' +
|
||||
'{"type":"relationship","data":{"id":"r1","type":"CONTAINS","sourceId":"File:a.ts","targetId":"File:a.ts"}}\n';
|
||||
return new Response(body, {
|
||||
status: 200,
|
||||
headers: { 'Content-Type': 'application/x-ndjson' },
|
||||
});
|
||||
};
|
||||
|
||||
describe('loadGraphAnyway (chat-only escape hatch, #2178)', () => {
|
||||
it('forces a full graph download and flips graphMode back to full', async () => {
|
||||
const fetchMock = vi.fn((url: string) => {
|
||||
if (url.includes('/api/repo')) return Promise.resolve(repoInfoResponse());
|
||||
if (url.includes('/api/graph')) return Promise.resolve(graphNdjsonResponse());
|
||||
return Promise.resolve(
|
||||
new Response('{}', { status: 200, headers: { 'Content-Type': 'application/json' } }),
|
||||
);
|
||||
});
|
||||
vi.stubGlobal('fetch', fetchMock);
|
||||
|
||||
const { result } = renderHook(() => useAppState(), { wrapper: AppStateProvider });
|
||||
|
||||
act(() => {
|
||||
result.current.setServerBaseUrl('http://localhost:4747');
|
||||
result.current.setCurrentRepo('big-repo');
|
||||
result.current.setGraphMode('chatOnly');
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
await result.current.loadGraphAnyway();
|
||||
});
|
||||
|
||||
// Despite the 300K node count, skipGraph:false forces the download.
|
||||
expect(result.current.graphMode).toBe('full');
|
||||
expect(result.current.graph?.nodeCount).toBe(1);
|
||||
const graphCalls = fetchMock.mock.calls.filter(([u]) => String(u).includes('/api/graph'));
|
||||
expect(graphCalls.length).toBeGreaterThan(0);
|
||||
// The override is session-scoped — deliberately NOT persisted to the URL, so
|
||||
// it cannot leak onto a different repo or re-trigger the hang on F5 (#2178).
|
||||
expect(window.location.search).not.toContain('skipGraph');
|
||||
});
|
||||
|
||||
it('no-ops when there is no server connection', async () => {
|
||||
const fetchMock = vi.fn();
|
||||
vi.stubGlobal('fetch', fetchMock);
|
||||
|
||||
const { result } = renderHook(() => useAppState(), { wrapper: AppStateProvider });
|
||||
|
||||
await act(async () => {
|
||||
await result.current.loadGraphAnyway();
|
||||
});
|
||||
|
||||
expect(fetchMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('guards against a concurrent double-invocation (only one download)', async () => {
|
||||
const fetchMock = vi.fn((url: string) => {
|
||||
if (url.includes('/api/repo')) return Promise.resolve(repoInfoResponse());
|
||||
if (url.includes('/api/graph')) return Promise.resolve(graphNdjsonResponse());
|
||||
return Promise.resolve(
|
||||
new Response('{}', { status: 200, headers: { 'Content-Type': 'application/json' } }),
|
||||
);
|
||||
});
|
||||
vi.stubGlobal('fetch', fetchMock);
|
||||
|
||||
const { result } = renderHook(() => useAppState(), { wrapper: AppStateProvider });
|
||||
act(() => {
|
||||
result.current.setServerBaseUrl('http://localhost:4747');
|
||||
result.current.setCurrentRepo('big-repo');
|
||||
result.current.setGraphMode('chatOnly');
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
// Fire twice synchronously — the second call must be dropped by the guard.
|
||||
const a = result.current.loadGraphAnyway();
|
||||
const b = result.current.loadGraphAnyway();
|
||||
await Promise.all([a, b]);
|
||||
});
|
||||
|
||||
const graphCalls = fetchMock.mock.calls.filter(([u]) => String(u).includes('/api/graph'));
|
||||
expect(graphCalls).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('stays in chat-only mode when the full-graph download fails', async () => {
|
||||
const fetchMock = vi.fn((url: string) => {
|
||||
if (url.includes('/api/repo')) return Promise.resolve(repoInfoResponse());
|
||||
if (url.includes('/api/graph'))
|
||||
return Promise.resolve(new Response('{"error":"boom"}', { status: 500 }));
|
||||
return Promise.resolve(
|
||||
new Response('{}', { status: 200, headers: { 'Content-Type': 'application/json' } }),
|
||||
);
|
||||
});
|
||||
vi.stubGlobal('fetch', fetchMock);
|
||||
|
||||
const { result } = renderHook(() => useAppState(), { wrapper: AppStateProvider });
|
||||
act(() => {
|
||||
result.current.setServerBaseUrl('http://localhost:4747');
|
||||
result.current.setCurrentRepo('big-repo');
|
||||
result.current.setGraphMode('chatOnly');
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
await result.current.loadGraphAnyway();
|
||||
});
|
||||
|
||||
// Failure leaves the user in chat-only mode (overlay reappears), view restored.
|
||||
expect(result.current.graphMode).toBe('chatOnly');
|
||||
expect(result.current.viewMode).toBe('exploring');
|
||||
expect(window.location.search).not.toContain('skipGraph=0');
|
||||
});
|
||||
|
||||
it('discards a stale result when the active repo changed mid-load', async () => {
|
||||
let resolveGraph: (r: Response) => void = () => {};
|
||||
const graphPromise = new Promise<Response>((res) => {
|
||||
resolveGraph = res;
|
||||
});
|
||||
const fetchMock = vi.fn((url: string) => {
|
||||
if (url.includes('/api/repo')) return Promise.resolve(repoInfoResponse());
|
||||
if (url.includes('/api/graph')) return graphPromise;
|
||||
return Promise.resolve(
|
||||
new Response('{}', { status: 200, headers: { 'Content-Type': 'application/json' } }),
|
||||
);
|
||||
});
|
||||
vi.stubGlobal('fetch', fetchMock);
|
||||
|
||||
const { result } = renderHook(() => useAppState(), { wrapper: AppStateProvider });
|
||||
act(() => {
|
||||
result.current.setServerBaseUrl('http://localhost:4747');
|
||||
result.current.setCurrentRepo('repo-A');
|
||||
result.current.setGraphMode('chatOnly');
|
||||
});
|
||||
|
||||
let loadPromise: Promise<void> = Promise.resolve();
|
||||
act(() => {
|
||||
loadPromise = result.current.loadGraphAnyway(); // captures repo-A
|
||||
});
|
||||
// A concurrent switch changes the active repo while the load is in flight.
|
||||
act(() => {
|
||||
result.current.setCurrentRepo('repo-B');
|
||||
});
|
||||
await act(async () => {
|
||||
resolveGraph(graphNdjsonResponse());
|
||||
await loadPromise;
|
||||
});
|
||||
|
||||
// The stale repo-A result must NOT flip the (now repo-B) view to full.
|
||||
expect(result.current.graphMode).toBe('chatOnly');
|
||||
});
|
||||
|
||||
it('does not throw or apply state when unmounted mid-load', async () => {
|
||||
let resolveGraph: (r: Response) => void = () => {};
|
||||
const graphPromise = new Promise<Response>((res) => {
|
||||
resolveGraph = res;
|
||||
});
|
||||
const fetchMock = vi.fn((url: string) => {
|
||||
if (url.includes('/api/repo')) return Promise.resolve(repoInfoResponse());
|
||||
if (url.includes('/api/graph')) return graphPromise;
|
||||
return Promise.resolve(
|
||||
new Response('{}', { status: 200, headers: { 'Content-Type': 'application/json' } }),
|
||||
);
|
||||
});
|
||||
vi.stubGlobal('fetch', fetchMock);
|
||||
|
||||
const { result, unmount } = renderHook(() => useAppState(), { wrapper: AppStateProvider });
|
||||
act(() => {
|
||||
result.current.setServerBaseUrl('http://localhost:4747');
|
||||
result.current.setCurrentRepo('big-repo');
|
||||
result.current.setGraphMode('chatOnly');
|
||||
});
|
||||
|
||||
let loadPromise: Promise<void> = Promise.resolve();
|
||||
act(() => {
|
||||
loadPromise = result.current.loadGraphAnyway();
|
||||
});
|
||||
unmount(); // fires cleanup: mountedRef=false + abort
|
||||
await act(async () => {
|
||||
resolveGraph(graphNdjsonResponse());
|
||||
await loadPromise; // resolves without setState-after-unmount throwing
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('switchRepo auto-detect (chat-only, #2178)', () => {
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
window.history.replaceState(null, '', '/');
|
||||
});
|
||||
|
||||
it('enters chat-only mode and captures the node count for a large repo', async () => {
|
||||
const fetchMock = vi.fn((url: string) => {
|
||||
if (url.includes('/api/repo')) return Promise.resolve(repoInfoResponse());
|
||||
if (url.includes('/api/repos'))
|
||||
return Promise.resolve(
|
||||
new Response('[]', { status: 200, headers: { 'Content-Type': 'application/json' } }),
|
||||
);
|
||||
if (url.includes('/api/graph')) return Promise.resolve(graphNdjsonResponse());
|
||||
return Promise.resolve(
|
||||
new Response('{}', { status: 200, headers: { 'Content-Type': 'application/json' } }),
|
||||
);
|
||||
});
|
||||
vi.stubGlobal('fetch', fetchMock);
|
||||
|
||||
const { result } = renderHook(() => useAppState(), { wrapper: AppStateProvider });
|
||||
act(() => {
|
||||
result.current.setServerBaseUrl('http://localhost:4747');
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
await result.current.switchRepo('big-repo');
|
||||
});
|
||||
|
||||
// 300K nodes > threshold → auto-skip, empty graph, count captured, no graph download.
|
||||
expect(result.current.graphMode).toBe('chatOnly');
|
||||
expect(result.current.graph?.nodeCount).toBe(0);
|
||||
expect(result.current.chatOnlyNodeCount).toBe(300_000);
|
||||
const graphCalls = fetchMock.mock.calls.filter(([u]) => String(u).includes('/api/graph'));
|
||||
expect(graphCalls).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
|
|
@ -1,12 +1,34 @@
|
|||
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||
import {
|
||||
connectToServer,
|
||||
fetchGraph,
|
||||
getBackendUrl,
|
||||
GraphTooLargeError,
|
||||
normalizeServerUrl,
|
||||
setBackendUrl,
|
||||
validateBackendUrl,
|
||||
} from '../../src/services/backend-client';
|
||||
|
||||
// ── NDJSON stream helpers for the U3 circuit-breaker tests ──
|
||||
const ndjsonStream = (lines: string[]): ReadableStream<Uint8Array> => {
|
||||
const encoder = new TextEncoder();
|
||||
return new ReadableStream<Uint8Array>({
|
||||
start(controller) {
|
||||
for (const l of lines) controller.enqueue(encoder.encode(l));
|
||||
controller.close();
|
||||
},
|
||||
});
|
||||
};
|
||||
const ndjsonResponse = (lines: string[]): Response =>
|
||||
new Response(ndjsonStream(lines), {
|
||||
status: 200,
|
||||
headers: { 'Content-Type': 'application/x-ndjson' },
|
||||
});
|
||||
const nodeLine = (i: number): string =>
|
||||
`{"type":"node","data":{"id":"n${i}","label":"Function","properties":{"name":"f${i}"}}}\n`;
|
||||
const relLine = (i: number): string =>
|
||||
`{"type":"relationship","data":{"id":"r${i}","type":"CALLS","sourceId":"n0","targetId":"n${i}"}}\n`;
|
||||
|
||||
describe('normalizeServerUrl', () => {
|
||||
it('adds http:// to localhost', () => {
|
||||
expect(normalizeServerUrl('localhost:4747')).toBe('http://localhost:4747');
|
||||
|
|
@ -172,6 +194,151 @@ describe('fetchGraph', () => {
|
|||
});
|
||||
});
|
||||
|
||||
describe('connectToServer skipGraph (chat-only mode)', () => {
|
||||
const repoInfo = (nodes: number | undefined) => ({
|
||||
name: 'big-repo',
|
||||
path: '/repos/big-repo',
|
||||
repoPath: '/repos/big-repo',
|
||||
indexedAt: '2026-06-13T00:00:00Z',
|
||||
...(nodes !== undefined ? { stats: { nodes, edges: nodes * 2 } } : {}),
|
||||
});
|
||||
|
||||
// Routes /api/repo to the repo info and /api/graph to the supplied handler;
|
||||
// any other path returns an empty 200 so the breaker stays closed.
|
||||
const makeFetchMock = (nodes: number | undefined) => {
|
||||
const graphHandler = vi.fn(
|
||||
() =>
|
||||
new Response('{"nodes":[],"relationships":[]}', {
|
||||
status: 200,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
}),
|
||||
);
|
||||
const fetchMock = vi.fn((url: string) => {
|
||||
if (url.includes('/api/repo')) {
|
||||
return Promise.resolve(
|
||||
new Response(JSON.stringify(repoInfo(nodes)), {
|
||||
status: 200,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
}),
|
||||
);
|
||||
}
|
||||
if (url.includes('/api/graph')) {
|
||||
return Promise.resolve(graphHandler());
|
||||
}
|
||||
return Promise.resolve(
|
||||
new Response('{}', { status: 200, headers: { 'Content-Type': 'application/json' } }),
|
||||
);
|
||||
});
|
||||
return { fetchMock, graphHandler };
|
||||
};
|
||||
|
||||
const graphRequests = (fetchMock: ReturnType<typeof vi.fn>) =>
|
||||
fetchMock.mock.calls.filter(([u]: unknown[]) => String(u).includes('/api/graph'));
|
||||
|
||||
it('skips the graph download when skipGraph is true (even for a tiny repo)', async () => {
|
||||
const { fetchMock } = makeFetchMock(5);
|
||||
vi.stubGlobal('fetch', fetchMock);
|
||||
|
||||
const result = await connectToServer(
|
||||
'http://localhost:4747',
|
||||
undefined,
|
||||
undefined,
|
||||
'big-repo',
|
||||
{
|
||||
skipGraph: true,
|
||||
},
|
||||
);
|
||||
|
||||
expect(result.graphSkipped).toBe(true);
|
||||
expect(result.nodes).toEqual([]);
|
||||
expect(result.relationships).toEqual([]);
|
||||
expect(result.repoInfo.name).toBe('big-repo');
|
||||
expect(graphRequests(fetchMock)).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('downloads the graph when skipGraph is false (even for a huge repo)', async () => {
|
||||
const { fetchMock } = makeFetchMock(300_000);
|
||||
vi.stubGlobal('fetch', fetchMock);
|
||||
|
||||
const result = await connectToServer(
|
||||
'http://localhost:4747',
|
||||
undefined,
|
||||
undefined,
|
||||
'big-repo',
|
||||
{
|
||||
skipGraph: false,
|
||||
},
|
||||
);
|
||||
|
||||
expect(result.graphSkipped).toBe(false);
|
||||
expect(graphRequests(fetchMock).length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it('auto-detects a large project and skips the graph (no explicit flag)', async () => {
|
||||
const { fetchMock } = makeFetchMock(300_000);
|
||||
vi.stubGlobal('fetch', fetchMock);
|
||||
|
||||
const result = await connectToServer('http://localhost:4747', undefined, undefined, 'big-repo');
|
||||
|
||||
expect(result.graphSkipped).toBe(true);
|
||||
expect(graphRequests(fetchMock)).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('downloads the graph for a small project (no explicit flag)', async () => {
|
||||
const { fetchMock } = makeFetchMock(500);
|
||||
vi.stubGlobal('fetch', fetchMock);
|
||||
|
||||
const result = await connectToServer('http://localhost:4747', undefined, undefined, 'big-repo');
|
||||
|
||||
expect(result.graphSkipped).toBe(false);
|
||||
expect(graphRequests(fetchMock).length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it('fails open to a full download when node stats are missing', async () => {
|
||||
const { fetchMock } = makeFetchMock(undefined);
|
||||
vi.stubGlobal('fetch', fetchMock);
|
||||
|
||||
const result = await connectToServer('http://localhost:4747', undefined, undefined, 'big-repo');
|
||||
|
||||
expect(result.graphSkipped).toBe(false);
|
||||
expect(graphRequests(fetchMock).length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it('auto-detects an edge-heavy repo (nodes under, edges over the threshold)', async () => {
|
||||
// 10K nodes (< 25K node threshold) but 80K edges (> 50K edge threshold).
|
||||
const fetchMock = vi.fn((url: string) => {
|
||||
if (url.includes('/api/repo')) {
|
||||
return Promise.resolve(
|
||||
new Response(
|
||||
JSON.stringify({
|
||||
name: 'edgy-repo',
|
||||
path: '/repos/edgy-repo',
|
||||
repoPath: '/repos/edgy-repo',
|
||||
indexedAt: '2026-06-13T00:00:00Z',
|
||||
stats: { nodes: 10_000, edges: 80_000 },
|
||||
}),
|
||||
{ status: 200, headers: { 'Content-Type': 'application/json' } },
|
||||
),
|
||||
);
|
||||
}
|
||||
return Promise.resolve(
|
||||
new Response('{}', { status: 200, headers: { 'Content-Type': 'application/json' } }),
|
||||
);
|
||||
});
|
||||
vi.stubGlobal('fetch', fetchMock);
|
||||
|
||||
const result = await connectToServer(
|
||||
'http://localhost:4747',
|
||||
undefined,
|
||||
undefined,
|
||||
'edgy-repo',
|
||||
);
|
||||
|
||||
expect(result.graphSkipped).toBe(true);
|
||||
expect(fetchMock.mock.calls.filter(([u]) => String(u).includes('/api/graph'))).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('DEFAULT_BACKEND_URL resolution', () => {
|
||||
afterEach(() => {
|
||||
delete window.__GITNEXUS_CONFIG__;
|
||||
|
|
@ -203,6 +370,34 @@ describe('DEFAULT_BACKEND_URL resolution', () => {
|
|||
});
|
||||
});
|
||||
|
||||
describe('LARGE_GRAPH_NODE_THRESHOLD resolution', () => {
|
||||
afterEach(() => {
|
||||
delete window.__GITNEXUS_CONFIG__;
|
||||
vi.resetModules();
|
||||
});
|
||||
|
||||
it('defaults to 25000 when no config is injected', async () => {
|
||||
delete window.__GITNEXUS_CONFIG__;
|
||||
const { LARGE_GRAPH_NODE_THRESHOLD } = await import('../../src/config/ui-constants');
|
||||
expect(LARGE_GRAPH_NODE_THRESHOLD).toBe(25_000);
|
||||
});
|
||||
|
||||
it('uses a valid positive override', async () => {
|
||||
window.__GITNEXUS_CONFIG__ = { largeGraphNodeThreshold: 100_000 };
|
||||
const { LARGE_GRAPH_NODE_THRESHOLD } = await import('../../src/config/ui-constants');
|
||||
expect(LARGE_GRAPH_NODE_THRESHOLD).toBe(100_000);
|
||||
});
|
||||
|
||||
it('ignores NaN, zero, and negative overrides (falls back to default)', async () => {
|
||||
for (const bad of [NaN, 0, -10]) {
|
||||
window.__GITNEXUS_CONFIG__ = { largeGraphNodeThreshold: bad };
|
||||
vi.resetModules();
|
||||
const { LARGE_GRAPH_NODE_THRESHOLD } = await import('../../src/config/ui-constants');
|
||||
expect(LARGE_GRAPH_NODE_THRESHOLD, `override=${bad}`).toBe(25_000);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('validateBackendUrl', () => {
|
||||
it('allows http:// URLs', () => {
|
||||
expect(() => validateBackendUrl('http://localhost:4747')).not.toThrow();
|
||||
|
|
@ -259,3 +454,122 @@ describe('setBackendUrl', () => {
|
|||
expect(getBackendUrl()).toBe('http://localhost:4747');
|
||||
});
|
||||
});
|
||||
|
||||
describe('fetchGraph streaming size breaker (#2178)', () => {
|
||||
it('throws GraphTooLargeError when node count exceeds maxNodes', async () => {
|
||||
setBackendUrl('http://localhost:4747');
|
||||
vi.stubGlobal(
|
||||
'fetch',
|
||||
vi.fn().mockResolvedValue(ndjsonResponse([nodeLine(0), nodeLine(1), nodeLine(2)])),
|
||||
);
|
||||
await expect(fetchGraph('repo', { maxNodes: 2 })).rejects.toBeInstanceOf(GraphTooLargeError);
|
||||
});
|
||||
|
||||
it('completes when node count is at or below maxNodes (== not >)', async () => {
|
||||
setBackendUrl('http://localhost:4747');
|
||||
vi.stubGlobal('fetch', vi.fn().mockResolvedValue(ndjsonResponse([nodeLine(0), nodeLine(1)])));
|
||||
const result = await fetchGraph('repo', { maxNodes: 2 });
|
||||
expect(result.nodes).toHaveLength(2);
|
||||
});
|
||||
|
||||
it('trips on the edge counter for a node-light stream', async () => {
|
||||
setBackendUrl('http://localhost:4747');
|
||||
vi.stubGlobal(
|
||||
'fetch',
|
||||
vi.fn().mockResolvedValue(ndjsonResponse([nodeLine(0), relLine(1), relLine(2), relLine(3)])),
|
||||
);
|
||||
await expect(fetchGraph('repo', { maxNodes: 1000, maxEdges: 2 })).rejects.toBeInstanceOf(
|
||||
GraphTooLargeError,
|
||||
);
|
||||
});
|
||||
|
||||
it('never trips when no limits are passed (default behavior unchanged)', async () => {
|
||||
setBackendUrl('http://localhost:4747');
|
||||
vi.stubGlobal(
|
||||
'fetch',
|
||||
vi
|
||||
.fn()
|
||||
.mockResolvedValue(ndjsonResponse([nodeLine(0), nodeLine(1), nodeLine(2), relLine(3)])),
|
||||
);
|
||||
const result = await fetchGraph('repo');
|
||||
expect(result.nodes).toHaveLength(3);
|
||||
expect(result.relationships).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('breaker wins over a later error record in the same stream', async () => {
|
||||
setBackendUrl('http://localhost:4747');
|
||||
vi.stubGlobal(
|
||||
'fetch',
|
||||
vi
|
||||
.fn()
|
||||
.mockResolvedValue(
|
||||
ndjsonResponse([
|
||||
nodeLine(0),
|
||||
nodeLine(1),
|
||||
nodeLine(2),
|
||||
'{"type":"error","error":"late boom"}\n',
|
||||
]),
|
||||
),
|
||||
);
|
||||
await expect(fetchGraph('repo', { maxNodes: 2 })).rejects.toBeInstanceOf(GraphTooLargeError);
|
||||
});
|
||||
});
|
||||
|
||||
describe('connectToServer streaming breaker (no-stats fail-open backstop, #2178)', () => {
|
||||
afterEach(() => {
|
||||
delete window.__GITNEXUS_CONFIG__;
|
||||
vi.resetModules();
|
||||
});
|
||||
|
||||
// Re-import with a tiny threshold so a 3-record stream exercises the breaker.
|
||||
const setupTinyThreshold = async () => {
|
||||
window.__GITNEXUS_CONFIG__ = { largeGraphNodeThreshold: 2, largeGraphEdgeThreshold: 2 };
|
||||
vi.resetModules();
|
||||
const mod = await import('../../src/services/backend-client');
|
||||
mod.setBackendUrl('http://localhost:4747');
|
||||
return mod;
|
||||
};
|
||||
|
||||
const repoNoStats = () =>
|
||||
new Response(
|
||||
JSON.stringify({ name: 'r', path: '/r', repoPath: '/r', indexedAt: '2026-06-13T00:00:00Z' }),
|
||||
{ status: 200, headers: { 'Content-Type': 'application/json' } },
|
||||
);
|
||||
|
||||
it('falls into chat-only when an auto-detect stream exceeds the threshold (absent stats)', async () => {
|
||||
const { connectToServer: connect } = await setupTinyThreshold();
|
||||
const fetchMock = vi.fn((url: string) => {
|
||||
if (url.includes('/api/repo')) return Promise.resolve(repoNoStats());
|
||||
if (url.includes('/api/graph'))
|
||||
return Promise.resolve(ndjsonResponse([nodeLine(0), nodeLine(1), nodeLine(2)]));
|
||||
return Promise.resolve(
|
||||
new Response('{}', { status: 200, headers: { 'Content-Type': 'application/json' } }),
|
||||
);
|
||||
});
|
||||
vi.stubGlobal('fetch', fetchMock);
|
||||
|
||||
const result = await connect('http://localhost:4747', undefined, undefined, 'r');
|
||||
expect(result.graphSkipped).toBe(true);
|
||||
expect(result.nodes).toEqual([]);
|
||||
expect(result.relationships).toEqual([]);
|
||||
});
|
||||
|
||||
it('does NOT enforce the breaker for an explicit load-anyway (skipGraph:false)', async () => {
|
||||
const { connectToServer: connect } = await setupTinyThreshold();
|
||||
const fetchMock = vi.fn((url: string) => {
|
||||
if (url.includes('/api/repo')) return Promise.resolve(repoNoStats());
|
||||
if (url.includes('/api/graph'))
|
||||
return Promise.resolve(ndjsonResponse([nodeLine(0), nodeLine(1), nodeLine(2)]));
|
||||
return Promise.resolve(
|
||||
new Response('{}', { status: 200, headers: { 'Content-Type': 'application/json' } }),
|
||||
);
|
||||
});
|
||||
vi.stubGlobal('fetch', fetchMock);
|
||||
|
||||
const result = await connect('http://localhost:4747', undefined, undefined, 'r', {
|
||||
skipGraph: false,
|
||||
});
|
||||
expect(result.graphSkipped).toBe(false);
|
||||
expect(result.nodes).toHaveLength(3);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue