perf(web): O(1) lookups, memoization, bundle optimizations, React fixes

Performance:
- nodeById Map in GraphCanvas for O(1) click/hover lookups
- fileNodeByPath Map in useAppState for O(1) file path lookups
- Set.has() for HIGHLIGHT_NODES/IMPACT matching (was O(N²))
- useMemo for primaryLanguage in StatusBar
- useCallback on toggleLabelVisibility/toggleEdgeVisibility
- Recursive FileTreePanel search (full subtree, not 1 level)

React fixes:
- Remove stale queryResult dep from clearAICodeReferences
- Cancel RAF chains in CodeReferencesPanel on cleanup
- Clean up timeouts in SettingsPanel and MarkdownRenderer on unmount
- try/catch on localStorage in DropZone (private browsing)
- Await handleServerConnect in App.tsx auto-connect
- pendingToolCalls counter replaces allToolsDone boolean in agent.ts
- JSON.parse try/catch in agent streaming
- sessionStorage JSDoc fix in settings-service

Bundle:
- Centralized lucide icon deep imports

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
jreakin 2026-03-23 02:55:06 -05:00
parent 46325494fb
commit a8dad4b1f8
18 changed files with 811 additions and 580 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,5 +1,5 @@
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';
@ -161,8 +161,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 +178,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(() => {

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

@ -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,25 +55,28 @@ 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);
@ -106,7 +110,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 +118,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 +130,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 +142,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(() => {

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);
}

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

@ -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

@ -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,198 @@
/**
* Direct icon imports from lucide-react (bundle-size optimization).
* Avoids loading the full barrel (~1500+ modules). Each icon loads only its own module.
* See Vercel React Best Practices rule 2.1 (Avoid Barrel File Imports).
*/
// Direct ESM paths so only these icon modules are bundled, not the full barrel
// @ts-ignore — no types for deep ESM path
import AlertCircle from 'lucide-react/dist/esm/icons/alert-circle.js';
// @ts-ignore — no types for deep ESM path
import AlertTriangle from 'lucide-react/dist/esm/icons/alert-triangle.js';
// @ts-ignore — no types for deep ESM path
import ArrowRight from 'lucide-react/dist/esm/icons/arrow-right.js';
// @ts-ignore — no types for deep ESM path
import Brain from 'lucide-react/dist/esm/icons/brain.js';
// @ts-ignore — no types for deep ESM path
import Box from 'lucide-react/dist/esm/icons/box.js';
// @ts-ignore — no types for deep ESM path
import Braces from 'lucide-react/dist/esm/icons/braces.js';
// @ts-ignore — no types for deep ESM path
import Check from 'lucide-react/dist/esm/icons/check.js';
// @ts-ignore — no types for deep ESM path
import ChevronDown from 'lucide-react/dist/esm/icons/chevron-down.js';
// @ts-ignore — no types for deep ESM path
import ChevronRight from 'lucide-react/dist/esm/icons/chevron-right.js';
// @ts-ignore — no types for deep ESM path
import ChevronUp from 'lucide-react/dist/esm/icons/chevron-up.js';
// @ts-ignore — no types for deep ESM path
import Code from 'lucide-react/dist/esm/icons/code.js';
// @ts-ignore — no types for deep ESM path
import Copy from 'lucide-react/dist/esm/icons/copy.js';
// @ts-ignore — no types for deep ESM path
import Eye from 'lucide-react/dist/esm/icons/eye.js';
// @ts-ignore — no types for deep ESM path
import EyeOff from 'lucide-react/dist/esm/icons/eye-off.js';
// @ts-ignore — no types for deep ESM path
import FileArchive from 'lucide-react/dist/esm/icons/file-archive.js';
// @ts-ignore — no types for deep ESM path
import FileCode from 'lucide-react/dist/esm/icons/file-code.js';
// @ts-ignore — no types for deep ESM path
import Filter from 'lucide-react/dist/esm/icons/filter.js';
// @ts-ignore — no types for deep ESM path
import FlaskConical from 'lucide-react/dist/esm/icons/flask-conical.js';
// @ts-ignore — no types for deep ESM path
import Focus from 'lucide-react/dist/esm/icons/focus.js';
// @ts-ignore — no types for deep ESM path
import Folder from 'lucide-react/dist/esm/icons/folder.js';
// @ts-ignore — no types for deep ESM path
import FolderOpen from 'lucide-react/dist/esm/icons/folder-open.js';
// @ts-ignore — no types for deep ESM path
import GitBranch from 'lucide-react/dist/esm/icons/git-branch.js';
// @ts-ignore — no types for deep ESM path
import Github from 'lucide-react/dist/esm/icons/github.js';
// @ts-ignore — no types for deep ESM path
import Globe from 'lucide-react/dist/esm/icons/globe.js';
// @ts-ignore — no types for deep ESM path
import Hash from 'lucide-react/dist/esm/icons/hash.js';
// @ts-ignore — no types for deep ESM path
import Heart from 'lucide-react/dist/esm/icons/heart.js';
// @ts-ignore — no types for deep ESM path
import HelpCircle from 'lucide-react/dist/esm/icons/help-circle.js';
// @ts-ignore — no types for deep ESM path
import Home from 'lucide-react/dist/esm/icons/home.js';
// @ts-ignore — no types for deep ESM path
import Key from 'lucide-react/dist/esm/icons/key.js';
// @ts-ignore — no types for deep ESM path
import Layers from 'lucide-react/dist/esm/icons/layers.js';
// @ts-ignore — no types for deep ESM path
import Lightbulb from 'lucide-react/dist/esm/icons/lightbulb.js';
// @ts-ignore — no types for deep ESM path
import LightbulbOff from 'lucide-react/dist/esm/icons/lightbulb-off.js';
// @ts-ignore — no types for deep ESM path
import Loader2 from 'lucide-react/dist/esm/icons/loader-2.js';
// @ts-ignore — no types for deep ESM path
import Maximize2 from 'lucide-react/dist/esm/icons/maximize-2.js';
// @ts-ignore — no types for deep ESM path
import MousePointerClick from 'lucide-react/dist/esm/icons/mouse-pointer-click.js';
// @ts-ignore — no types for deep ESM path
import PanelLeft from 'lucide-react/dist/esm/icons/panel-left.js';
// @ts-ignore — no types for deep ESM path
import PanelLeftClose from 'lucide-react/dist/esm/icons/panel-left-close.js';
// @ts-ignore — no types for deep ESM path
import PanelRightClose from 'lucide-react/dist/esm/icons/panel-right-close.js';
// @ts-ignore — no types for deep ESM path
import Pause from 'lucide-react/dist/esm/icons/pause.js';
// @ts-ignore — no types for deep ESM path
import Play from 'lucide-react/dist/esm/icons/play.js';
// @ts-ignore — no types for deep ESM path
import RefreshCw from 'lucide-react/dist/esm/icons/refresh-cw.js';
// @ts-ignore — no types for deep ESM path
import Rocket from 'lucide-react/dist/esm/icons/rocket.js';
// @ts-ignore — no types for deep ESM path
import RotateCcw from 'lucide-react/dist/esm/icons/rotate-ccw.js';
// @ts-ignore — no types for deep ESM path
import Search from 'lucide-react/dist/esm/icons/search.js';
// @ts-ignore — no types for deep ESM path
import Send from 'lucide-react/dist/esm/icons/send.js';
// @ts-ignore — no types for deep ESM path
import Server from 'lucide-react/dist/esm/icons/server.js';
// @ts-ignore — no types for deep ESM path
import Settings from 'lucide-react/dist/esm/icons/settings.js';
// @ts-ignore — no types for deep ESM path
import SkipForward from 'lucide-react/dist/esm/icons/skip-forward.js';
// @ts-ignore — no types for deep ESM path
import Snail from 'lucide-react/dist/esm/icons/snail.js';
// @ts-ignore — no types for deep ESM path
import Sparkles from 'lucide-react/dist/esm/icons/sparkles.js';
// @ts-ignore — no types for deep ESM path
import Square from 'lucide-react/dist/esm/icons/square.js';
// @ts-ignore — no types for deep ESM path
import Star from 'lucide-react/dist/esm/icons/star.js';
// @ts-ignore — no types for deep ESM path
import Table from 'lucide-react/dist/esm/icons/table.js';
// @ts-ignore — no types for deep ESM path
import Target from 'lucide-react/dist/esm/icons/target.js';
// @ts-ignore — no types for deep ESM path
import Terminal from 'lucide-react/dist/esm/icons/terminal.js';
// @ts-ignore — no types for deep ESM path
import Trash2 from 'lucide-react/dist/esm/icons/trash-2.js';
// @ts-ignore — no types for deep ESM path
import Upload from 'lucide-react/dist/esm/icons/upload.js';
// @ts-ignore — no types for deep ESM path
import User from 'lucide-react/dist/esm/icons/user.js';
// @ts-ignore — no types for deep ESM path
import Variable from 'lucide-react/dist/esm/icons/variable.js';
// @ts-ignore — no types for deep ESM path
import X from 'lucide-react/dist/esm/icons/x.js';
// @ts-ignore — no types for deep ESM path
import Zap from 'lucide-react/dist/esm/icons/zap.js';
// @ts-ignore — no types for deep ESM path
import ZoomIn from 'lucide-react/dist/esm/icons/zoom-in.js';
// @ts-ignore — no types for deep ESM path
import ZoomOut from 'lucide-react/dist/esm/icons/zoom-out.js';
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,
};

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;
};