Merge pull request #477 from jreakin/perf/web-o1-lookups-memo-bundle

perf(web): O(1) lookups, memoization, bundle optimizations, React fixes
This commit is contained in:
Gergő Magyar 2026-03-23 17:01:09 +00:00 committed by GitHub
commit 2ede01dbff
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
30 changed files with 948 additions and 642 deletions

View file

@ -13,7 +13,7 @@ import { FileEntry } from './services/zip';
import { getActiveProviderConfig } from './core/llm/settings-service';
import { createKnowledgeGraph } from './core/graph/graph';
import { connectToServer, fetchRepos, normalizeServerUrl, type ConnectToServerResult } from './services/server-connection';
import { HelpPanel } from './components/HelpPanel';
import { ERROR_RESET_DELAY_MS } from './config/ui-constants';
const AppContent = () => {
const {
@ -29,11 +29,9 @@ const AppContent = () => {
runPipelineFromFiles,
isSettingsPanelOpen,
setSettingsPanelOpen,
isHelpDialogBoxOpen,
setHelpDialogBoxOpen,
refreshLLMSettings,
initializeAgent,
startEmbeddings,
startEmbeddingsWithFallback,
embeddingStatus,
codeReferences,
selectedNode,
@ -44,7 +42,6 @@ const AppContent = () => {
setAvailableRepos,
switchRepo,
loadServerGraph,
graph
} = useAppState();
const graphCanvasRef = useRef<GraphCanvasHandle>(null);
@ -72,13 +69,7 @@ const AppContent = () => {
// Auto-start embeddings pipeline in background
// Uses WebGPU if available, falls back to WASM
startEmbeddings().catch((err) => {
if (err?.name === 'WebGPUNotAvailableError' || err?.message?.includes('WebGPU')) {
startEmbeddings('wasm').catch(console.warn);
} else {
console.warn('Embeddings auto-start failed:', err);
}
});
startEmbeddingsWithFallback();
} catch (error) {
console.error('Pipeline error:', error);
setProgress({
@ -90,9 +81,9 @@ const AppContent = () => {
setTimeout(() => {
setViewMode('onboarding');
setProgress(null);
}, 3000);
}, ERROR_RESET_DELAY_MS);
}
}, [setViewMode, setGraph, setFileContents, setProgress, setProjectName, runPipeline, startEmbeddings, initializeAgent]);
}, [setViewMode, setGraph, setFileContents, setProgress, setProjectName, runPipeline, startEmbeddingsWithFallback, initializeAgent]);
const handleGitClone = useCallback(async (files: FileEntry[]) => {
const firstPath = files[0]?.path || 'repository';
@ -115,13 +106,7 @@ const AppContent = () => {
initializeAgent(projectName);
}
startEmbeddings().catch((err) => {
if (err?.name === 'WebGPUNotAvailableError' || err?.message?.includes('WebGPU')) {
startEmbeddings('wasm').catch(console.warn);
} else {
console.warn('Embeddings auto-start failed:', err);
}
});
startEmbeddingsWithFallback();
} catch (error) {
console.error('Pipeline error:', error);
setProgress({
@ -133,9 +118,9 @@ const AppContent = () => {
setTimeout(() => {
setViewMode('onboarding');
setProgress(null);
}, 3000);
}, ERROR_RESET_DELAY_MS);
}
}, [setViewMode, setGraph, setFileContents, setProgress, setProjectName, runPipelineFromFiles, startEmbeddings, initializeAgent]);
}, [setViewMode, setGraph, setFileContents, setProgress, setProjectName, runPipelineFromFiles, startEmbeddingsWithFallback, initializeAgent]);
const handleServerConnect = useCallback((result: ConnectToServerResult): Promise<void> => {
// Extract project name from repoPath
@ -172,13 +157,7 @@ const AppContent = () => {
}
})
.then(() => {
startEmbeddings().catch((err) => {
if (err?.name === 'WebGPUNotAvailableError' || err?.message?.includes('WebGPU')) {
startEmbeddings('wasm').catch(console.warn);
} else {
console.warn('Embeddings auto-start failed:', err);
}
});
startEmbeddingsWithFallback();
})
.catch((err) => {
console.warn('Failed to load graph into LadybugDB:', err);
@ -186,7 +165,7 @@ const AppContent = () => {
});
return loadGraphPromise;
}, [setViewMode, setGraph, setFileContents, setProjectName, loadServerGraph, initializeAgent, startEmbeddings]);
}, [setViewMode, setGraph, setFileContents, setProjectName, loadServerGraph, initializeAgent, startEmbeddingsWithFallback]);
// Auto-connect when ?server query param is present (bookmarkable shortcut)
const autoConnectRan = useRef(false);
@ -235,7 +214,7 @@ const AppContent = () => {
setTimeout(() => {
setViewMode('onboarding');
setProgress(null);
}, 3000);
}, ERROR_RESET_DELAY_MS);
});
}, [handleServerConnect, setProgress, setViewMode, setServerBaseUrl, setAvailableRepos]);
@ -309,13 +288,6 @@ const AppContent = () => {
onSettingsSaved={handleSettingsSaved}
/>
<HelpPanel
isOpen={isHelpDialogBoxOpen}
onClose={() => setHelpDialogBoxOpen(false)}
nodeCount={graph!.nodes.length}
edgeCount={graph!.relationships.length}
/>
</div>
);
};

View file

