From 6dc6544365aff48faa1129b0eb9ce5b6b7fc0843 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Gerg=C5=91=20Magyar?= Date: Sat, 13 Jun 2026 11:07:58 +0100 Subject: [PATCH] fix(web): chat-only mode for large projects to prevent WebUI hang (#2178) (#2185) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * 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> --- gitnexus-web/src/App.tsx | 43 ++- gitnexus-web/src/components/DropZone.tsx | 4 + gitnexus-web/src/components/GraphCanvas.tsx | 69 +++- gitnexus-web/src/components/Header.tsx | 6 +- gitnexus-web/src/components/RightPanel.tsx | 9 + gitnexus-web/src/components/StatusBar.tsx | 6 +- gitnexus-web/src/config/ui-constants.ts | 34 ++ gitnexus-web/src/core/llm/agent.ts | 18 +- gitnexus-web/src/core/llm/context-builder.ts | 24 +- .../src/hooks/app-state/graph.test.tsx | 17 + gitnexus-web/src/hooks/app-state/graph.tsx | 27 ++ gitnexus-web/src/hooks/useAppState.tsx | 163 ++++++++- gitnexus-web/src/lib/apply-connect-result.ts | 46 +++ gitnexus-web/src/lib/graph-load-decision.ts | 84 +++++ gitnexus-web/src/locales/en/chat.json | 3 + gitnexus-web/src/locales/en/graph.json | 11 +- gitnexus-web/src/locales/zh-CN/chat.json | 3 + gitnexus-web/src/locales/zh-CN/graph.json | 11 +- gitnexus-web/src/services/backend-client.ts | 111 ++++++- gitnexus-web/src/vite-env.d.ts | 14 + gitnexus-web/test/unit/agent-prompt.test.ts | 30 ++ .../test/unit/graph-load-decision.test.ts | 145 ++++++++ .../test/unit/load-graph-anyway.test.tsx | 242 ++++++++++++++ .../test/unit/server-connection.test.ts | 314 ++++++++++++++++++ 24 files changed, 1387 insertions(+), 47 deletions(-) create mode 100644 gitnexus-web/src/lib/apply-connect-result.ts create mode 100644 gitnexus-web/src/lib/graph-load-decision.ts create mode 100644 gitnexus-web/test/unit/graph-load-decision.test.ts create mode 100644 gitnexus-web/test/unit/load-graph-anyway.test.tsx diff --git a/gitnexus-web/src/App.tsx b/gitnexus-web/src/App.tsx index 68f7d4968..6141104b5 100644 --- a/gitnexus-web/src/App.tsx +++ b/gitnexus-web/src/App.tsx @@ -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)); diff --git a/gitnexus-web/src/components/DropZone.tsx b/gitnexus-web/src/components/DropZone.tsx index 521b64115..389f99869 100644 --- a/gitnexus-web/src/components/DropZone.tsx +++ b/gitnexus-web/src/components/DropZone.tsx @@ -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) => { diff --git a/gitnexus-web/src/components/GraphCanvas.tsx b/gitnexus-web/src/components/GraphCanvas.tsx index d0880dbe1..70030eb88 100644 --- a/gitnexus-web/src/components/GraphCanvas.tsx +++ b/gitnexus-web/src/components/GraphCanvas.tsx @@ -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((_, ref) => { animatedNodes, graphViewMode, setGraphViewMode, + graphMode, + chatOnlyNodeCount, + loadGraphAnyway, } = useAppState(); const [hoveredNodeName, setHoveredNodeName] = useState(null); @@ -193,7 +198,10 @@ export const GraphCanvas = forwardRef((_, 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; @@ -218,7 +226,7 @@ export const GraphCanvas = forwardRef((_, 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((_, 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 (
{/* Background gradient */} @@ -324,6 +363,32 @@ export const GraphCanvas = forwardRef((_, 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' && ( +
+
+

+ {t('canvas.chatOnly.title')} +

+

+ {chatOnlyNodeCount != null + ? t('canvas.chatOnly.descriptionWithCount', { + count: chatOnlyNodeCount.toLocaleString(), + }) + : t('canvas.chatOnly.description')} +

+

{t('canvas.chatOnly.citationNote')}

+ +
+
+ )} + {/* Hovered node tooltip - only show when NOT selected */} {hoveredNodeName && !sigmaSelectedNode && (
diff --git a/gitnexus-web/src/components/Header.tsx b/gitnexus-web/src/components/Header.tsx index 3dc83301c..03bdd6041 100644 --- a/gitnexus-web/src/components/Header.tsx +++ b/gitnexus-web/src/components/Header.tsx @@ -63,6 +63,7 @@ export const Header = ({ const { projectName, graph, + graphMode, openChatPanel, isRightPanelOpen, rightPanelTab, @@ -467,8 +468,9 @@ export const Header = ({ - {/* 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' && (
{t('common:counts.nodes', { count: nodeCount })} {t('common:counts.edges', { count: edgeCount })} diff --git a/gitnexus-web/src/components/RightPanel.tsx b/gitnexus-web/src/components/RightPanel.tsx index 193d2c1e6..7038895d8 100644 --- a/gitnexus-web/src/components/RightPanel.tsx +++ b/gitnexus-web/src/components/RightPanel.tsx @@ -23,6 +23,7 @@ export const RightPanel = () => { isRightPanelOpen, setRightPanelOpen, graph, + graphMode, addCodeReference, // LLM / chat state chatMessages, @@ -283,6 +284,14 @@ export const RightPanel = () => {
+ {/* 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' && ( +
+ {t('chat:chatOnly.banner')} +
+ )} + {/* Status / errors */} {agentError && (
diff --git a/gitnexus-web/src/components/StatusBar.tsx b/gitnexus-web/src/components/StatusBar.tsx index 4193d4f21..1097aba6a 100644 --- a/gitnexus-web/src/components/StatusBar.tsx +++ b/gitnexus-web/src/components/StatusBar.tsx @@ -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 */}
- {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' && ( <> {t('common:counts.nodes', { count: nodeCount })} diff --git a/gitnexus-web/src/config/ui-constants.ts b/gitnexus-web/src/config/ui-constants.ts index 1bdb9bae1..6dfba2b2b 100644 --- a/gitnexus-web/src/config/ui-constants.ts +++ b/gitnexus-web/src/config/ui-constants.ts @@ -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__; diff --git a/gitnexus-web/src/core/llm/agent.ts b/gitnexus-web/src/core/llm/agent.ts index 9263cfe2c..c10748fd0 100644 --- a/gitnexus-web/src/core/llm/agent.ts +++ b/gitnexus-web/src/core/llm/agent.ts @@ -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) { diff --git a/gitnexus-web/src/core/llm/context-builder.ts b/gitnexus-web/src/core/llm/context-builder.ts index 1deb595cc..3c0cdd77a 100644 --- a/gitnexus-web/src/core/llm/context-builder.ts +++ b/gitnexus-web/src/core/llm/context-builder.ts @@ -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 : ''}`; } diff --git a/gitnexus-web/src/hooks/app-state/graph.test.tsx b/gitnexus-web/src/hooks/app-state/graph.test.tsx index 12d946ee2..9bbf76d1f 100644 --- a/gitnexus-web/src/hooks/app-state/graph.test.tsx +++ b/gitnexus-web/src/hooks/app-state/graph.test.tsx @@ -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'); + }); }); diff --git a/gitnexus-web/src/hooks/app-state/graph.tsx b/gitnexus-web/src/hooks/app-state/graph.tsx index aa6524a5f..d02b6d16d 100644 --- a/gitnexus-web/src/hooks/app-state/graph.tsx +++ b/gitnexus-web/src/hooks/app-state/graph.tsx @@ -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) => 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(null); @@ -30,6 +49,8 @@ export const GraphStateProvider = ({ children }: { children: ReactNode }) => { const [depthFilter, setDepthFilter] = useState(null); const [highlightedNodeIds, setHighlightedNodeIds] = useState>(new Set()); const [graphViewMode, setGraphViewMode] = useState<'force' | 'tree' | 'circles'>('force'); + const [graphMode, setGraphMode] = useState('full'); + const [chatOnlyNodeCount, setChatOnlyNodeCount] = useState(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, ], ); diff --git a/gitnexus-web/src/hooks/useAppState.tsx b/gitnexus-web/src/hooks/useAppState.tsx index 5c118450b..e3a3daaec 100644 --- a/gitnexus-web/src/hooks/useAppState.tsx +++ b/gitnexus-web/src/hooks/useAppState.tsx @@ -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; setHighlightedNodeIds: (ids: Set) => void; @@ -163,6 +170,8 @@ interface AppState { setAvailableRepos: (repos: BackendRepo[]) => void; switchRepo: (repoName: string) => Promise; setCurrentRepo: (repoName: string) => void; + /** Download the full graph for the current repo after a chat-only connect (#2178). */ + loadGraphAnyway: () => Promise; // Worker API (shared across app) runQuery: (cypher: string) => Promise; @@ -195,7 +204,7 @@ interface AppState { // LLM methods refreshLLMSettings: () => void; - initializeAgent: (overrideProjectName?: string) => Promise; + initializeAgent: (overrideProjectName?: string, opts?: { chatOnly?: boolean }) => Promise; sendChatMessage: (message: string) => Promise; 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(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 => { + async (overrideProjectName?: string, opts?: { chatOnly?: boolean }): Promise => { 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(null); + const loadGraphMountedRef = useRef(true); + useEffect(() => { + return () => { + loadGraphMountedRef.current = false; + loadGraphAbortRef.current?.abort(); + }; + }, []); + const loadGraphAnyway = useCallback(async (): Promise => { + 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 diff --git a/gitnexus-web/src/lib/apply-connect-result.ts b/gitnexus-web/src/lib/apply-connect-result.ts new file mode 100644 index 000000000..1b7028869 --- /dev/null +++ b/gitnexus-web/src/lib/apply-connect-result.ts @@ -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, + }; +} diff --git a/gitnexus-web/src/lib/graph-load-decision.ts b/gitnexus-web/src/lib/graph-load-decision.ts new file mode 100644 index 000000000..955e3f297 --- /dev/null +++ b/gitnexus-web/src/lib/graph-load-decision.ts @@ -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; +} diff --git a/gitnexus-web/src/locales/en/chat.json b/gitnexus-web/src/locales/en/chat.json index 4d976c335..a768877b6 100644 --- a/gitnexus-web/src/locales/en/chat.json +++ b/gitnexus-web/src/locales/en/chat.json @@ -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" diff --git a/gitnexus-web/src/locales/en/graph.json b/gitnexus-web/src/locales/en/graph.json index 072e47c2d..72c883456 100644 --- a/gitnexus-web/src/locales/en/graph.json +++ b/gitnexus-web/src/locales/en/graph.json @@ -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", diff --git a/gitnexus-web/src/locales/zh-CN/chat.json b/gitnexus-web/src/locales/zh-CN/chat.json index f83c5ad75..6804b6b53 100644 --- a/gitnexus-web/src/locales/zh-CN/chat.json +++ b/gitnexus-web/src/locales/zh-CN/chat.json @@ -29,6 +29,9 @@ "configureAI": "配置 AI", "connecting": "连接中" }, + "chatOnly": { + "banner": "图谱未加载(大型项目)。对话功能正常;内联节点引用不会在图谱视图中高亮。" + }, "roles": { "you": "你", "assistant": "Nexus AI" diff --git a/gitnexus-web/src/locales/zh-CN/graph.json b/gitnexus-web/src/locales/zh-CN/graph.json index 7fc7c71e7..6dba980b7 100644 --- a/gitnexus-web/src/locales/zh-CN/graph.json +++ b/gitnexus-web/src/locales/zh-CN/graph.json @@ -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": "未知", diff --git a/gitnexus-web/src/services/backend-client.ts b/gitnexus-web/src/services/backend-client.ts index b12cf45a9..b6f2e34a5 100644 --- a/gitnexus-web/src/services/backend-client.ts +++ b/gitnexus-web/src/services/backend-client.ts @@ -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 { @@ -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 { 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; + } } diff --git a/gitnexus-web/src/vite-env.d.ts b/gitnexus-web/src/vite-env.d.ts index 4a8d41b00..cbbe32bf1 100644 --- a/gitnexus-web/src/vite-env.d.ts +++ b/gitnexus-web/src/vite-env.d.ts @@ -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; }; } diff --git a/gitnexus-web/test/unit/agent-prompt.test.ts b/gitnexus-web/test/unit/agent-prompt.test.ts index 6c47c6fd2..c2cb1392f 100644 --- a/gitnexus-web/test/unit/agent-prompt.test.ts +++ b/gitnexus-web/test/unit/agent-prompt.test.ts @@ -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'); + }); +}); diff --git a/gitnexus-web/test/unit/graph-load-decision.test.ts b/gitnexus-web/test/unit/graph-load-decision.test.ts new file mode 100644 index 000000000..d4713a4da --- /dev/null +++ b/gitnexus-web/test/unit/graph-load-decision.test.ts @@ -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); + }); +}); diff --git a/gitnexus-web/test/unit/load-graph-anyway.test.tsx b/gitnexus-web/test/unit/load-graph-anyway.test.tsx new file mode 100644 index 000000000..10c15c9cc --- /dev/null +++ b/gitnexus-web/test/unit/load-graph-anyway.test.tsx @@ -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((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 = 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((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 = 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); + }); +}); diff --git a/gitnexus-web/test/unit/server-connection.test.ts b/gitnexus-web/test/unit/server-connection.test.ts index dc1e79e7c..b5767ec33 100644 --- a/gitnexus-web/test/unit/server-connection.test.ts +++ b/gitnexus-web/test/unit/server-connection.test.ts @@ -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 => { + const encoder = new TextEncoder(); + return new ReadableStream({ + 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) => + 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); + }); +});