@ -1,4 +1,4 @@
import { Server, ArrowRight } from 'lucide-react';
import { Server, ArrowRight } from '@/lib/lucide-icons';
import { BackendRepo } from '../services/backend';
interface BackendRepoSelectorProps {

View file

@ -1,8 +1,9 @@
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { Code, PanelLeftClose, PanelLeft, Trash2, X, Target, FileCode, Sparkles, MousePointerClick } from 'lucide-react';
import { Code, PanelLeftClose, PanelLeft, Trash2, X, Target, FileCode, Sparkles, MousePointerClick } from '@/lib/lucide-icons';
import { Prism as SyntaxHighlighter } from 'react-syntax-highlighter';
import { vscDarkPlus } from 'react-syntax-highlighter/dist/esm/styles/prism';
import { useAppState } from '../hooks/useAppState';
import type { GraphNode } from '../core/graph/types';
import { NODE_COLORS } from '../lib/constants';
/** Map file extension to Prism syntax highlighter language identifier */
@ -75,6 +76,11 @@ export const CodeReferencesPanel = ({ onFocusNode }: CodeReferencesPanelProps) =
codeReferenceFocus,
} = useAppState();
const nodeById = useMemo(() => {
if (!graph) return new Map<string, GraphNode>();
return new Map(graph.nodes.map(n => [n.id, n]));
}, [graph]);
const [isCollapsed, setIsCollapsed] = useState(false);
const [glowRefId, setGlowRefId] = useState<string | null>(null);
const panelRef = useRef<HTMLElement | null>(null);
@ -161,8 +167,9 @@ export const CodeReferencesPanel = ({ onFocusNode }: CodeReferencesPanelProps) =
if (!target) return;
// Double rAF: wait for collapse state + list DOM to render.
requestAnimationFrame(() => {
requestAnimationFrame(() => {
const rafIds: number[] = [];
const outerRafId = requestAnimationFrame(() => {
const innerRafId = requestAnimationFrame(() => {
const el = refCardEls.current.get(target.id);
if (!el) return;
@ -177,7 +184,13 @@ export const CodeReferencesPanel = ({ onFocusNode }: CodeReferencesPanelProps) =
glowTimerRef.current = null;
}, 1200);
});
rafIds.push(innerRafId);
});
rafIds.push(outerRafId);
return () => {
rafIds.forEach(id => cancelAnimationFrame(id));
};
}, [codeReferenceFocus?.ts, aiReferences]);
const refsWithSnippets = useMemo(() => {
@ -414,7 +427,7 @@ export const CodeReferencesPanel = ({ onFocusNode }: CodeReferencesPanelProps) =
const nodeId = ref.nodeId!;
// Sync selection + focus graph
if (graph) {
const node = graph.nodes.find((n) => n.id === nodeId);
const node = nodeById.get(nodeId);
if (node) setSelectedNode(node);
}
onFocusNode(nodeId);

View file

@ -1,5 +1,5 @@
import { useState, useCallback, useRef, DragEvent } from 'react';
import { Upload, FileArchive, Github, Loader2, ArrowRight, Key, Eye, EyeOff, Globe, X } from 'lucide-react';
import { Upload, FileArchive, Github, Loader2, ArrowRight, Key, Eye, EyeOff, Globe, X } from '@/lib/lucide-icons';
import { cloneRepository, parseGitHubUrl } from '../services/git-clone';
import { connectToServer, type ConnectToServerResult } from '../services/server-connection';
import { FileEntry } from '../services/zip';
@ -27,9 +27,13 @@ export const DropZone = ({ onFileSelect, onGitClone, onServerConnect }: DropZone
const [error, setError] = useState<string | null>(null);
// Server tab state
const [serverUrl, setServerUrl] = useState(() =>
localStorage.getItem('gitnexus-server-url') || ''
);
const [serverUrl, setServerUrl] = useState(() => {
try {
return localStorage.getItem('gitnexus-server-url') || '';
} catch {
return '';
}
});
const [isConnecting, setIsConnecting] = useState(false);
const [serverProgress, setServerProgress] = useState<{
phase: string;
@ -133,7 +137,11 @@ export const DropZone = ({ onFileSelect, onGitClone, onServerConnect }: DropZone
}
// Persist URL to localStorage
localStorage.setItem('gitnexus-server-url', serverUrl);
try {
localStorage.setItem('gitnexus-server-url', serverUrl);
} catch {
// localStorage may be unavailable (e.g. private browsing, quota exceeded)
}
setError(null);
setIsConnecting(true);

View file

@ -1,4 +1,4 @@
import { Brain, Loader2, Check, AlertCircle, Zap, FlaskConical } from 'lucide-react';
import { Brain, Loader2, Check, AlertCircle, Zap, FlaskConical } from '@/lib/lucide-icons';
import { useAppState } from '../hooks/useAppState';
import { useState } from 'react';
import { WebGPUFallbackDialog } from './WebGPUFallbackDialog';

View file

@ -14,7 +14,7 @@ import {
Variable,
Hash,
Target,
} from 'lucide-react';
} from '@/lib/lucide-icons';
import { useAppState } from '../hooks/useAppState';
import { FILTERABLE_LABELS, NODE_COLORS, ALL_EDGE_TYPES, EDGE_INFO, type EdgeType } from '../lib/constants';
import { GraphNode, NodeLabel } from '../core/graph/types';
@ -98,13 +98,15 @@ const TreeItem = ({
const isSelected = selectedPath === node.path;
const hasChildren = node.children.length > 0;
// Filter children based on search
// Filter children based on search (recursive)
const filteredChildren = useMemo(() => {
if (!searchQuery) return node.children;
return node.children.filter(child =>
child.name.toLowerCase().includes(searchQuery.toLowerCase()) ||
child.children.some(c => c.name.toLowerCase().includes(searchQuery.toLowerCase()))
);
const searchLower = searchQuery.toLowerCase();
const matchesSearch = (node: TreeNode, query: string): boolean => {
if (node.name.toLowerCase().includes(query)) return true;
return node.children?.some(child => matchesSearch(child, query)) ?? false;
};
return node.children.filter(child => matchesSearch(child, searchLower));
}, [node.children, searchQuery]);
// Check if this node matches search

View file

@ -1,8 +1,9 @@
import { useEffect, useCallback, useMemo, useState, forwardRef, useImperativeHandle } from 'react';
import { ZoomIn, ZoomOut, Maximize2, Focus, RotateCcw, Play, Pause, Lightbulb, LightbulbOff } from 'lucide-react';
import { ZoomIn, ZoomOut, Maximize2, Focus, RotateCcw, Play, Pause, Lightbulb, LightbulbOff } from '@/lib/lucide-icons';
import { useSigma } from '../hooks/useSigma';
import { useAppState } from '../hooks/useAppState';
import { knowledgeGraphToGraphology, filterGraphByDepth, SigmaNodeAttributes, SigmaEdgeAttributes } from '../lib/graph-adapter';
import type { GraphNode } from '../core/graph/types';
import { QueryFAB } from './QueryFAB';
import Graph from 'graphology';
@ -54,30 +55,44 @@ export const GraphCanvas = forwardRef<GraphCanvasHandle>((_, ref) => {
return animatedNodes;
}, [animatedNodes, isAIHighlightsEnabled]);
const nodeById = useMemo(() => {
if (!graph) return new Map<string, GraphNode>();
return new Map(graph.nodes.map(n => [n.id, n]));
}, [graph]);
const handleNodeClick = useCallback((nodeId: string) => {
if (!graph) return;
const node = graph.nodes.find(n => n.id === nodeId);
const node = nodeById.get(nodeId);
if (node) {
setSelectedNode(node);
openCodePanel();
}
}, [graph, setSelectedNode, openCodePanel]);
}, [graph, nodeById, setSelectedNode, openCodePanel]);
const handleNodeHover = useCallback((nodeId: string | null) => {
if (!nodeId || !graph) {
setHoveredNodeName(null);
return;
}
const node = graph.nodes.find(n => n.id === nodeId);
if (node) {
setHoveredNodeName(node.properties.name);
}
}, [graph]);
const node = nodeById.get(nodeId);
setHoveredNodeName(node ? node.properties.name : null);
}, [graph, nodeById]);
const handleStageClick = useCallback(() => {
setSelectedNode(null);
}, [setSelectedNode]);
const handleToggleAIHighlights = useCallback(() => {
if (isAIHighlightsEnabled) {
clearAIToolHighlights();
clearAICitationHighlights();
clearBlastRadius();
setSelectedNode(null);
setSigmaSelectedNode(null);
}
toggleAIHighlights();
}, [isAIHighlightsEnabled, clearAIToolHighlights, clearAICitationHighlights, clearBlastRadius, setSelectedNode, toggleAIHighlights]);
const {
containerRef,
sigmaRef,
@ -106,7 +121,7 @@ export const GraphCanvas = forwardRef<GraphCanvasHandle>((_, ref) => {
focusNode: (nodeId: string) => {
// Also update app state so the selection syncs properly
if (graph) {
const node = graph.nodes.find(n => n.id === nodeId);
const node = nodeById.get(nodeId);
if (node) {
setSelectedNode(node);
openCodePanel();
@ -114,7 +129,7 @@ export const GraphCanvas = forwardRef<GraphCanvasHandle>((_, ref) => {
}
focusNode(nodeId);
}
}), [focusNode, graph, setSelectedNode, openCodePanel]);
}), [focusNode, graph, nodeById, setSelectedNode, openCodePanel]);
// Update Sigma graph when KnowledgeGraph changes
useEffect(() => {
@ -126,10 +141,11 @@ export const GraphCanvas = forwardRef<GraphCanvasHandle>((_, ref) => {
graph.relationships.forEach(rel => {
if (rel.type === 'MEMBER_OF') {
// Find the community node to get its index
const communityNode = graph.nodes.find(n => n.id === rel.targetId && n.label === 'Community');
if (communityNode) {
const communityNode = nodeById.get(rel.targetId);
if (communityNode && communityNode.label === 'Community') {
// Extract community index from id (e.g., "comm_5" -> 5)
const communityIdx = parseInt(rel.targetId.replace('comm_', ''), 10) || 0;
const numericPart = rel.targetId.replace('comm_', '');
const communityIdx = /^\d+$/.test(numericPart) ? parseInt(numericPart, 10) : 0;
communityMemberships.set(rel.sourceId, communityIdx);
}
}
@ -137,7 +153,7 @@ export const GraphCanvas = forwardRef<GraphCanvasHandle>((_, ref) => {
const sigmaGraph = knowledgeGraphToGraphology(graph, communityMemberships);
setSigmaGraph(sigmaGraph);
}, [graph, setSigmaGraph]);
}, [graph, nodeById, setSigmaGraph]);
// Update node visibility when filters change
useEffect(() => {
@ -149,7 +165,8 @@ export const GraphCanvas = forwardRef<GraphCanvasHandle>((_, ref) => {
filterGraphByDepth(sigmaGraph, appSelectedNode?.id || null, depthFilter, visibleLabels);
sigma.refresh();
}, [visibleLabels, depthFilter, appSelectedNode, sigmaRef]);
// eslint-disable-next-line react-hooks/exhaustive-deps -- sigmaRef identity never changes
}, [visibleLabels, depthFilter, appSelectedNode]);
// Sync app selected node with sigma
useEffect(() => {
@ -307,17 +324,7 @@ export const GraphCanvas = forwardRef<GraphCanvasHandle>((_, ref) => {
{/* AI Highlights toggle - Top Right */}
<div className="absolute top-4 right-4 z-20">
<button
onClick={() => {
if (isAIHighlightsEnabled) {
// Turning off — clear AI highlights and selection (preserve user query highlights)
clearAIToolHighlights();
clearAICitationHighlights();
clearBlastRadius();
setSelectedNode(null);
setSigmaSelectedNode(null);
}
toggleAIHighlights();
}}
onClick={handleToggleAIHighlights}
className={
isAIHighlightsEnabled
? 'w-10 h-10 flex items-center justify-center bg-cyan-500/15 border border-cyan-400/40 rounded-lg text-cyan-200 hover:bg-cyan-500/20 hover:border-cyan-300/60 transition-colors'

View file

@ -1,4 +1,4 @@
import { Search, Settings, HelpCircle, Sparkles, Github, Star, ChevronDown } from 'lucide-react';
import { Search, Settings, HelpCircle, Sparkles, Github, Star, ChevronDown } from '@/lib/lucide-icons';
import { useAppState } from '../hooks/useAppState';
import type { RepoSummary } from '../services/server-connection';
import { useState, useMemo, useRef, useEffect, useCallback } from 'react';

View file

@ -1,11 +1,11 @@
import React, { useState } from 'react';
import React, { useState, useRef, useEffect } from 'react';
import ReactMarkdown from 'react-markdown';
import remarkGfm from 'remark-gfm';
import { Prism as SyntaxHighlighter } from 'react-syntax-highlighter';
import { vscDarkPlus } from 'react-syntax-highlighter/dist/esm/styles/prism';
import { MermaidDiagram } from './MermaidDiagram';
import { ToolCallCard } from './ToolCallCard';
import { Copy, Check } from 'lucide-react';
import { Copy, Check } from '@/lib/lucide-icons';
// Custom syntax theme
const customTheme = {
@ -39,12 +39,24 @@ export const MarkdownRenderer: React.FC<MarkdownRendererProps> = ({
showCopyButton = false
}) => {
const [copied, setCopied] = useState(false);
const copyTimerRef = useRef<ReturnType<typeof setTimeout>>(undefined);
useEffect(() => {
return () => {
if (copyTimerRef.current) {
clearTimeout(copyTimerRef.current);
}
};
}, []);
const handleCopy = async () => {
try {
await navigator.clipboard.writeText(content);
setCopied(true);
setTimeout(() => setCopied(false), 2000);
if (copyTimerRef.current) {
clearTimeout(copyTimerRef.current);
}
copyTimerRef.current = setTimeout(() => setCopied(false), 2000);
} catch (err) {
console.error('Failed to copy:', err);
}
@ -78,13 +90,13 @@ export const MarkdownRenderer: React.FC<MarkdownRendererProps> = ({
return parts.join('```');
};
const handleLinkClick = (e: React.MouseEvent<HTMLAnchorElement>, href: string) => {
const handleLinkClick = React.useCallback((e: React.MouseEvent<HTMLAnchorElement>, href: string) => {
if (href.startsWith('code-ref:') || href.startsWith('node-ref:')) {
e.preventDefault();
onLinkClick?.(href);
}
// External links open in new tab (default behavior)
};
}, [onLinkClick]);
const formattedContent = React.useMemo(() => formatMarkdownForDisplay(content), [content]);
@ -164,7 +176,7 @@ export const MarkdownRenderer: React.FC<MarkdownRendererProps> = ({
);
},
pre: ({ children }: any) => <>{children}</>,
}), [onLinkClick]); // Removed handleLinkClick dependency as it is defined inside component but depends on onLinkClick
}), [handleLinkClick]);
return (
<div className="text-text-primary text-sm">

View file

@ -1,10 +1,13 @@
import { useEffect, useRef, useState } from 'react';
import { Suspense, useEffect, useRef, useState, lazy } from 'react';
import mermaid from 'mermaid';
import DOMPurify from 'dompurify';
import { AlertTriangle, Maximize2 } from 'lucide-react';
import { ProcessFlowModal } from './ProcessFlowModal';
import { AlertTriangle, Maximize2 } from '@/lib/lucide-icons';
import type { ProcessData } from '../lib/mermaid-generator';
const ProcessFlowModal = lazy(() =>
import('./ProcessFlowModal').then((m) => ({ default: m.ProcessFlowModal })),
);
// Initialize mermaid with cyan theme matching ProcessFlowModal
mermaid.initialize({
startOnLoad: false,
@ -68,7 +71,8 @@ export const MermaidDiagram = ({ code }: MermaidDiagramProps) => {
// Render the diagram
const { svg: renderedSvg } = await mermaid.render(id, code.trim());
setSvg(renderedSvg);
const sanitizedSvg = DOMPurify.sanitize(renderedSvg, { USE_PROFILES: { svg: true, svgFilters: true }, ADD_TAGS: ['foreignObject'] });
setSvg(sanitizedSvg);
setError(null);
} catch (err) {
// Silent catch for streaming:
@ -141,17 +145,23 @@ export const MermaidDiagram = ({ code }: MermaidDiagramProps) => {
<div
ref={containerRef}
className="flex items-center justify-center p-4 overflow-auto max-h-[400px]"
dangerouslySetInnerHTML={{ __html: DOMPurify.sanitize(svg, { USE_PROFILES: { svg: true, svgFilters: true } }) }}
dangerouslySetInnerHTML={{ __html: DOMPurify.sanitize(svg, { USE_PROFILES: { svg: true, svgFilters: true }, ADD_TAGS: ['foreignObject'] }) }}
/>
</div>
</div>
{/* Use ProcessFlowModal for expansion */}
{showModal && processData && (
<ProcessFlowModal
process={processData}
onClose={() => setShowModal(false)}
/>
<Suspense
fallback={
<div className="p-4 text-sm text-text-muted">Loading diagram</div>
}
>
<ProcessFlowModal
process={processData}
onClose={() => setShowModal(false)}
/>
</Suspense>
)}
</>
);

View file

@ -148,7 +148,7 @@ export const ProcessFlowModal = ({ process, onClose, onFocusInGraph, isFullScree
const { svg } = await mermaid.render(id, mermaidCode);
if (!diagramRef.current) return;
diagramRef.current!.innerHTML = DOMPurify.sanitize(svg, { USE_PROFILES: { svg: true, svgFilters: true } });
diagramRef.current!.innerHTML = DOMPurify.sanitize(svg, { USE_PROFILES: { svg: true, svgFilters: true }, ADD_TAGS: ['foreignObject'] });
} catch (error) {
console.error('Mermaid render error:', error);
const errorMessage = error instanceof Error ? error.message : String(error);

View file

@ -1,5 +1,5 @@
import { useState, useRef, useEffect, useCallback } from 'react';
import { Terminal, Play, X, ChevronDown, ChevronUp, Loader2, Sparkles, Table } from 'lucide-react';
import { Terminal, Play, X, ChevronDown, ChevronUp, Loader2, Sparkles, Table } from '@/lib/lucide-icons';
import { useAppState } from '../hooks/useAppState';
const EXAMPLE_QUERIES = [

View file

@ -2,7 +2,7 @@ import { useState, useRef, useEffect, useCallback } from 'react';
import {
Send, Square, Sparkles, User,
PanelRightClose, Loader2, AlertTriangle, GitBranch
} from 'lucide-react';
} from '@/lib/lucide-icons';
import { useAppState } from '../hooks/useAppState';
import { ToolCallCard } from './ToolCallCard';
import { isProviderConfigured } from '../core/llm/settings-service';

View file

@ -1,5 +1,5 @@
import { useState, useEffect, useCallback, useRef, useMemo } from 'react';
import { X, Key, Server, Brain, Check, AlertCircle, Eye, EyeOff, RefreshCw, ChevronDown, Loader2, Search } from 'lucide-react';
import { X, Key, Server, Brain, Check, AlertCircle, Eye, EyeOff, RefreshCw, ChevronDown, Loader2, Search } from '@/lib/lucide-icons';
import {
loadSettings,
saveSettings,
@ -7,6 +7,8 @@ import {
fetchOpenRouterModels,
} from '../core/llm/settings-service';
import type { LLMSettings, LLMProvider } from '../core/llm/types';
import { DEFAULT_OLLAMA_BASE_URL } from '../config/ui-constants';
import { ProviderConfigCard } from './settings/ProviderConfigCard';
interface SettingsPanelProps {
isOpen: boolean;
@ -216,6 +218,7 @@ export const SettingsPanel = ({ isOpen, onClose, onSettingsSaved, backendUrl, is
const [settings, setSettings] = useState<LLMSettings>(loadSettings);
const [showApiKey, setShowApiKey] = useState<Record<string, boolean>>({});
const [saveStatus, setSaveStatus] = useState<'idle' | 'saved' | 'error'>('idle');
const saveTimerRef = useRef<ReturnType<typeof setTimeout>>(undefined);
// Ollama connection state
const [ollamaError, setOllamaError] = useState<string | null>(null);
const [isCheckingOllama, setIsCheckingOllama] = useState(false);
@ -223,6 +226,15 @@ export const SettingsPanel = ({ isOpen, onClose, onSettingsSaved, backendUrl, is
const [openRouterModels, setOpenRouterModels] = useState<Array<{ id: string; name: string }>>([]);
const [isLoadingModels, setIsLoadingModels] = useState(false);
// Clean up save timer on unmount
useEffect(() => {
return () => {
if (saveTimerRef.current) {
clearTimeout(saveTimerRef.current);
}
};
}, []);
// Load settings when panel opens
useEffect(() => {
if (isOpen) {
@ -252,7 +264,7 @@ export const SettingsPanel = ({ isOpen, onClose, onSettingsSaved, backendUrl, is
useEffect(() => {
if (settings.activeProvider === 'ollama') {
const baseUrl = settings.ollama?.baseUrl ?? 'http://localhost:11434';
const baseUrl = settings.ollama?.baseUrl ?? DEFAULT_OLLAMA_BASE_URL;
const timer = setTimeout(() => {
checkOllamaConnection(baseUrl);
}, 300);
@ -269,7 +281,10 @@ export const SettingsPanel = ({ isOpen, onClose, onSettingsSaved, backendUrl, is
saveSettings(settings);
setSaveStatus('saved');
onSettingsSaved?.();
setTimeout(() => setSaveStatus('idle'), 2000);
if (saveTimerRef.current) {
clearTimeout(saveTimerRef.current);
}
saveTimerRef.current = setTimeout(() => setSaveStatus('idle'), 2000);
} catch {
setSaveStatus('error');
}
@ -374,60 +389,36 @@ export const SettingsPanel = ({ isOpen, onClose, onSettingsSaved, backendUrl, is
</div>
</div>
<div className="p-3 bg-amber-500/10 border border-amber-500/30 rounded-xl text-xs text-amber-200">
API keys are stored in session storage and will be cleared when you close this tab.
</div>
{/* OpenAI Settings */}
{settings.activeProvider === 'openai' && (
<div className="space-y-4 animate-fade-in">
<div className="space-y-2">
<label className="flex items-center gap-2 text-sm font-medium text-text-secondary">
<Key className="w-4 h-4" />
API Key
</label>
<div className="relative">
<input
type={showApiKey['openai'] ? 'text' : 'password'}
value={settings.openai?.apiKey ?? ''}
onChange={e => setSettings(prev => ({
...prev,
openai: { ...prev.openai!, apiKey: e.target.value }
}))}
placeholder="Enter your OpenAI API key"
className="w-full px-4 py-3 pr-12 bg-elevated border border-border-subtle rounded-xl text-text-primary placeholder:text-text-muted focus:border-accent focus:ring-2 focus:ring-accent/20 outline-none transition-all"
/>
<button
type="button"
onClick={() => toggleApiKeyVisibility('openai')}
className="absolute right-3 top-1/2 -translate-y-1/2 p-1 text-text-muted hover:text-text-primary transition-colors"
>
{showApiKey['openai'] ? <EyeOff className="w-4 h-4" /> : <Eye className="w-4 h-4" />}
</button>
</div>
<p className="text-xs text-text-muted">
Get your API key from{' '}
<a
href="https://platform.openai.com/api-keys"
target="_blank"
rel="noopener noreferrer"
className="text-accent hover:underline"
>
OpenAI Platform
</a>
</p>
</div>
<div className="space-y-2">
<label className="text-sm font-medium text-text-secondary">Model</label>
<input
type="text"
value={settings.openai?.model ?? 'gpt-5.2-chat'}
onChange={e => setSettings(prev => ({
...prev,
openai: { ...prev.openai!, model: e.target.value }
}))}
placeholder="e.g., gpt-4o, gpt-4-turbo, gpt-3.5-turbo"
className="w-full px-4 py-3 bg-elevated border border-border-subtle rounded-xl text-text-primary placeholder:text-text-muted focus:border-accent focus:ring-2 focus:ring-accent/20 outline-none transition-all font-mono text-sm"
/>
</div>
<ProviderConfigCard
title="OpenAI"
apiKey={{
value: settings.openai?.apiKey ?? '',
placeholder: 'Enter your OpenAI API key',
helperText: 'Get your API key from',
helperLink: 'https://platform.openai.com/api-keys',
helperLinkLabel: 'OpenAI Platform',
isVisible: !!showApiKey['openai'],
onChange: (value) => setSettings(prev => ({
...prev,
openai: { ...prev.openai!, apiKey: value }
})),
onToggleVisibility: () => toggleApiKeyVisibility('openai'),
}}
model={{
value: settings.openai?.model ?? 'gpt-5.2-chat',
placeholder: 'e.g., gpt-4o, gpt-4-turbo, gpt-3.5-turbo',
onChange: (value) => setSettings(prev => ({
...prev,
openai: { ...prev.openai!, model: value }
})),
}}
>
<div className="space-y-2">
<label className="flex items-center gap-2 text-sm font-medium text-text-secondary">
<Server className="w-4 h-4" />
@ -447,119 +438,63 @@ export const SettingsPanel = ({ isOpen, onClose, onSettingsSaved, backendUrl, is
Leave empty to use the default OpenAI API. Set a custom URL for proxies or compatible APIs.
</p>
</div>
</div>
</ProviderConfigCard>
)}
{/* Gemini Settings */}
{settings.activeProvider === 'gemini' && (
<div className="space-y-4 animate-fade-in">
<div className="space-y-2">
<label className="flex items-center gap-2 text-sm font-medium text-text-secondary">
<Key className="w-4 h-4" />
API Key
</label>
<div className="relative">
<input
type={showApiKey['gemini'] ? 'text' : 'password'}
value={settings.gemini?.apiKey ?? ''}
onChange={e => setSettings(prev => ({
...prev,
gemini: { ...prev.gemini!, apiKey: e.target.value }
}))}
placeholder="Enter your Google AI API key"
className="w-full px-4 py-3 pr-12 bg-elevated border border-border-subtle rounded-xl text-text-primary placeholder:text-text-muted focus:border-accent focus:ring-2 focus:ring-accent/20 outline-none transition-all"
/>
<button
type="button"
onClick={() => toggleApiKeyVisibility('gemini')}
className="absolute right-3 top-1/2 -translate-y-1/2 p-1 text-text-muted hover:text-text-primary transition-colors"
>
{showApiKey['gemini'] ? <EyeOff className="w-4 h-4" /> : <Eye className="w-4 h-4" />}
</button>
</div>
<p className="text-xs text-text-muted">
Get your API key from{' '}
<a
href="https://aistudio.google.com/app/apikey"
target="_blank"
rel="noopener noreferrer"
className="text-accent hover:underline"
>
Google AI Studio
</a>
</p>
</div>
<div className="space-y-2">
<label className="text-sm font-medium text-text-secondary">Model</label>
<input
type="text"
value={settings.gemini?.model ?? 'gemini-2.0-flash'}
onChange={e => setSettings(prev => ({
...prev,
gemini: { ...prev.gemini!, model: e.target.value }
}))}
placeholder="e.g., gemini-2.0-flash, gemini-1.5-pro"
className="w-full px-4 py-3 bg-elevated border border-border-subtle rounded-xl text-text-primary placeholder:text-text-muted focus:border-accent focus:ring-2 focus:ring-accent/20 outline-none transition-all font-mono text-sm"
/>
</div>
</div>
<ProviderConfigCard
title="Google Gemini"
apiKey={{
value: settings.gemini?.apiKey ?? '',
placeholder: 'Enter your Google AI API key',
helperText: 'Get your API key from',
helperLink: 'https://aistudio.google.com/app/apikey',
helperLinkLabel: 'Google AI Studio',
isVisible: !!showApiKey['gemini'],
onChange: (value) => setSettings(prev => ({
...prev,
gemini: { ...prev.gemini!, apiKey: value }
})),
onToggleVisibility: () => toggleApiKeyVisibility('gemini'),
}}
model={{
value: settings.gemini?.model ?? 'gemini-2.0-flash',
placeholder: 'e.g., gemini-2.0-flash, gemini-1.5-pro',
onChange: (value) => setSettings(prev => ({
...prev,
gemini: { ...prev.gemini!, model: value }
})),
}}
/>
)}
{/* Anthropic Settings */}
{settings.activeProvider === 'anthropic' && (
<div className="space-y-4 animate-fade-in">
<div className="space-y-2">
<label className="flex items-center gap-2 text-sm font-medium text-text-secondary">
<Key className="w-4 h-4" />
API Key
</label>
<div className="relative">
<input
type={showApiKey['anthropic'] ? 'text' : 'password'}
value={settings.anthropic?.apiKey ?? ''}
onChange={e => setSettings(prev => ({
...prev,
anthropic: { ...prev.anthropic!, apiKey: e.target.value }
}))}
placeholder="Enter your Anthropic API key"
className="w-full px-4 py-3 pr-12 bg-elevated border border-border-subtle rounded-xl text-text-primary placeholder:text-text-muted focus:border-accent focus:ring-2 focus:ring-accent/20 outline-none transition-all"
/>
<button
type="button"
onClick={() => toggleApiKeyVisibility('anthropic')}
className="absolute right-3 top-1/2 -translate-y-1/2 p-1 text-text-muted hover:text-text-primary transition-colors"
>
{showApiKey['anthropic'] ? <EyeOff className="w-4 h-4" /> : <Eye className="w-4 h-4" />}
</button>
</div>
<p className="text-xs text-text-muted">
Get your API key from{' '}
<a
href="https://console.anthropic.com/settings/keys"
target="_blank"
rel="noopener noreferrer"
className="text-accent hover:underline"
>
Anthropic Console
</a>
</p>
</div>
<div className="space-y-2">
<label className="text-sm font-medium text-text-secondary">Model</label>
<input
type="text"
value={settings.anthropic?.model ?? 'claude-sonnet-4-20250514'}
onChange={e => setSettings(prev => ({
...prev,
anthropic: { ...prev.anthropic!, model: e.target.value }
}))}
placeholder="e.g., claude-sonnet-4-20250514, claude-3-opus"
className="w-full px-4 py-3 bg-elevated border border-border-subtle rounded-xl text-text-primary placeholder:text-text-muted focus:border-accent focus:ring-2 focus:ring-accent/20 outline-none transition-all font-mono text-sm"
/>
</div>
</div>
<ProviderConfigCard
title="Anthropic"
apiKey={{
value: settings.anthropic?.apiKey ?? '',
placeholder: 'Enter your Anthropic API key',
helperText: 'Get your API key from',
helperLink: 'https://console.anthropic.com/settings/keys',
helperLinkLabel: 'Anthropic Console',
isVisible: !!showApiKey['anthropic'],
onChange: (value) => setSettings(prev => ({
...prev,
anthropic: { ...prev.anthropic!, apiKey: value }
})),
onToggleVisibility: () => toggleApiKeyVisibility('anthropic'),
}}
model={{
value: settings.anthropic?.model ?? 'claude-sonnet-4-20250514',
placeholder: 'e.g., claude-sonnet-4-20250514, claude-3-opus',
onChange: (value) => setSettings(prev => ({
...prev,
anthropic: { ...prev.anthropic!, model: value }
})),
}}
/>
)}
{/* Azure OpenAI Settings */}
@ -695,17 +630,17 @@ export const SettingsPanel = ({ isOpen, onClose, onSettingsSaved, backendUrl, is
<div className="flex gap-2">
<input
type="url"
value={settings.ollama?.baseUrl ?? 'http://localhost:11434'}
value={settings.ollama?.baseUrl ?? DEFAULT_OLLAMA_BASE_URL}
onChange={e => setSettings(prev => ({
...prev,
ollama: { ...prev.ollama!, baseUrl: e.target.value }
}))}
placeholder="http://localhost:11434"
placeholder={DEFAULT_OLLAMA_BASE_URL}
className="flex-1 px-4 py-3 bg-elevated border border-border-subtle rounded-xl text-text-primary placeholder:text-text-muted focus:border-accent focus:ring-2 focus:ring-accent/20 outline-none transition-all font-mono text-sm"
/>
<button
type="button"
onClick={() => checkOllamaConnection(settings.ollama?.baseUrl ?? 'http://localhost:11434')}
onClick={() => checkOllamaConnection(settings.ollama?.baseUrl ?? DEFAULT_OLLAMA_BASE_URL)}
disabled={isCheckingOllama}
className="px-3 py-3 bg-elevated border border-border-subtle rounded-xl text-text-secondary hover:text-text-primary hover:border-accent/50 transition-colors disabled:opacity-50"
title="Check connection"
@ -749,44 +684,22 @@ export const SettingsPanel = ({ isOpen, onClose, onSettingsSaved, backendUrl, is
{/* OpenRouter Settings */}
{settings.activeProvider === 'openrouter' && (
<div className="space-y-4 animate-fade-in">
<div className="space-y-2">
<label className="flex items-center gap-2 text-sm font-medium text-text-secondary">
<Key className="w-4 h-4" />
API Key
</label>
<div className="relative">
<input
type={showApiKey['openrouter'] ? 'text' : 'password'}
value={settings.openrouter?.apiKey ?? ''}
onChange={e => setSettings(prev => ({
...prev,
openrouter: { ...prev.openrouter!, apiKey: e.target.value }
}))}
placeholder="Enter your OpenRouter API key"
className="w-full px-4 py-3 pr-12 bg-elevated border border-border-subtle rounded-xl text-text-primary placeholder:text-text-muted focus:border-accent focus:ring-2 focus:ring-accent/20 outline-none transition-all"
/>
<button
type="button"
onClick={() => toggleApiKeyVisibility('openrouter')}
className="absolute right-3 top-1/2 -translate-y-1/2 p-1 text-text-muted hover:text-text-primary transition-colors"
>
{showApiKey['openrouter'] ? <EyeOff className="w-4 h-4" /> : <Eye className="w-4 h-4" />}
</button>
</div>
<p className="text-xs text-text-muted">
Get your API key from{' '}
<a
href="https://openrouter.ai/keys"
target="_blank"
rel="noopener noreferrer"
className="text-accent hover:underline"
>
OpenRouter Keys
</a>
</p>
</div>
<ProviderConfigCard
title="OpenRouter"
apiKey={{
value: settings.openrouter?.apiKey ?? '',
placeholder: 'Enter your OpenRouter API key',
helperText: 'Get your API key from',
helperLink: 'https://openrouter.ai/keys',
helperLinkLabel: 'OpenRouter Keys',
isVisible: !!showApiKey['openrouter'],
onChange: (value) => setSettings(prev => ({
...prev,
openrouter: { ...prev.openrouter!, apiKey: value }
})),
onToggleVisibility: () => toggleApiKeyVisibility('openrouter'),
}}
>
<div className="space-y-2">
<label className="text-sm font-medium text-text-secondary">Model</label>
<OpenRouterModelCombobox
@ -811,66 +724,36 @@ export const SettingsPanel = ({ isOpen, onClose, onSettingsSaved, backendUrl, is
</a>
</p>
</div>
</div>
</ProviderConfigCard>
)}
{/* MiniMax Settings */}
{settings.activeProvider === 'minimax' && (
<div className="space-y-4 animate-fade-in">
<div className="space-y-2">
<label className="flex items-center gap-2 text-sm font-medium text-text-secondary">
<Key className="w-4 h-4" />
API Key
</label>
<div className="relative">
<input
type={showApiKey['minimax'] ? 'text' : 'password'}
value={settings.minimax?.apiKey ?? ''}
onChange={e => setSettings(prev => ({
...prev,
minimax: { ...prev.minimax!, apiKey: e.target.value }
}))}
placeholder="Enter your MiniMax API key"
className="w-full px-4 py-3 pr-12 bg-elevated border border-border-subtle rounded-xl text-text-primary placeholder:text-text-muted focus:border-accent focus:ring-2 focus:ring-accent/20 outline-none transition-all"
/>
<button
type="button"
onClick={() => toggleApiKeyVisibility('minimax')}
className="absolute right-3 top-1/2 -translate-y-1/2 p-1 text-text-muted hover:text-text-primary transition-colors"
>
{showApiKey['minimax'] ? <EyeOff className="w-4 h-4" /> : <Eye className="w-4 h-4" />}
</button>
</div>
<p className="text-xs text-text-muted">
Get your API key from{' '}
<a
href="https://platform.minimax.io"
target="_blank"
rel="noopener noreferrer"
className="text-accent hover:underline"
>
MiniMax Platform
</a>
</p>
</div>
<div className="space-y-2">
<label className="text-sm font-medium text-text-secondary">Model</label>
<input
type="text"
value={settings.minimax?.model ?? 'MiniMax-M2.5'}
onChange={e => setSettings(prev => ({
...prev,
minimax: { ...prev.minimax!, model: e.target.value }
}))}
placeholder="e.g., MiniMax-M2.5, MiniMax-M2.5-highspeed"
className="w-full px-4 py-3 bg-elevated border border-border-subtle rounded-xl text-text-primary placeholder:text-text-muted focus:border-accent focus:ring-2 focus:ring-accent/20 outline-none transition-all font-mono text-sm"
/>
<p className="text-xs text-text-muted">
Available models: MiniMax-M2.5 (default), MiniMax-M2.5-highspeed (faster)
</p>
</div>
</div>
<ProviderConfigCard
title="MiniMax"
apiKey={{
value: settings.minimax?.apiKey ?? '',
placeholder: 'Enter your MiniMax API key',
helperText: 'Get your API key from',
helperLink: 'https://platform.minimax.io',
helperLinkLabel: 'MiniMax Platform',
isVisible: !!showApiKey['minimax'],
onChange: (value) => setSettings(prev => ({
...prev,
minimax: { ...prev.minimax!, apiKey: value }
})),
onToggleVisibility: () => toggleApiKeyVisibility('minimax'),
}}
model={{
value: settings.minimax?.model ?? 'MiniMax-M2.5',
placeholder: 'e.g., MiniMax-M2.5, MiniMax-M2.5-highspeed',
onChange: (value) => setSettings(prev => ({
...prev,
minimax: { ...prev.minimax!, model: value }
})),
helperText: 'Available: MiniMax-M2.5 (default), MiniMax-M2.5-highspeed (faster)',
}}
/>
)}
{/* Privacy Note */}
@ -880,8 +763,7 @@ export const SettingsPanel = ({ isOpen, onClose, onSettingsSaved, backendUrl, is
🔒
</div>
<div className="text-xs text-text-muted leading-relaxed">
<span className="text-text-secondary font-medium">Privacy:</span> Your API keys are stored only in your browser's local storage.
They're sent directly to the LLM provider when you chat. Your code never leaves your machine.
<span className="text-text-secondary font-medium">Privacy:</span> Your API keys are stored only in your browser's session storage and are cleared when the tab closes. They're sent directly to the LLM provider when you chat. Your code never leaves your machine.
</div>
</div>
</div>

View file

@ -1,4 +1,5 @@
import { Heart } from 'lucide-react';
import { useMemo } from 'react';
import { Heart } from '@/lib/lucide-icons';
import { useAppState } from '../hooks/useAppState';
export const StatusBar = () => {
@ -8,7 +9,7 @@ export const StatusBar = () => {
const edgeCount = graph?.relationships.length ?? 0;
// Detect primary language
const primaryLanguage = (() => {
const primaryLanguage = useMemo(() => {
if (!graph) return null;
const languages = graph.nodes
.map(n => n.properties.language)
@ -21,7 +22,7 @@ export const StatusBar = () => {
}, {} as Record<string, number>);
return Object.entries(counts).sort((a, b) => b[1] - a[1])[0]?.[0];
})();
}, [graph]);
return (
<footer className="flex items-center justify-between px-5 py-2 bg-deep border-t border-dashed border-border-subtle text-[11px] text-text-muted">
@ -38,7 +39,7 @@ export const StatusBar = () => {
<span>{progress.message}</span>
</>
) : (
<div className="flex items-center gap-1.5">
<div className="flex items-center gap-1.5" data-testid="status-ready">
<span className="w-1.5 h-1.5 bg-node-function rounded-full" />
<span>Ready</span>
</div>

View file

@ -6,7 +6,7 @@
*/
import { useState } from 'react';
import { ChevronDown, ChevronRight, Sparkles, Check, Loader2, AlertCircle } from 'lucide-react';
import { ChevronDown, ChevronRight, Sparkles, Check, Loader2, AlertCircle } from '@/lib/lucide-icons';
import type { ToolCallInfo } from '../core/llm/types';
interface ToolCallCardProps {

View file

@ -1,5 +1,5 @@
import { useState, useEffect } from 'react';
import { X, Snail, Rocket, SkipForward } from 'lucide-react';
import { X, Snail, Rocket, SkipForward } from '@/lib/lucide-icons';
interface WebGPUFallbackDialogProps {
isOpen: boolean;

View file

@ -0,0 +1,110 @@
import { ReactNode } from 'react';
import { Eye, EyeOff, Key } from '@/lib/lucide-icons';
type ApiKeyField = {
value: string;
placeholder: string;
helperText?: string;
helperLink?: string;
helperLinkLabel?: string;
isVisible: boolean;
onChange: (value: string) => void;
onToggleVisibility: () => void;
};
type ModelField = {
value: string;
placeholder: string;
label?: string;
helperText?: string;
onChange: (value: string) => void;
};
interface ProviderConfigCardProps {
title: string;
description?: string;
apiKey?: ApiKeyField;
model?: ModelField;
children?: ReactNode;
}
export const ProviderConfigCard = ({
title,
description,
apiKey,
model,
children,
}: ProviderConfigCardProps) => {
return (
<div className="space-y-4 animate-fade-in">
<div className="flex items-center justify-between">
<div>
<h3 className="text-sm font-semibold text-text-primary">{title}</h3>
{description ? (
<p className="text-xs text-text-muted">{description}</p>
) : null}
</div>
</div>
{apiKey && (
<div className="space-y-2">
<label className="flex items-center gap-2 text-sm font-medium text-text-secondary">
<Key className="w-4 h-4" />
API Key
</label>
<div className="relative">
<input
type={apiKey.isVisible ? 'text' : 'password'}
value={apiKey.value}
onChange={e => apiKey.onChange(e.target.value)}
placeholder={apiKey.placeholder}
className="w-full px-4 py-3 pr-12 bg-elevated border border-border-subtle rounded-xl text-text-primary placeholder:text-text-muted focus:border-accent focus:ring-2 focus:ring-accent/20 outline-none transition-all"
/>
<button
type="button"
onClick={apiKey.onToggleVisibility}
className="absolute right-3 top-1/2 -translate-y-1/2 p-1 text-text-muted hover:text-text-primary transition-colors"
>
{apiKey.isVisible ? <EyeOff className="w-4 h-4" /> : <Eye className="w-4 h-4" />}
</button>
</div>
{apiKey.helperText && (
<p className="text-xs text-text-muted">
{apiKey.helperText}{' '}
{apiKey.helperLink ? (
<a
href={apiKey.helperLink}
target="_blank"
rel="noopener noreferrer"
className="text-accent hover:underline"
>
{apiKey.helperLinkLabel ?? 'Learn more'}
</a>
) : null}
</p>
)}
</div>
)}
{model && (
<div className="space-y-2">
<label className="text-sm font-medium text-text-secondary">
{model.label ?? 'Model'}
</label>
<input
type="text"
value={model.value}
onChange={e => model.onChange(e.target.value)}
placeholder={model.placeholder}
className="w-full px-4 py-3 bg-elevated border border-border-subtle rounded-xl text-text-primary placeholder:text-text-muted focus:border-accent focus:ring-2 focus:ring-accent/20 outline-none transition-all font-mono text-sm"
/>
{model.helperText ? (
<p className="text-xs text-text-muted">{model.helperText}</p>
) : null}
</div>
)}
{children}
</div>
);
};

View file

@ -0,0 +1,7 @@
// Centralized UI and provider defaults to reduce magic numbers and duplicated URLs.
export const ERROR_RESET_DELAY_MS = 3000;
export const BACKEND_URL_DEBOUNCE_MS = 500;
export const DEFAULT_BACKEND_URL = 'http://localhost:4747';
export const DEFAULT_OLLAMA_BASE_URL = 'http://localhost:11434';
export const DEFAULT_OPENROUTER_BASE_URL = 'https://openrouter.ai/api/v1';

View file

@ -226,7 +226,7 @@ export const loadGraphToLbug = async (
}
// Execute batched prepared statements per label pair
const SUB_BATCH_SIZE = 4;
// Prepare once per (fromLabel, toLabel) pair and reuse across all rows
for (const [key, rels] of relsByLabelPair) {
const [fromLabel, toLabel] = key.split(':');
const cypher = `
@ -235,39 +235,37 @@ export const loadGraphToLbug = async (
CREATE (a)-[:${REL_TABLE_NAME} {type: $relType, confidence: $confidence, reason: $reason, step: $step}]->(b)
`;
for (let i = 0; i < rels.length; i += SUB_BATCH_SIZE) {
const subBatch = rels.slice(i, i + SUB_BATCH_SIZE);
const stmt = await conn.prepare(cypher);
if (!stmt.isSuccess()) {
const errMsg = await stmt.getErrorMessage();
if (import.meta.env.DEV) console.warn(`Prepare failed for ${key}: ${errMsg}`);
skippedRels += subBatch.length;
await stmt.close();
continue;
}
const stmt = await conn.prepare(cypher);
if (!stmt.isSuccess()) {
const errMsg = await stmt.getErrorMessage();
if (import.meta.env.DEV) console.warn(`Prepare failed for ${key}: ${errMsg}`);
skippedRels += rels.length;
await stmt.close();
continue;
}
try {
for (const r of subBatch) {
try {
await conn.execute(stmt, r);
insertedRels++;
} catch (err) {
skippedRels++;
const statKey = `${r.relType}:${fromLabel}->${toLabel}`;
skippedRelStats.set(statKey, (skippedRelStats.get(statKey) || 0) + 1);
if (import.meta.env.DEV) {
console.warn(`⚠️ Skipped: ${statKey} | "${r.fromId}" → "${r.toId}" | ${err instanceof Error ? err.message : String(err)}`);
}
try {
for (let i = 0; i < rels.length; i++) {
try {
await conn.execute(stmt, rels[i]);
insertedRels++;
} catch (err) {
skippedRels++;
const r = rels[i];
const statKey = `${r.relType}:${fromLabel}->${toLabel}`;
skippedRelStats.set(statKey, (skippedRelStats.get(statKey) || 0) + 1);
if (import.meta.env.DEV) {
console.warn(`⚠️ Skipped: ${statKey} | "${r.fromId}" → "${r.toId}" | ${err instanceof Error ? err.message : String(err)}`);
}
}
} finally {
await stmt.close();
}
// Yield to event loop between sub-batches
if (i + SUB_BATCH_SIZE < rels.length) {
await new Promise(r => setTimeout(r, 0));
// Yield to event loop every 500 relations
if (i > 0 && i % 500 === 0) {
await new Promise(r => setTimeout(r, 0));
}
}
} finally {
await stmt.close();
}
}

View file

@ -28,6 +28,7 @@ import {
type CodebaseContext,
buildDynamicSystemPrompt,
} from './context-builder';
import { DEFAULT_OLLAMA_BASE_URL, DEFAULT_OPENROUTER_BASE_URL } from '../../config/ui-constants';
/**
* System prompt for the Graph RAG agent
@ -184,7 +185,7 @@ export const createChatModel = (config: ProviderConfig): BaseChatModel => {
case 'ollama': {
const ollamaConfig = config as OllamaConfig;
return new ChatOllama({
baseUrl: ollamaConfig.baseUrl ?? 'http://localhost:11434',
baseUrl: ollamaConfig.baseUrl ?? DEFAULT_OLLAMA_BASE_URL,
model: ollamaConfig.model,
temperature: ollamaConfig.temperature ?? 0.1,
streaming: true,
@ -203,7 +204,6 @@ export const createChatModel = (config: ProviderConfig): BaseChatModel => {
if (import.meta.env.DEV) {
console.log('🌐 OpenRouter config:', {
hasApiKey: !!openRouterConfig.apiKey,
apiKeyLength: openRouterConfig.apiKey?.length || 0,
model: openRouterConfig.model,
baseUrl: openRouterConfig.baseUrl,
});
@ -221,7 +221,7 @@ export const createChatModel = (config: ProviderConfig): BaseChatModel => {
maxTokens: openRouterConfig.maxTokens,
configuration: {
apiKey: openRouterConfig.apiKey, // Ensure client receives it
baseURL: openRouterConfig.baseUrl ?? 'https://openrouter.ai/api/v1',
baseURL: openRouterConfig.baseUrl ?? DEFAULT_OPENROUTER_BASE_URL,
},
streaming: true,
});
@ -355,8 +355,8 @@ export async function* streamAgentResponse(
const yieldedToolCalls = new Set<string>();
const yieldedToolResults = new Set<string>();
let lastProcessedMsgCount = formattedMessages.length;
// Track if all tools are done (for distinguishing reasoning vs final content)
let allToolsDone = true;
// Track pending tool calls (for distinguishing reasoning vs final content)
let pendingToolCalls = 0;
// Track if we've seen any tool calls in this response turn.
// Anything before the first tool call should be treated as "reasoning/narration"
// so the UI can show the Cursor-like loop: plan → tool → update → tool → answer.
@ -420,7 +420,7 @@ export async function* streamAgentResponse(
const isReasoning =
!hasSeenToolCallThisTurn ||
toolCalls.length > 0 ||
!allToolsDone;
pendingToolCalls > 0;
yield {
type: isReasoning ? 'reasoning' : 'content',
[isReasoning ? 'reasoning' : 'content']: content,
@ -430,17 +430,23 @@ export async function* streamAgentResponse(
// Track tool calls from message chunks
if (toolCalls.length > 0) {
hasSeenToolCallThisTurn = true;
allToolsDone = false;
pendingToolCalls += toolCalls.length;
for (const tc of toolCalls) {
const toolId = tc.id || `tool-${Date.now()}-${Math.random().toString(36).slice(2)}`;
if (!yieldedToolCalls.has(toolId)) {
yieldedToolCalls.add(toolId);
let parsedArgs: Record<string, any>;
try {
parsedArgs = tc.function?.arguments ? JSON.parse(tc.function.arguments) : {};
} catch {
parsedArgs = {};
}
yield {
type: 'tool_call',
toolCall: {
id: toolId,
name: tc.name || tc.function?.name || 'unknown',
args: tc.args || (tc.function?.arguments ? JSON.parse(tc.function.arguments) : {}),
args: tc.args || parsedArgs,
status: 'running',
},
};
@ -465,8 +471,8 @@ export async function* streamAgentResponse(
status: 'completed',
},
};
// After tool result, next AI content could be reasoning or final
allToolsDone = true;
// After tool result, decrement pending count
pendingToolCalls = Math.max(0, pendingToolCalls - 1);
}
}
}
@ -486,7 +492,7 @@ export async function* streamAgentResponse(
for (const tc of toolCalls) {
const toolId = tc.id || `tool-${Date.now()}`;
if (!yieldedToolCalls.has(toolId)) {
allToolsDone = false;
pendingToolCalls++;
yieldedToolCalls.add(toolId);
yield {
type: 'tool_call',
@ -517,7 +523,7 @@ export async function* streamAgentResponse(
status: 'completed',
},
};
allToolsDone = true;
pendingToolCalls = Math.max(0, pendingToolCalls - 1);
}
}
}

View file

@ -18,54 +18,85 @@ import {
MiniMaxConfig,
ProviderConfig,
} from './types';
import { DEFAULT_OPENROUTER_BASE_URL, DEFAULT_OLLAMA_BASE_URL } from '../../config/ui-constants';
const STORAGE_KEY = 'gitnexus-llm-settings';
const mergeWithDefaults = (parsed?: Partial<LLMSettings> | null): LLMSettings => ({
...DEFAULT_LLM_SETTINGS,
...parsed,
openai: {
...DEFAULT_LLM_SETTINGS.openai,
...parsed?.openai,
},
azureOpenAI: {
...DEFAULT_LLM_SETTINGS.azureOpenAI,
...parsed?.azureOpenAI,
},
gemini: {
...DEFAULT_LLM_SETTINGS.gemini,
...parsed?.gemini,
},
anthropic: {
...DEFAULT_LLM_SETTINGS.anthropic,
...parsed?.anthropic,
},
ollama: {
...DEFAULT_LLM_SETTINGS.ollama,
...parsed?.ollama,
},
openrouter: {
...DEFAULT_LLM_SETTINGS.openrouter,
...parsed?.openrouter,
},
minimax: {
...DEFAULT_LLM_SETTINGS.minimax,
...parsed?.minimax,
},
});
const readSettings = (storage: Storage): Partial<LLMSettings> | null => {
const raw = storage.getItem(STORAGE_KEY);
if (!raw) return null;
try {
return JSON.parse(raw) as Partial<LLMSettings>;
} catch (error) {
console.warn('Failed to parse LLM settings:', error);
return null;
}
};
const writeSettings = (storage: Storage, settings: LLMSettings): void => {
storage.setItem(STORAGE_KEY, JSON.stringify(settings));
};
/**
* Load settings from localStorage
* Load settings from sessionStorage (migrates legacy localStorage once).
*/
export const loadSettings = (): LLMSettings => {
try {
const stored = localStorage.getItem(STORAGE_KEY);
if (!stored) {
return DEFAULT_LLM_SETTINGS;
const sessionData = typeof sessionStorage !== 'undefined' ? readSettings(sessionStorage) : null;
if (sessionData) {
return mergeWithDefaults(sessionData);
}
const parsed = JSON.parse(stored) as Partial<LLMSettings>;
// Merge with defaults to handle new fields
return {
...DEFAULT_LLM_SETTINGS,
...parsed,
openai: {
...DEFAULT_LLM_SETTINGS.openai,
...parsed.openai,
},
azureOpenAI: {
...DEFAULT_LLM_SETTINGS.azureOpenAI,
...parsed.azureOpenAI,
},
gemini: {
...DEFAULT_LLM_SETTINGS.gemini,
...parsed.gemini,
},
anthropic: {
...DEFAULT_LLM_SETTINGS.anthropic,
...parsed.anthropic,
},
ollama: {
...DEFAULT_LLM_SETTINGS.ollama,
...parsed.ollama,
},
openrouter: {
...DEFAULT_LLM_SETTINGS.openrouter,
...parsed.openrouter,
},
minimax: {
...DEFAULT_LLM_SETTINGS.minimax,
...parsed.minimax,
},
};
const legacyData = typeof localStorage !== 'undefined' ? readSettings(localStorage) : null;
if (legacyData) {
const merged = mergeWithDefaults(legacyData);
try {
if (typeof sessionStorage !== 'undefined') {
writeSettings(sessionStorage, merged);
}
if (typeof localStorage !== 'undefined') {
localStorage.removeItem(STORAGE_KEY);
}
} catch (error) {
console.warn('Failed to migrate legacy LLM settings to sessionStorage:', error);
}
return merged;
}
return DEFAULT_LLM_SETTINGS;
} catch (error) {
console.warn('Failed to load LLM settings:', error);
return DEFAULT_LLM_SETTINGS;
@ -73,11 +104,13 @@ export const loadSettings = (): LLMSettings => {
};
/**
* Save settings to localStorage
* Save settings to sessionStorage
*/
export const saveSettings = (settings: LLMSettings): void => {
try {
localStorage.setItem(STORAGE_KEY, JSON.stringify(settings));
if (typeof sessionStorage !== 'undefined') {
writeSettings(sessionStorage, settings);
}
} catch (error) {
console.error('Failed to save LLM settings:', error);
}
@ -204,77 +237,53 @@ export const setActiveProvider = (provider: LLMProvider): LLMSettings => {
/**
* Get the current provider configuration
*/
type ProviderBuilder = (settings: LLMSettings) => ProviderConfig | null;
const providerBuilders: Record<LLMProvider, ProviderBuilder> = {
openai: (settings) => {
if (!settings.openai?.apiKey) return null;
return { provider: 'openai', ...settings.openai } as OpenAIConfig;
},
'azure-openai': (settings) => {
if (!settings.azureOpenAI?.apiKey || !settings.azureOpenAI?.endpoint) return null;
return { provider: 'azure-openai', ...settings.azureOpenAI } as AzureOpenAIConfig;
},
gemini: (settings) => {
if (!settings.gemini?.apiKey) return null;
return { provider: 'gemini', ...settings.gemini } as GeminiConfig;
},
anthropic: (settings) => {
if (!settings.anthropic?.apiKey) return null;
return { provider: 'anthropic', ...settings.anthropic } as AnthropicConfig;
},
ollama: (settings) => {
return {
provider: 'ollama',
...settings.ollama,
baseUrl: settings.ollama?.baseUrl ?? DEFAULT_OLLAMA_BASE_URL,
} as OllamaConfig;
},
openrouter: (settings) => {
if (!settings.openrouter?.apiKey || settings.openrouter.apiKey.trim() === '') return null;
return {
provider: 'openrouter',
apiKey: settings.openrouter.apiKey,
model: settings.openrouter.model || '',
baseUrl: settings.openrouter.baseUrl || DEFAULT_OPENROUTER_BASE_URL,
temperature: settings.openrouter.temperature,
maxTokens: settings.openrouter.maxTokens,
} as OpenRouterConfig;
},
minimax: (settings) => {
if (!settings.minimax?.apiKey) return null;
return { provider: 'minimax', ...settings.minimax } as MiniMaxConfig;
},
};
export const getActiveProviderConfig = (): ProviderConfig | null => {
const settings = loadSettings();
switch (settings.activeProvider) {
case 'openai':
if (!settings.openai?.apiKey) {
return null;
}
return {
provider: 'openai',
...settings.openai,
} as OpenAIConfig;
case 'azure-openai':
if (!settings.azureOpenAI?.apiKey || !settings.azureOpenAI?.endpoint) {
return null;
}
return {
provider: 'azure-openai',
...settings.azureOpenAI,
} as AzureOpenAIConfig;
case 'gemini':
if (!settings.gemini?.apiKey) {
return null;
}
return {
provider: 'gemini',
...settings.gemini,
} as GeminiConfig;
case 'anthropic':
if (!settings.anthropic?.apiKey) {
return null;
}
return {
provider: 'anthropic',
...settings.anthropic,
} as AnthropicConfig;
case 'ollama':
return {
provider: 'ollama',
...settings.ollama,
} as OllamaConfig;
case 'openrouter':
if (!settings.openrouter?.apiKey || settings.openrouter.apiKey.trim() === '') {
return null;
}
return {
provider: 'openrouter',
apiKey: settings.openrouter.apiKey,
model: settings.openrouter.model || '',
baseUrl: settings.openrouter.baseUrl || 'https://openrouter.ai/api/v1',
temperature: settings.openrouter.temperature,
maxTokens: settings.openrouter.maxTokens,
} as OpenRouterConfig;
case 'minimax':
if (!settings.minimax?.apiKey) {
return null;
}
return {
provider: 'minimax',
...settings.minimax,
} as MiniMaxConfig;
default:
return null;
}
const builder = providerBuilders[settings.activeProvider];
return builder ? builder(settings) : null;
};
/**
@ -288,7 +297,16 @@ export const isProviderConfigured = (): boolean => {
* Clear all settings (reset to defaults)
*/
export const clearSettings = (): void => {
localStorage.removeItem(STORAGE_KEY);
try {
if (typeof sessionStorage !== 'undefined') {
sessionStorage.removeItem(STORAGE_KEY);
}
if (typeof localStorage !== 'undefined') {
localStorage.removeItem(STORAGE_KEY);
}
} catch (error) {
console.warn('Failed to clear LLM settings:', error);
}
};
/**
@ -343,7 +361,7 @@ export const getAvailableModels = (provider: LLMProvider): string[] => {
*/
export const fetchOpenRouterModels = async (): Promise<Array<{ id: string; name: string }>> => {
try {
const response = await fetch('https://openrouter.ai/api/v1/models');
const response = await fetch(`${DEFAULT_OPENROUTER_BASE_URL}/models`);
if (!response.ok) throw new Error('Failed to fetch models');
const data = await response.json();
return data.data.map((model: any) => ({

View file

@ -8,6 +8,8 @@
/**
* Supported LLM providers
*/
import { DEFAULT_OLLAMA_BASE_URL, DEFAULT_OPENROUTER_BASE_URL } from '../../config/ui-constants';
export type LLMProvider = 'openai' | 'azure-openai' | 'gemini' | 'anthropic' | 'ollama' | 'openrouter' | 'minimax';
/**
@ -148,14 +150,14 @@ export const DEFAULT_LLM_SETTINGS: LLMSettings = {
temperature: 0.1,
},
ollama: {
baseUrl: 'http://localhost:11434',
baseUrl: DEFAULT_OLLAMA_BASE_URL,
model: 'llama3.2',
temperature: 0.1,
},
openrouter: {
apiKey: '',
model: '',
baseUrl: 'https://openrouter.ai/api/v1',
baseUrl: DEFAULT_OPENROUTER_BASE_URL,
temperature: 0.1,
},
minimax: {

View file

@ -0,0 +1,75 @@
import { createContext, useContext, useCallback, useMemo, useState, ReactNode } from 'react';
import type { KnowledgeGraph, GraphNode, NodeLabel } from '../../core/graph/types';
import { DEFAULT_VISIBLE_LABELS, DEFAULT_VISIBLE_EDGES, type EdgeType } from '../../lib/constants';
interface GraphStateContextValue {
graph: KnowledgeGraph | null;
setGraph: (graph: KnowledgeGraph | null) => void;
fileContents: Map<string, string>;
setFileContents: (contents: Map<string, string>) => void;
selectedNode: GraphNode | null;
setSelectedNode: (node: GraphNode | null) => void;
visibleLabels: NodeLabel[];
toggleLabelVisibility: (label: NodeLabel) => void;
visibleEdgeTypes: EdgeType[];
toggleEdgeVisibility: (edgeType: EdgeType) => void;
depthFilter: number | null;
setDepthFilter: (depth: number | null) => void;
highlightedNodeIds: Set<string>;
setHighlightedNodeIds: (ids: Set<string>) => void;
}
const GraphStateContext = createContext<GraphStateContextValue | null>(null);
export const GraphStateProvider = ({ children }: { children: ReactNode }) => {
const [graph, setGraph] = useState<KnowledgeGraph | null>(null);
const [fileContents, setFileContents] = useState<Map<string, string>>(new Map());
const [selectedNode, setSelectedNode] = useState<GraphNode | null>(null);
const [visibleLabels, setVisibleLabels] = useState<NodeLabel[]>(DEFAULT_VISIBLE_LABELS);
const [visibleEdgeTypes, setVisibleEdgeTypes] = useState<EdgeType[]>(DEFAULT_VISIBLE_EDGES);
const [depthFilter, setDepthFilter] = useState<number | null>(null);
const [highlightedNodeIds, setHighlightedNodeIds] = useState<Set<string>>(new Set());
const toggleLabelVisibility = useCallback((label: NodeLabel) => {
setVisibleLabels(prev =>
prev.includes(label) ? prev.filter(l => l !== label) : [...prev, label]
);
}, []);
const toggleEdgeVisibility = useCallback((edgeType: EdgeType) => {
setVisibleEdgeTypes(prev =>
prev.includes(edgeType) ? prev.filter(e => e !== edgeType) : [...prev, edgeType]
);
}, []);
const value = useMemo<GraphStateContextValue>(() => ({
graph,
setGraph,
fileContents,
setFileContents,
selectedNode,
setSelectedNode,
visibleLabels,
toggleLabelVisibility,
visibleEdgeTypes,
toggleEdgeVisibility,
depthFilter,
setDepthFilter,
highlightedNodeIds,
setHighlightedNodeIds,
}), [graph, fileContents, selectedNode, visibleLabels, visibleEdgeTypes, depthFilter, highlightedNodeIds]);
return (
<GraphStateContext.Provider value={value}>
{children}
</GraphStateContext.Provider>
);
};
export const useGraphState = (): GraphStateContextValue => {
const ctx = useContext(GraphStateContext);
if (!ctx) {
throw new Error('useGraphState must be used within a GraphStateProvider');
}
return ctx;
};

View file

@ -1,18 +1,21 @@
import { createContext, useContext, useState, useCallback, useRef, useEffect, ReactNode } from 'react';
import { createContext, useContext, useState, useCallback, useRef, useEffect, useMemo, ReactNode } from 'react';
import * as Comlink from 'comlink';
import { KnowledgeGraph, GraphNode, GraphRelationship, NodeLabel } from '../core/graph/types';
import { PipelineProgress, PipelineResult, deserializePipelineResult } from '../types/pipeline';
import { createKnowledgeGraph } from '../core/graph/graph';
import { DEFAULT_VISIBLE_LABELS } from '../lib/constants';
import type { IngestionWorkerApi } from '../workers/ingestion.worker';
import type { FileEntry } from '../services/zip';
import type { EmbeddingProgress, SemanticSearchResult } from '../core/embeddings/types';
import type { LLMSettings, ProviderConfig, AgentStreamChunk, ChatMessage, ToolCallInfo, MessageStep } from '../core/llm/types';
import { loadSettings, getActiveProviderConfig, saveSettings } from '../core/llm/settings-service';
import type { AgentMessage } from '../core/llm/agent';
import { DEFAULT_VISIBLE_EDGES, type EdgeType } from '../lib/constants';
import { type EdgeType } from '../lib/constants';
import type { RepoSummary, ConnectToServerResult } from '../services/server-connection';
import { fetchRepos, connectToServer } from '../services/server-connection';
import { ERROR_RESET_DELAY_MS } from '../config/ui-constants';
import { normalizePath, resolveFilePath as resolvePathFromContents } from '../lib/path-resolution';
import { FILE_REF_REGEX, NODE_REF_REGEX } from '../lib/grounding-patterns';
import { GraphStateProvider, useGraphState } from './app-state/graph';
export type ViewMode = 'onboarding' | 'loading' | 'exploring';
export type RightPanelTab = 'code' | 'chat';
@ -74,6 +77,8 @@ interface AppState {
setRightPanelTab: (tab: RightPanelTab) => void;
openCodePanel: () => void;
openChatPanel: () => void;
helpDialogBoxOpen: boolean;
setHelpDialogBoxOpen: (open: boolean) => void;
// Filters
visibleLabels: NodeLabel[];
@ -134,6 +139,7 @@ interface AppState {
// Embedding methods
startEmbeddings: (forceDevice?: 'webgpu' | 'wasm') => Promise<void>;
startEmbeddingsWithFallback: () => void;
semanticSearch: (query: string, k?: number) => Promise<SemanticSearchResult[]>;
semanticSearchWithContext: (query: string, k?: number, hops?: number) => Promise<any[]>;
isEmbeddingReady: boolean;
@ -145,9 +151,7 @@ interface AppState {
llmSettings: LLMSettings;
updateLLMSettings: (updates: Partial<LLMSettings>) => void;
isSettingsPanelOpen: boolean;
isHelpDialogBoxOpen: boolean;
setSettingsPanelOpen: (open: boolean) => void;
setHelpDialogBoxOpen: (open: boolean) => void;
isAgentReady: boolean;
isAgentInitializing: boolean;
agentError: string | null;
@ -177,20 +181,37 @@ interface AppState {
const AppStateContext = createContext<AppState | null>(null);
export const AppStateProvider = ({ children }: { children: ReactNode }) => {
export const AppStateProvider = ({ children }: { children: ReactNode }) => (
<GraphStateProvider>
<AppStateProviderInner>{children}</AppStateProviderInner>
</GraphStateProvider>
);
const AppStateProviderInner = ({ children }: { children: ReactNode }) => {
// View state
const [viewMode, setViewMode] = useState<ViewMode>('onboarding');
// Graph data
const [graph, setGraph] = useState<KnowledgeGraph | null>(null);
const [fileContents, setFileContents] = useState<Map<string, string>>(new Map());
// Selection
const [selectedNode, setSelectedNode] = useState<GraphNode | null>(null);
const {
graph,
setGraph,
fileContents,
setFileContents,
selectedNode,
setSelectedNode,
visibleLabels,
toggleLabelVisibility,
visibleEdgeTypes,
toggleEdgeVisibility,
depthFilter,
setDepthFilter,
highlightedNodeIds,
setHighlightedNodeIds,
} = useGraphState();
// Right Panel
const [isRightPanelOpen, setRightPanelOpen] = useState(false);
const [rightPanelTab, setRightPanelTab] = useState<RightPanelTab>('code');
const [helpDialogBoxOpen, setHelpDialogBoxOpen] = useState(false);
const openCodePanel = useCallback(() => {
// Legacy API: used by graph/tree selection.
@ -204,15 +225,7 @@ export const AppStateProvider = ({ children }: { children: ReactNode }) => {
setRightPanelTab('chat');
}, []);
// Filters
const [visibleLabels, setVisibleLabels] = useState<NodeLabel[]>(DEFAULT_VISIBLE_LABELS);
const [visibleEdgeTypes, setVisibleEdgeTypes] = useState<EdgeType[]>(DEFAULT_VISIBLE_EDGES);
// Depth filter
const [depthFilter, setDepthFilter] = useState<number | null>(null);
// Query state
const [highlightedNodeIds, setHighlightedNodeIds] = useState<Set<string>>(new Set());
const [queryResult, setQueryResult] = useState<QueryResult | null>(null);
// AI highlights (separate from user/query highlights)
@ -298,7 +311,6 @@ export const AppStateProvider = ({ children }: { children: ReactNode }) => {
// LLM/Agent state
const [llmSettings, setLLMSettings] = useState<LLMSettings>(loadSettings);
const [isSettingsPanelOpen, setSettingsPanelOpen] = useState(false);
const [isHelpDialogBoxOpen, setHelpDialogBoxOpen] = useState(false);
const [isAgentReady, setIsAgentReady] = useState(false);
const [isAgentInitializing, setIsAgentInitializing] = useState(false);
const [agentError, setAgentError] = useState<string | null>(null);
@ -313,54 +325,24 @@ export const AppStateProvider = ({ children }: { children: ReactNode }) => {
const [isCodePanelOpen, setCodePanelOpen] = useState(false);
const [codeReferenceFocus, setCodeReferenceFocus] = useState<CodeReferenceFocus | null>(null);
const normalizePath = useCallback((p: string) => {
return p.replace(/\\/g, '/').replace(/^\.?\//, '');
}, []);
const resolveFilePath = useCallback((requestedPath: string): string | null => {
const req = normalizePath(requestedPath).toLowerCase();
if (!req) return null;
return resolvePathFromContents(fileContents, requestedPath);
}, [fileContents]);
// Exact match first
for (const key of fileContents.keys()) {
if (normalizePath(key).toLowerCase() === req) return key;
}
// Ends-with match (best for partial paths like "src/foo.ts")
let best: { path: string; score: number } | null = null;
for (const key of fileContents.keys()) {
const norm = normalizePath(key).toLowerCase();
if (norm.endsWith(req)) {
const score = 1000 - norm.length; // shorter is better
if (!best || score > best.score) best = { path: key, score };
const fileNodeByPath = useMemo(() => {
if (!graph) return new Map<string, string>();
const map = new Map<string, string>();
for (const n of graph.nodes) {
if (n.label === 'File') {
map.set(normalizePath(n.properties.filePath), n.id);
}
}
if (best) return best.path;
// Segment match fallback
const segs = req.split('/').filter(Boolean);
for (const key of fileContents.keys()) {
const normSegs = normalizePath(key).toLowerCase().split('/').filter(Boolean);
let idx = 0;
for (const s of segs) {
const found = normSegs.findIndex((x, i) => i >= idx && x.includes(s));
if (found === -1) { idx = -1; break; }
idx = found + 1;
}
if (idx !== -1) return key;
}
return null;
}, [fileContents, normalizePath]);
return map;
}, [graph]);
const findFileNodeId = useCallback((filePath: string): string | undefined => {
if (!graph) return undefined;
const target = normalizePath(filePath);
const fileNode = graph.nodes.find(
(n) => n.label === 'File' && normalizePath(n.properties.filePath) === target
);
return fileNode?.id;
}, [graph, normalizePath]);
return fileNodeByPath.get(normalizePath(filePath));
}, [fileNodeByPath]);
// Code References methods
const addCodeReference = useCallback((ref: Omit<CodeReference, 'id'>) => {
@ -419,7 +401,7 @@ export const AppStateProvider = ({ children }: { children: ReactNode }) => {
}
return kept;
});
}, [queryResult, selectedNode]);
}, [selectedNode]);
// Auto-add a code reference when the user selects a node in the graph/tree
useEffect(() => {
@ -546,6 +528,25 @@ export const AppStateProvider = ({ children }: { children: ReactNode }) => {
}
}, []);
const startEmbeddingsWithFallback = useCallback(() => {
// Skip auto-start in automated/headless runs to avoid WebGPU errors and long downloads.
const isPlaywright =
(typeof navigator !== 'undefined' && navigator.webdriver) ||
(typeof import.meta !== 'undefined' && typeof import.meta.env !== 'undefined' && import.meta.env.VITE_PLAYWRIGHT_TEST) ||
(typeof process !== 'undefined' && process.env.PLAYWRIGHT_TEST);
if (isPlaywright) {
setEmbeddingStatus('idle');
return;
}
startEmbeddings().catch((err) => {
if (err?.name === 'WebGPUNotAvailableError' || err?.message?.includes('WebGPU')) {
startEmbeddings('wasm').catch(console.warn);
} else {
console.warn('Embeddings auto-start failed:', err);
}
});
}, [startEmbeddings]);
const semanticSearch = useCallback(async (
query: string,
k: number = 10
@ -709,6 +710,15 @@ export const AppStateProvider = ({ children }: { children: ReactNode }) => {
}
});
};
let pendingUpdate = false;
const scheduleMessageUpdate = () => {
if (pendingUpdate) return;
pendingUpdate = true;
requestAnimationFrame(() => {
pendingUpdate = false;
updateMessage();
});
};
try {
const onChunk = Comlink.proxy((chunk: AgentStreamChunk) => {
@ -731,7 +741,7 @@ export const AppStateProvider = ({ children }: { children: ReactNode }) => {
content: chunk.reasoning,
});
}
updateMessage();
scheduleMessageUpdate();
}
break;
@ -754,7 +764,7 @@ export const AppStateProvider = ({ children }: { children: ReactNode }) => {
content: chunk.content,
});
}
updateMessage();
scheduleMessageUpdate();
// Parse inline grounding references and add them to the Code References panel.
// Supports: [[file.ts:10-25]] (file refs) and [[Class:View]] (node refs)
@ -765,7 +775,7 @@ export const AppStateProvider = ({ children }: { children: ReactNode }) => {
// Pattern 1: File refs - [[path/file.ext]] or [[path/file.ext:line]] or [[path/file.ext:line-line]]
// Line numbers are optional
const fileRefRegex = /\[\[([a-zA-Z0-9_\-./\\]+\.[a-zA-Z0-9]+)(?::(\d+)(?:[-](\d+))?)?\]\]/g;
const fileRefRegex = new RegExp(FILE_REF_REGEX.source, FILE_REF_REGEX.flags);
let fileMatch: RegExpExecArray | null;
while ((fileMatch = fileRefRegex.exec(fullText)) !== null) {
const rawPath = fileMatch[1].trim();
@ -791,7 +801,7 @@ export const AppStateProvider = ({ children }: { children: ReactNode }) => {
}
// Pattern 2: Node refs - [[Type:Name]] or [[graph:Type:Name]]
const nodeRefRegex = /\[\[(?:graph:)?(Class|Function|Method|Interface|File|Folder|Variable|Enum|Type|CodeElement):([^\]]+)\]\]/g;
const nodeRefRegex = new RegExp(NODE_REF_REGEX.source, NODE_REF_REGEX.flags);
let nodeMatch: RegExpExecArray | null;
while ((nodeMatch = nodeRefRegex.exec(fullText)) !== null) {
const nodeType = nodeMatch[1];
@ -832,7 +842,7 @@ export const AppStateProvider = ({ children }: { children: ReactNode }) => {
toolCall: tc,
});
setCurrentToolCalls(prev => [...prev, tc]);
updateMessage();
scheduleMessageUpdate();
}
break;
@ -891,7 +901,7 @@ export const AppStateProvider = ({ children }: { children: ReactNode }) => {
return prev;
});
updateMessage();
scheduleMessageUpdate();
// Parse highlight marker from tool results
if (tc.result) {
@ -900,15 +910,15 @@ export const AppStateProvider = ({ children }: { children: ReactNode }) => {
const rawIds = highlightMatch[1].split(',').map((id: string) => id.trim()).filter(Boolean);
if (rawIds.length > 0 && graph) {
const matchedIds = new Set<string>();
const graphNodeIds = graph.nodes.map(n => n.id);
const graphNodeIdSet = new Set(graph.nodes.map(n => n.id));
for (const rawId of rawIds) {
if (graphNodeIds.includes(rawId)) {
if (graphNodeIdSet.has(rawId)) {
matchedIds.add(rawId);
} else {
const found = graphNodeIds.find(gid =>
gid.endsWith(rawId) || gid.endsWith(':' + rawId)
);
const found = graph.nodes.find(n =>
n.id.endsWith(rawId) || n.id.endsWith(':' + rawId)
)?.id;
if (found) {
matchedIds.add(found);
}
@ -929,15 +939,15 @@ export const AppStateProvider = ({ children }: { children: ReactNode }) => {
const rawIds = impactMatch[1].split(',').map((id: string) => id.trim()).filter(Boolean);
if (rawIds.length > 0 && graph) {
const matchedIds = new Set<string>();
const graphNodeIds = graph.nodes.map(n => n.id);
const graphNodeIdSet = new Set(graph.nodes.map(n => n.id));
for (const rawId of rawIds) {
if (graphNodeIds.includes(rawId)) {
if (graphNodeIdSet.has(rawId)) {
matchedIds.add(rawId);
} else {
const found = graphNodeIds.find(gid =>
gid.endsWith(rawId) || gid.endsWith(':' + rawId)
);
const found = graph.nodes.find(n =>
n.id.endsWith(rawId) || n.id.endsWith(':' + rawId)
)?.id;
if (found) {
matchedIds.add(found);
}
@ -961,7 +971,7 @@ export const AppStateProvider = ({ children }: { children: ReactNode }) => {
case 'done':
// Finalize the assistant message - just call updateMessage one more time
updateMessage();
scheduleMessageUpdate();
break;
}
});
@ -997,7 +1007,6 @@ export const AppStateProvider = ({ children }: { children: ReactNode }) => {
setProgress({ phase: 'extracting', percent: 0, message: 'Switching repository...', detail: `Loading ${repoName}` });
setViewMode('loading');
setIsAgentReady(false);
// Clear stale graph state from previous repo (highlights, selections, blast radius)
@ -1046,13 +1055,7 @@ export const AppStateProvider = ({ children }: { children: ReactNode }) => {
await initializeAgent(pName);
}
setViewMode('exploring');
startEmbeddings().catch((err) => {
if (err?.name === 'WebGPUNotAvailableError' || err?.message?.includes('WebGPU')) {
startEmbeddings('wasm').catch(console.warn);
} else {
console.warn('Embeddings auto-start failed:', err);
}
});
startEmbeddingsWithFallback();
setProgress(null);
} catch (err) {
console.warn('Failed to load graph into LadybugDB:', err);
@ -1071,9 +1074,9 @@ export const AppStateProvider = ({ children }: { children: ReactNode }) => {
});
setIsAgentReady(false);
await apiRef.current?.disposeAgent();
setTimeout(() => { setViewMode('exploring'); setProgress(null); }, 3000);
setTimeout(() => { setViewMode('exploring'); setProgress(null); }, ERROR_RESET_DELAY_MS);
}
}, [serverBaseUrl, setProgress, setViewMode, setProjectName, setGraph, setFileContents, loadServerGraph, initializeAgent, startEmbeddings, setHighlightedNodeIds, clearAIToolHighlights, clearAICitationHighlights, clearBlastRadius, setSelectedNode, setQueryResult, setCodeReferences, setCodePanelOpen, setCodeReferenceFocus]);
}, [serverBaseUrl, setProgress, setViewMode, setProjectName, setGraph, setFileContents, loadServerGraph, initializeAgent, startEmbeddingsWithFallback, setHighlightedNodeIds, clearAIToolHighlights, clearAICitationHighlights, clearBlastRadius, setSelectedNode, setQueryResult, setCodeReferences, setCodePanelOpen, setCodeReferenceFocus]);
const removeCodeReference = useCallback((id: string) => {
setCodeReferences(prev => {
@ -1107,26 +1110,6 @@ export const AppStateProvider = ({ children }: { children: ReactNode }) => {
setCodeReferenceFocus(null);
}, []);
const toggleLabelVisibility = useCallback((label: NodeLabel) => {
setVisibleLabels(prev => {
if (prev.includes(label)) {
return prev.filter(l => l !== label);
} else {
return [...prev, label];
}
});
}, []);
const toggleEdgeVisibility = useCallback((edgeType: EdgeType) => {
setVisibleEdgeTypes(prev => {
if (prev.includes(edgeType)) {
return prev.filter(t => t !== edgeType);
} else {
return [...prev, edgeType];
}
});
}, []);
const value: AppState = {
viewMode,
setViewMode,
@ -1142,6 +1125,8 @@ export const AppStateProvider = ({ children }: { children: ReactNode }) => {
setRightPanelTab,
openCodePanel,
openChatPanel,
helpDialogBoxOpen,
setHelpDialogBoxOpen,
visibleLabels,
toggleLabelVisibility,
visibleEdgeTypes,
@ -1184,6 +1169,7 @@ export const AppStateProvider = ({ children }: { children: ReactNode }) => {
embeddingStatus,
embeddingProgress,
startEmbeddings,
startEmbeddingsWithFallback,
semanticSearch,
semanticSearchWithContext,
isEmbeddingReady: embeddingStatus === 'ready',
@ -1194,8 +1180,6 @@ export const AppStateProvider = ({ children }: { children: ReactNode }) => {
updateLLMSettings,
isSettingsPanelOpen,
setSettingsPanelOpen,
isHelpDialogBoxOpen,
setHelpDialogBoxOpen,
isAgentReady,
isAgentInitializing,
agentError,

View file

@ -6,16 +6,13 @@ import {
getBackendUrl,
type BackendRepo,
} from '../services/backend';
import { BACKEND_URL_DEBOUNCE_MS, DEFAULT_BACKEND_URL } from '../config/ui-constants';
// ── localStorage keys ────────────────────────────────────────────────────────
const LS_URL_KEY = 'gitnexus-backend-url';
const LS_REPO_KEY = 'gitnexus-backend-repo';
const DEFAULT_URL = 'http://localhost:4747';
// ── Debounce delay ───────────────────────────────────────────────────────────
const DEBOUNCE_MS = 500;
const DEFAULT_URL = DEFAULT_BACKEND_URL;
// ── Public interface ─────────────────────────────────────────────────────────
@ -90,7 +87,10 @@ export function useBackend(): UseBackendResult {
// Re-check: still the latest probe?
if (id !== probeIdRef.current) return false;
setRepos(repoList);
} catch {
} catch (err) {
if (import.meta.env.DEV) {
console.warn('Failed to fetch repos:', err);
}
if (id === probeIdRef.current) {
setRepos([]);
}
@ -133,7 +133,7 @@ export function useBackend(): UseBackendResult {
debounceRef.current = setTimeout(() => {
debounceRef.current = null;
void probe();
}, DEBOUNCE_MS);
}, BACKEND_URL_DEBOUNCE_MS);
},
[probe],
);

View file

@ -0,0 +1,7 @@
// Shared regex patterns for grounding references in chat/markdown.
// Pattern 1: File refs - [[path/file.ext]] or [[path/file.ext:line]] or [[path/file.ext:line-line]]
// Line numbers are optional.
export const FILE_REF_REGEX = /\[\[([a-zA-Z0-9_\-./\\]+\.[a-zA-Z0-9]+)(?::(\d+)(?:[-](\d+))?)?\]\]/g;
// Pattern 2: Node refs - [[Type:Name]] or [[graph:Type:Name]]
export const NODE_REF_REGEX = /\[\[(?:graph:)?(Class|Function|Method|Interface|File|Folder|Variable|Enum|Type|CodeElement):([^\]]+)\]\]/g;

View file

@ -0,0 +1,73 @@
/**
* Centralized icon re-exports from lucide-react.
*
* All components import icons from this module (@/lib/lucide-icons) rather
* than directly from lucide-react. This provides a single place to manage
* which icons are used and allows future optimization (e.g., tree-shaking
* configuration, icon subset bundling) without touching every component.
*/
export {
AlertCircle,
AlertTriangle,
ArrowRight,
Brain,
Box,
Braces,
Check,
ChevronDown,
ChevronRight,
ChevronUp,
Code,
Copy,
Eye,
EyeOff,
FileArchive,
FileCode,
Filter,
FlaskConical,
Focus,
Folder,
FolderOpen,
GitBranch,
Github,
Globe,
Hash,
Heart,
HelpCircle,
Home,
Key,
Layers,
Lightbulb,
LightbulbOff,
Loader2,
Maximize2,
MousePointerClick,
PanelLeft,
PanelLeftClose,
PanelRightClose,
Pause,
Play,
RefreshCw,
Rocket,
RotateCcw,
Search,
Send,
Server,
Settings,
SkipForward,
Snail,
Sparkles,
Square,
Star,
Table,
Target,
Terminal,
Trash2,
Upload,
User,
Variable,
X,
Zap,
ZoomIn,
ZoomOut,
} from 'lucide-react';

View file

@ -0,0 +1,45 @@
// Utilities for normalizing and resolving file paths referenced in chat and code panels.
export const normalizePath = (p: string): string => {
return p.replace(/\\/g, '/').replace(/^\.?\//, '');
};
/**
* Resolve a user-supplied path (which may be partial) to an exact file path in the repo.
* Follows the same heuristics previously embedded in useAppState:
* 1) exact match, 2) ends-with match (prefers shorter paths), 3) segment containment.
*/
export const resolveFilePath = (fileContents: Map<string, string>, requestedPath: string): string | null => {
const req = normalizePath(requestedPath).toLowerCase();
if (!req) return null;
// Exact match first
for (const key of fileContents.keys()) {
if (normalizePath(key).toLowerCase() === req) return key;
}
// Ends-with match (best for partial paths like "src/foo.ts")
let best: { path: string; score: number } | null = null;
for (const key of fileContents.keys()) {
const norm = normalizePath(key).toLowerCase();
if (norm.endsWith(req)) {
const score = 1000 - norm.length; // shorter is better
if (!best || score > best.score) best = { path: key, score };
}
}
if (best) return best.path;
// Segment match fallback
const segs = req.split('/').filter(Boolean);
for (const key of fileContents.keys()) {
const normSegs = normalizePath(key).toLowerCase().split('/').filter(Boolean);
let idx = 0;
for (const s of segs) {
const found = normSegs.findIndex((x, i) => i >= idx && x.includes(s));
if (found === -1) { idx = -1; break; }
idx = found + 1;
}
if (idx !== -1) return key;
}
return null;
};

View file

@ -0,0 +1,74 @@
import { describe, expect, it } from 'vitest';
// ==========================================================================
// PR4 Performance Optimizations — verify behavior preserved after changes
// Tests the pure functions underlying the O(1) lookup optimizations.
// ==========================================================================
describe('nodeById Map — O(1) lookup correctness', () => {
// Positive: Map.get returns correct node
it('Map provides O(1) lookup by ID', () => {
const nodes = [
{ id: 'Function:a.ts:foo', label: 'Function', name: 'foo' },
{ id: 'Class:b.ts:Bar', label: 'Class', name: 'Bar' },
{ id: 'File:c.ts', label: 'File', name: 'c.ts' },
];
const nodeById = new Map(nodes.map(n => [n.id, n]));
expect(nodeById.get('Function:a.ts:foo')?.name).toBe('foo');
expect(nodeById.get('Class:b.ts:Bar')?.name).toBe('Bar');
expect(nodeById.get('File:c.ts')?.label).toBe('File');
});
// Positive: handles duplicate IDs (last wins)
it('last node wins on duplicate IDs', () => {
const nodes = [
{ id: 'File:a.ts', label: 'File', name: 'first' },
{ id: 'File:a.ts', label: 'File', name: 'second' },
];
const nodeById = new Map(nodes.map(n => [n.id, n]));
expect(nodeById.get('File:a.ts')?.name).toBe('second');
expect(nodeById.size).toBe(1);
});
// Negative: missing ID returns undefined
it('returns undefined for non-existent ID', () => {
const nodeById = new Map([['File:a.ts', { id: 'File:a.ts' }]]);
expect(nodeById.get('NonExistent:x')).toBeUndefined();
});
// Negative: empty map
it('empty Map returns undefined for any key', () => {
const nodeById = new Map<string, any>();
expect(nodeById.get('anything')).toBeUndefined();
});
});
describe('Set.has — O(1) highlight matching', () => {
// Positive: exact match
it('Set.has returns true for present IDs', () => {
const idSet = new Set(['Function:a.ts:foo', 'Class:b.ts:Bar']);
expect(idSet.has('Function:a.ts:foo')).toBe(true);
expect(idSet.has('Class:b.ts:Bar')).toBe(true);
});
// Negative: missing ID
it('Set.has returns false for absent IDs', () => {
const idSet = new Set(['Function:a.ts:foo']);
expect(idSet.has('Function:a.ts:bar')).toBe(false);
expect(idSet.has('')).toBe(false);
});
// Positive: works with graph node IDs containing special chars
it('handles IDs with colons, dots, and slashes', () => {
const idSet = new Set(['Function:src/utils/path-resolver.ts:resolveFile']);
expect(idSet.has('Function:src/utils/path-resolver.ts:resolveFile')).toBe(true);
});
// Negative: case sensitive
it('is case-sensitive', () => {
const idSet = new Set(['Function:a.ts:Foo']);
expect(idSet.has('Function:a.ts:foo')).toBe(false);
});
});