+
Ready
diff --git a/gitnexus-web/src/components/ToolCallCard.tsx b/gitnexus-web/src/components/ToolCallCard.tsx
index e202f83d6b..685b2b8a80 100644
--- a/gitnexus-web/src/components/ToolCallCard.tsx
+++ b/gitnexus-web/src/components/ToolCallCard.tsx
@@ -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 {
diff --git a/gitnexus-web/src/components/WebGPUFallbackDialog.tsx b/gitnexus-web/src/components/WebGPUFallbackDialog.tsx
index 8bb28d9c45..8868d7ff72 100644
--- a/gitnexus-web/src/components/WebGPUFallbackDialog.tsx
+++ b/gitnexus-web/src/components/WebGPUFallbackDialog.tsx
@@ -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;
diff --git a/gitnexus-web/src/components/settings/ProviderConfigCard.tsx b/gitnexus-web/src/components/settings/ProviderConfigCard.tsx
new file mode 100644
index 0000000000..69d9207fa9
--- /dev/null
+++ b/gitnexus-web/src/components/settings/ProviderConfigCard.tsx
@@ -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 (
+
+
+
+
{title}
+ {description ? (
+
{description}
+ ) : null}
+
+
+
+ {apiKey && (
+
+
+
+ API Key
+
+
+ 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"
+ />
+
+ {apiKey.isVisible ? : }
+
+
+ {apiKey.helperText && (
+
+ {apiKey.helperText}{' '}
+ {apiKey.helperLink ? (
+
+ {apiKey.helperLinkLabel ?? 'Learn more'}
+
+ ) : null}
+
+ )}
+
+ )}
+
+ {model && (
+
+
+ {model.label ?? 'Model'}
+
+
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 ? (
+
{model.helperText}
+ ) : null}
+
+ )}
+
+ {children}
+
+ );
+};
diff --git a/gitnexus-web/src/config/ui-constants.ts b/gitnexus-web/src/config/ui-constants.ts
new file mode 100644
index 0000000000..9750723358
--- /dev/null
+++ b/gitnexus-web/src/config/ui-constants.ts
@@ -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';
diff --git a/gitnexus-web/src/core/graph/types.ts b/gitnexus-web/src/core/graph/types.ts
index ceff9f51bf..27f3b7038b 100644
--- a/gitnexus-web/src/core/graph/types.ts
+++ b/gitnexus-web/src/core/graph/types.ts
@@ -15,7 +15,24 @@ export type NodeLabel =
| 'Type'
| 'CodeElement'
| 'Community'
- | 'Process';
+ | 'Process'
+ | 'Section'
+ | 'Struct'
+ | 'Trait'
+ | 'Impl'
+ | 'TypeAlias'
+ | 'Const'
+ | 'Static'
+ | 'Namespace'
+ | 'Union'
+ | 'Typedef'
+ | 'Macro'
+ | 'Property'
+ | 'Record'
+ | 'Delegate'
+ | 'Annotation'
+ | 'Constructor'
+ | 'Template';
export type NodeProperties = {
diff --git a/gitnexus-web/src/core/lbug/csv-generator.ts b/gitnexus-web/src/core/lbug/csv-generator.ts
index aa85196ceb..d2159e3be7 100644
--- a/gitnexus-web/src/core/lbug/csv-generator.ts
+++ b/gitnexus-web/src/core/lbug/csv-generator.ts
@@ -202,6 +202,35 @@ const generateCodeElementCSV = (
return rows.join('\n');
};
+/**
+ * Generate CSV for multi-language code element nodes (Struct, Enum, Macro, etc.)
+ * These do NOT have isExported column.
+ * Headers: id,name,filePath,startLine,endLine,content
+ */
+const generateMultiLangCSV = (
+ nodes: GraphNode[],
+ label: NodeLabel,
+ fileContents: Map
+): string => {
+ const headers = ['id', 'name', 'filePath', 'startLine', 'endLine', 'content'];
+ const rows: string[] = [headers.join(',')];
+
+ for (const node of nodes) {
+ if (node.label !== label) continue;
+ const content = extractContent(node, fileContents);
+ rows.push([
+ escapeCSVField(node.id),
+ escapeCSVField(node.properties.name || ''),
+ escapeCSVField(node.properties.filePath || ''),
+ escapeCSVNumber(node.properties.startLine, -1),
+ escapeCSVNumber(node.properties.endLine, -1),
+ escapeCSVField(content),
+ ].join(','));
+ }
+
+ return rows.join('\n');
+};
+
/**
* Generate CSV for Community nodes (from Leiden algorithm)
* Headers: id,label,heuristicLabel,keywords,description,enrichedBy,cohesion,symbolCount
@@ -221,7 +250,7 @@ const generateCommunityCSV = (nodes: GraphNode[]): string => {
escapeCSVField(node.id),
escapeCSVField(node.properties.name || ''), // label is stored in name
escapeCSVField(node.properties.heuristicLabel || ''),
- keywordsStr, // Array format for LadybugDB
+ escapeCSVField(keywordsStr), // Array format for LadybugDB, needs CSV escaping for commas
escapeCSVField((node.properties as any).description || ''),
escapeCSVField((node.properties as any).enrichedBy || 'heuristic'),
escapeCSVNumber(node.properties.cohesion, 0),
@@ -312,7 +341,17 @@ export const generateAllCSVs = (
nodeCSVs.set('CodeElement', generateCodeElementCSV(nodes, 'CodeElement', fileContents));
nodeCSVs.set('Community', generateCommunityCSV(nodes));
nodeCSVs.set('Process', generateProcessCSV(nodes));
-
+
+ // Generate CSVs for remaining multi-language tables (no isExported column)
+ const handledTables = new Set([
+ 'File', 'Folder', 'Function', 'Class', 'Interface', 'Method', 'CodeElement', 'Community', 'Process',
+ ]);
+ for (const table of NODE_TABLES) {
+ if (!handledTables.has(table)) {
+ nodeCSVs.set(table, generateMultiLangCSV(nodes, table as NodeLabel, fileContents));
+ }
+ }
+
// Generate single relation CSV
const relCSV = generateRelationCSV(graph);
diff --git a/gitnexus-web/src/core/lbug/lbug-adapter.ts b/gitnexus-web/src/core/lbug/lbug-adapter.ts
index 1ec99be5d7..ce6b8beeb4 100644
--- a/gitnexus-web/src/core/lbug/lbug-adapter.ts
+++ b/gitnexus-web/src/core/lbug/lbug-adapter.ts
@@ -106,6 +106,16 @@ export const loadGraphToLbug = async (
}
const { lbug: lbugModule } = await initLbug();
+ // Close previous connection/database to avoid leaking WASM resources across repo switches
+ if (conn) {
+ try { await conn.close(); } catch {}
+ conn = null;
+ }
+ if (db) {
+ try { await db.close(); } catch {}
+ db = null;
+ }
+
// Recreate a fresh in-memory DB each load to avoid cleanup/quoting issues with reserved names
const BUFFER_POOL_SIZE = 512 * 1024 * 1024; // 512MB (mirror init)
db = new lbugModule.Database(':memory:', BUFFER_POOL_SIZE);
diff --git a/gitnexus-web/src/core/lbug/schema.ts b/gitnexus-web/src/core/lbug/schema.ts
index 53a4dab750..4949c33f2f 100644
--- a/gitnexus-web/src/core/lbug/schema.ts
+++ b/gitnexus-web/src/core/lbug/schema.ts
@@ -26,7 +26,7 @@ export type NodeTableName = typeof NODE_TABLES[number];
export const REL_TABLE_NAME = 'CodeRelation';
// Valid relation types
-export const REL_TYPES = ['CONTAINS', 'DEFINES', 'IMPORTS', 'CALLS', 'EXTENDS', 'IMPLEMENTS', 'MEMBER_OF', 'STEP_IN_PROCESS'] as const;
+export const REL_TYPES = ['CONTAINS', 'DEFINES', 'IMPORTS', 'CALLS', 'EXTENDS', 'IMPLEMENTS', 'MEMBER_OF', 'STEP_IN_PROCESS', 'HAS_METHOD', 'HAS_PROPERTY', 'OVERRIDES', 'ACCESSES', 'INHERITS', 'USES', 'DECORATES'] as const;
export type RelType = typeof REL_TYPES[number];
// ============================================================================
diff --git a/gitnexus-web/src/core/llm/agent.ts b/gitnexus-web/src/core/llm/agent.ts
index 88e07d40f7..3fcd40bdfa 100644
--- a/gitnexus-web/src/core/llm/agent.ts
+++ b/gitnexus-web/src/core/llm/agent.ts
@@ -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();
const yieldedToolResults = new Set();
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;
+ 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);
}
}
}
diff --git a/gitnexus-web/src/core/llm/settings-service.ts b/gitnexus-web/src/core/llm/settings-service.ts
index f4ef5993aa..7fc9f57655 100644
--- a/gitnexus-web/src/core/llm/settings-service.ts
+++ b/gitnexus-web/src/core/llm/settings-service.ts
@@ -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 | 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 | null => {
+ const raw = storage.getItem(STORAGE_KEY);
+ if (!raw) return null;
+ try {
+ return JSON.parse(raw) as Partial;
+ } 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;
-
- // 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
*/
-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;
+type ProviderBuilder = (settings: LLMSettings) => ProviderConfig | null;
- case 'minimax':
- if (!settings.minimax?.apiKey) {
- return null;
- }
- return {
- provider: 'minimax',
- ...settings.minimax,
- } as MiniMaxConfig;
+const providerBuilders: Record = {
+ 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;
+ },
+};
- default:
- return null;
- }
+export const getActiveProviderConfig = (): ProviderConfig | null => {
+ const settings = loadSettings();
+ 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> => {
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) => ({
diff --git a/gitnexus-web/src/core/llm/types.ts b/gitnexus-web/src/core/llm/types.ts
index d2597ec184..4c611294f9 100644
--- a/gitnexus-web/src/core/llm/types.ts
+++ b/gitnexus-web/src/core/llm/types.ts
@@ -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: {
diff --git a/gitnexus-web/src/hooks/app-state/graph.tsx b/gitnexus-web/src/hooks/app-state/graph.tsx
new file mode 100644
index 0000000000..abe91eefff
--- /dev/null
+++ b/gitnexus-web/src/hooks/app-state/graph.tsx
@@ -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;
+ setFileContents: (contents: Map) => 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;
+ setHighlightedNodeIds: (ids: Set) => void;
+}
+
+const GraphStateContext = createContext(null);
+
+export const GraphStateProvider = ({ children }: { children: ReactNode }) => {
+ const [graph, setGraph] = useState(null);
+ const [fileContents, setFileContents] = useState>(new Map());
+ const [selectedNode, setSelectedNode] = useState(null);
+ const [visibleLabels, setVisibleLabels] = useState(DEFAULT_VISIBLE_LABELS);
+ const [visibleEdgeTypes, setVisibleEdgeTypes] = useState(DEFAULT_VISIBLE_EDGES);
+ const [depthFilter, setDepthFilter] = useState(null);
+ const [highlightedNodeIds, setHighlightedNodeIds] = useState>(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(() => ({
+ graph,
+ setGraph,
+ fileContents,
+ setFileContents,
+ selectedNode,
+ setSelectedNode,
+ visibleLabels,
+ toggleLabelVisibility,
+ visibleEdgeTypes,
+ toggleEdgeVisibility,
+ depthFilter,
+ setDepthFilter,
+ highlightedNodeIds,
+ setHighlightedNodeIds,
+ }), [graph, fileContents, selectedNode, visibleLabels, visibleEdgeTypes, depthFilter, highlightedNodeIds]);
+
+ return (
+
+ {children}
+
+ );
+};
+
+export const useGraphState = (): GraphStateContextValue => {
+ const ctx = useContext(GraphStateContext);
+ if (!ctx) {
+ throw new Error('useGraphState must be used within a GraphStateProvider');
+ }
+ return ctx;
+};
diff --git a/gitnexus-web/src/hooks/useAppState.tsx b/gitnexus-web/src/hooks/useAppState.tsx
index f5c11fa668..e8efe68325 100644
--- a/gitnexus-web/src/hooks/useAppState.tsx
+++ b/gitnexus-web/src/hooks/useAppState.tsx
@@ -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;
+ startEmbeddingsWithFallback: () => void;
semanticSearch: (query: string, k?: number) => Promise;
semanticSearchWithContext: (query: string, k?: number, hops?: number) => Promise;
isEmbeddingReady: boolean;
@@ -145,9 +151,7 @@ interface AppState {
llmSettings: LLMSettings;
updateLLMSettings: (updates: Partial) => 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(null);
-export const AppStateProvider = ({ children }: { children: ReactNode }) => {
+export const AppStateProvider = ({ children }: { children: ReactNode }) => (
+
+ {children}
+
+);
+
+const AppStateProviderInner = ({ children }: { children: ReactNode }) => {
// View state
const [viewMode, setViewMode] = useState('onboarding');
- // Graph data
- const [graph, setGraph] = useState(null);
- const [fileContents, setFileContents] = useState>(new Map());
-
- // Selection
- const [selectedNode, setSelectedNode] = useState(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('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(DEFAULT_VISIBLE_LABELS);
- const [visibleEdgeTypes, setVisibleEdgeTypes] = useState(DEFAULT_VISIBLE_EDGES);
-
- // Depth filter
- const [depthFilter, setDepthFilter] = useState(null);
-
// Query state
- const [highlightedNodeIds, setHighlightedNodeIds] = useState>(new Set());
const [queryResult, setQueryResult] = useState(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(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(null);
@@ -313,54 +325,24 @@ export const AppStateProvider = ({ children }: { children: ReactNode }) => {
const [isCodePanelOpen, setCodePanelOpen] = useState(false);
const [codeReferenceFocus, setCodeReferenceFocus] = useState(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;
-
- // 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 };
+ return resolvePathFromContents(fileContents, requestedPath);
+ }, [fileContents]);
+
+ const fileNodeByPath = useMemo(() => {
+ if (!graph) return new Map();
+ const map = new Map();
+ 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) => {
@@ -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();
- 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();
- 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,
diff --git a/gitnexus-web/src/hooks/useBackend.ts b/gitnexus-web/src/hooks/useBackend.ts
index 949e6ba2aa..9ab8cf8c75 100644
--- a/gitnexus-web/src/hooks/useBackend.ts
+++ b/gitnexus-web/src/hooks/useBackend.ts
@@ -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],
);
diff --git a/gitnexus-web/src/lib/constants.ts b/gitnexus-web/src/lib/constants.ts
index efe94286d2..2011986243 100644
--- a/gitnexus-web/src/lib/constants.ts
+++ b/gitnexus-web/src/lib/constants.ts
@@ -19,6 +19,7 @@ export const NODE_COLORS: Record = {
CodeElement: '#64748b', // Slate - muted
Community: '#818cf8', // Indigo light - cluster indicator
Process: '#f43f5e', // Rose - execution flow indicator
+ Section: '#60a5fa', // Blue light - structural section
};
// Node sizes by type - clear visual hierarchy with dramatic size differences
@@ -41,6 +42,7 @@ export const NODE_SIZES: Record = {
CodeElement: 2, // Generic small
Community: 0, // Hidden by default - metadata node
Process: 0, // Hidden by default - metadata node
+ Section: 8, // Structural section - similar to Folder
};
// Community color palette for cluster-based coloring
diff --git a/gitnexus-web/src/lib/grounding-patterns.ts b/gitnexus-web/src/lib/grounding-patterns.ts
new file mode 100644
index 0000000000..34f9f2d578
--- /dev/null
+++ b/gitnexus-web/src/lib/grounding-patterns.ts
@@ -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;
diff --git a/gitnexus-web/src/lib/lucide-icons.tsx b/gitnexus-web/src/lib/lucide-icons.tsx
new file mode 100644
index 0000000000..dd2dee1dac
--- /dev/null
+++ b/gitnexus-web/src/lib/lucide-icons.tsx
@@ -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,
+};
diff --git a/gitnexus-web/src/lib/path-resolution.ts b/gitnexus-web/src/lib/path-resolution.ts
new file mode 100644
index 0000000000..c54909167a
--- /dev/null
+++ b/gitnexus-web/src/lib/path-resolution.ts
@@ -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, 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;
+};
diff --git a/gitnexus-web/src/types/lbug-wasm.d.ts b/gitnexus-web/src/types/lbug-wasm.d.ts
index 669c61139b..02fd4b6b64 100644
--- a/gitnexus-web/src/types/lbug-wasm.d.ts
+++ b/gitnexus-web/src/types/lbug-wasm.d.ts
@@ -12,9 +12,8 @@ declare module '@ladybugdb/wasm-core' {
close(): Promise;
}
export interface QueryResult {
- getAll?(): Promise;
- getAllRows?(): Promise;
- getAllObjects?(): Promise;
+ getAll(): Promise;
+ getAllRows(): Promise;
hasNext(): Promise;
getNext(): Promise;
}
diff --git a/gitnexus-web/test/fixtures/graph.ts b/gitnexus-web/test/fixtures/graph.ts
new file mode 100644
index 0000000000..a8814e80ee
--- /dev/null
+++ b/gitnexus-web/test/fixtures/graph.ts
@@ -0,0 +1,66 @@
+/**
+ * Shared test data factories for graph structures.
+ * No test code — pure data exports.
+ */
+
+import type { GraphNode, GraphRelationship } from '../../src/core/graph/types';
+
+export function createFileNode(name: string, filePath?: string): GraphNode {
+ return {
+ id: `File:${filePath ?? name}`,
+ label: 'File',
+ properties: { name, filePath: filePath ?? name },
+ };
+}
+
+export function createFunctionNode(name: string, filePath: string, line = 1): GraphNode {
+ return {
+ id: `Function:${filePath}:${name}:${line}`,
+ label: 'Function',
+ properties: { name, filePath, startLine: line, endLine: line + 10 },
+ };
+}
+
+export function createClassNode(name: string, filePath: string): GraphNode {
+ return {
+ id: `Class:${filePath}:${name}`,
+ label: 'Class',
+ properties: { name, filePath },
+ };
+}
+
+export function createProcessNode(id: string, label: string, type: 'cross_community' | 'intra_community' = 'cross_community'): GraphNode {
+ return {
+ id,
+ label: 'Process',
+ properties: {
+ name: label,
+ heuristicLabel: label,
+ processType: type,
+ stepCount: 3,
+ communities: ['cluster-a', 'cluster-b'],
+ } as any,
+ };
+}
+
+export function createCallsRelationship(sourceId: string, targetId: string): GraphRelationship {
+ return {
+ id: `${sourceId}_CALLS_${targetId}`,
+ sourceId,
+ targetId,
+ type: 'CALLS',
+ confidence: 0.9,
+ reason: 'same-file',
+ };
+}
+
+export function createContainsRelationship(sourceId: string, targetId: string): GraphRelationship {
+ return {
+ id: `${sourceId}_CONTAINS_${targetId}`,
+ sourceId,
+ targetId,
+ type: 'CONTAINS',
+ confidence: 1.0,
+ reason: '',
+ };
+}
diff --git a/gitnexus-web/test/setup.ts b/gitnexus-web/test/setup.ts
index c6a2e2d532..860ee70c15 100644
--- a/gitnexus-web/test/setup.ts
+++ b/gitnexus-web/test/setup.ts
@@ -1,6 +1,8 @@
import { beforeEach } from 'vitest';
+import '@testing-library/jest-dom/vitest';
+// Reset storage between tests
beforeEach(() => {
- sessionStorage.clear();
- localStorage.clear();
+ sessionStorage.removeItem('gitnexus-llm-settings');
+ localStorage.removeItem('gitnexus-llm-settings'); // legacy key (migration)
});
diff --git a/gitnexus-web/test/unit/constants.test.ts b/gitnexus-web/test/unit/constants.test.ts
new file mode 100644
index 0000000000..c886dfdfd1
--- /dev/null
+++ b/gitnexus-web/test/unit/constants.test.ts
@@ -0,0 +1,81 @@
+import { describe, expect, it } from 'vitest';
+import {
+ NODE_COLORS,
+ NODE_SIZES,
+ COMMUNITY_COLORS,
+ getCommunityColor,
+ DEFAULT_VISIBLE_LABELS,
+ FILTERABLE_LABELS,
+ ALL_EDGE_TYPES,
+ DEFAULT_VISIBLE_EDGES,
+ EDGE_INFO,
+} from '../../src/lib/constants';
+
+describe('NODE_COLORS', () => {
+ it('has a color for every node label used in NODE_SIZES', () => {
+ for (const label of Object.keys(NODE_SIZES)) {
+ expect(NODE_COLORS).toHaveProperty(label);
+ expect(NODE_COLORS[label as keyof typeof NODE_COLORS]).toMatch(/^#[0-9a-f]{6}$/i);
+ }
+ });
+});
+
+describe('NODE_SIZES', () => {
+ it('gives Project the largest size', () => {
+ const maxLabel = Object.entries(NODE_SIZES).reduce((a, b) => a[1] > b[1] ? a : b);
+ expect(maxLabel[0]).toBe('Project');
+ });
+
+ it('gives structural nodes larger sizes than code nodes', () => {
+ expect(NODE_SIZES.Folder).toBeGreaterThan(NODE_SIZES.Function);
+ expect(NODE_SIZES.File).toBeGreaterThan(NODE_SIZES.Variable);
+ });
+});
+
+describe('getCommunityColor', () => {
+ it('returns valid hex colors', () => {
+ for (let i = 0; i < 20; i++) {
+ expect(getCommunityColor(i)).toMatch(/^#[0-9a-f]{6}$/i);
+ }
+ });
+
+ it('wraps around the palette', () => {
+ const paletteSize = COMMUNITY_COLORS.length;
+ expect(getCommunityColor(0)).toBe(getCommunityColor(paletteSize));
+ expect(getCommunityColor(1)).toBe(getCommunityColor(paletteSize + 1));
+ });
+});
+
+describe('DEFAULT_VISIBLE_LABELS', () => {
+ it('includes common structural and code labels', () => {
+ expect(DEFAULT_VISIBLE_LABELS).toContain('File');
+ expect(DEFAULT_VISIBLE_LABELS).toContain('Function');
+ expect(DEFAULT_VISIBLE_LABELS).toContain('Class');
+ });
+
+ it('excludes noisy labels by default', () => {
+ expect(DEFAULT_VISIBLE_LABELS).not.toContain('Variable');
+ expect(DEFAULT_VISIBLE_LABELS).not.toContain('Import');
+ });
+});
+
+describe('edge types', () => {
+ it('ALL_EDGE_TYPES contains all EDGE_INFO keys', () => {
+ const edgeInfoKeys = Object.keys(EDGE_INFO).sort();
+ const allEdgeTypes = [...ALL_EDGE_TYPES].sort();
+ expect(edgeInfoKeys).toEqual(allEdgeTypes);
+ });
+
+ it('DEFAULT_VISIBLE_EDGES is a subset of ALL_EDGE_TYPES', () => {
+ for (const type of DEFAULT_VISIBLE_EDGES) {
+ expect(ALL_EDGE_TYPES).toContain(type);
+ }
+ });
+
+ it('EDGE_INFO entries have color and label', () => {
+ for (const info of Object.values(EDGE_INFO)) {
+ expect(info.color).toMatch(/^#[0-9a-f]{6}$/i);
+ expect(info.label.length).toBeGreaterThan(0);
+ }
+ });
+});
diff --git a/gitnexus-web/test/unit/csv-generator.test.ts b/gitnexus-web/test/unit/csv-generator.test.ts
new file mode 100644
index 0000000000..b26ee67f76
--- /dev/null
+++ b/gitnexus-web/test/unit/csv-generator.test.ts
@@ -0,0 +1,201 @@
+import { describe, expect, it } from 'vitest';
+import { generateAllCSVs } from '../../src/core/lbug/csv-generator';
+import { createKnowledgeGraph } from '../../src/core/graph/graph';
+import { NODE_TABLES } from '../../src/core/lbug/schema';
+
+describe('generateAllCSVs', () => {
+ it('generates CSV for all NODE_TABLES present in the graph', () => {
+ const graph = createKnowledgeGraph();
+ // Add one node per multi-language table type
+ const testTables = ['Struct', 'Enum', 'Trait', 'Impl', 'Macro', 'TypeAlias'] as const;
+ for (const label of testTables) {
+ graph.addNode({
+ id: `${label}:test.rs:MyItem`,
+ label,
+ properties: { name: 'MyItem', filePath: 'test.rs', startLine: 1, endLine: 10 },
+ });
+ }
+
+ const csvData = generateAllCSVs(graph, new Map());
+
+ for (const label of testTables) {
+ const csv = csvData.nodes.get(label);
+ expect(csv, `CSV for ${label} should be generated`).toBeDefined();
+ expect(csv!.split('\n').length, `CSV for ${label} should have header + 1 row`).toBeGreaterThanOrEqual(2);
+ }
+ });
+
+ it('multi-language table CSVs have 6 columns (no isExported)', () => {
+ const graph = createKnowledgeGraph();
+ graph.addNode({
+ id: 'Struct:lib.rs:Point',
+ label: 'Struct',
+ properties: { name: 'Point', filePath: 'lib.rs', startLine: 5, endLine: 15 },
+ });
+
+ const csvData = generateAllCSVs(graph, new Map());
+ const csv = csvData.nodes.get('Struct')!;
+ const header = csv.split('\n')[0];
+ const columns = header.split(',').length;
+ expect(columns).toBe(6); // id, name, filePath, startLine, endLine, content
+ });
+
+ it('community keywords with commas are properly CSV-escaped', () => {
+ const graph = createKnowledgeGraph();
+ graph.addNode({
+ id: 'comm_0',
+ label: 'Community',
+ properties: {
+ name: 'TestCluster',
+ heuristicLabel: 'test',
+ keywords: ['auth, login', 'user management'],
+ description: 'test community',
+ cohesion: 0.8,
+ symbolCount: 5,
+ },
+ });
+
+ const csvData = generateAllCSVs(graph, new Map());
+ const csv = csvData.nodes.get('Community')!;
+ const rows = csv.split('\n');
+ expect(rows.length).toBeGreaterThanOrEqual(2);
+ // The keywords field should be quoted (RFC 4180) since it contains commas
+ const dataRow = rows[1];
+ expect(dataRow).toContain('auth');
+ expect(dataRow).toContain('login');
+ });
+
+ it('generates File CSV with content from fileContents map', () => {
+ const graph = createKnowledgeGraph();
+ graph.addNode({
+ id: 'File:src/index.ts',
+ label: 'File',
+ properties: { name: 'index.ts', filePath: 'src/index.ts' },
+ });
+
+ const fileContents = new Map([['src/index.ts', 'console.log("hello")']]);
+ const csvData = generateAllCSVs(graph, fileContents);
+ const csv = csvData.nodes.get('File')!;
+ expect(csv).toContain('hello');
+ });
+
+ it('returns a relCSV string', () => {
+ const graph = createKnowledgeGraph();
+ graph.addNode({
+ id: 'Function:a.ts:foo',
+ label: 'Function',
+ properties: { name: 'foo', filePath: 'a.ts', startLine: 1, endLine: 5 },
+ });
+ graph.addNode({
+ id: 'Function:a.ts:bar',
+ label: 'Function',
+ properties: { name: 'bar', filePath: 'a.ts', startLine: 10, endLine: 15 },
+ });
+ graph.addRelationship({
+ sourceId: 'Function:a.ts:foo',
+ targetId: 'Function:a.ts:bar',
+ type: 'CALLS',
+ properties: {},
+ });
+
+ const csvData = generateAllCSVs(graph, new Map());
+ expect(csvData.relCSV).toContain('CALLS');
+ expect(csvData.relCSV).toContain('Function:a.ts:foo');
+ });
+
+ it('handles all NODE_TABLES without crashing', () => {
+ const graph = createKnowledgeGraph();
+ for (const table of NODE_TABLES) {
+ if (table === 'File' || table === 'Folder' || table === 'Community' || table === 'Process') continue;
+ graph.addNode({
+ id: `${table}:test:item`,
+ label: table,
+ properties: { name: 'item', filePath: 'test', startLine: 1, endLine: 2 },
+ });
+ }
+
+ expect(() => generateAllCSVs(graph, new Map())).not.toThrow();
+ const csvData = generateAllCSVs(graph, new Map());
+ expect(csvData.nodes.size).toBeGreaterThan(0);
+ });
+
+ // ── Negative tests ──────────────────────────────────────────────
+
+ it('empty graph produces no node CSVs with data rows', () => {
+ const graph = createKnowledgeGraph();
+ const csvData = generateAllCSVs(graph, new Map());
+ // Nodes map may have header-only entries or be empty
+ for (const [, csv] of csvData.nodes.entries()) {
+ const rows = csv.split('\n').filter(r => r.trim());
+ expect(rows.length).toBeLessThanOrEqual(1); // header only, no data
+ }
+ });
+
+ it('empty graph produces relCSV with only a header', () => {
+ const graph = createKnowledgeGraph();
+ const csvData = generateAllCSVs(graph, new Map());
+ const lines = csvData.relCSV.split('\n').filter(r => r.trim());
+ expect(lines.length).toBeLessThanOrEqual(1);
+ });
+
+ it('node with double quotes in name is properly escaped', () => {
+ const graph = createKnowledgeGraph();
+ graph.addNode({
+ id: 'Function:a.ts:say"hello"',
+ label: 'Function',
+ properties: { name: 'say"hello"', filePath: 'a.ts', startLine: 1, endLine: 5 },
+ });
+
+ const csvData = generateAllCSVs(graph, new Map());
+ const csv = csvData.nodes.get('Function')!;
+ // RFC 4180: double quotes inside fields are doubled
+ expect(csv).toContain('""');
+ });
+
+ it('file node without matching fileContents gets empty content', () => {
+ const graph = createKnowledgeGraph();
+ graph.addNode({
+ id: 'File:missing.ts',
+ label: 'File',
+ properties: { name: 'missing.ts', filePath: 'missing.ts' },
+ });
+
+ const csvData = generateAllCSVs(graph, new Map()); // no file contents
+ const csv = csvData.nodes.get('File')!;
+ const rows = csv.split('\n');
+ expect(rows.length).toBeGreaterThanOrEqual(2);
+ // Content field should be empty (not undefined or crash)
+ });
+
+ it('community with empty keywords array produces valid CSV', () => {
+ const graph = createKnowledgeGraph();
+ graph.addNode({
+ id: 'comm_1',
+ label: 'Community',
+ properties: {
+ name: 'EmptyCluster',
+ heuristicLabel: 'empty',
+ keywords: [],
+ description: '',
+ cohesion: 0,
+ symbolCount: 0,
+ },
+ });
+
+ expect(() => generateAllCSVs(graph, new Map())).not.toThrow();
+ const csvData = generateAllCSVs(graph, new Map());
+ expect(csvData.nodes.get('Community')).toBeDefined();
+ });
+
+ it('node labels not in NODE_TABLES are silently skipped', () => {
+ const graph = createKnowledgeGraph();
+ graph.addNode({
+ id: 'FakeLabel:test:item',
+ label: 'FakeLabel' as any,
+ properties: { name: 'item', filePath: 'test' },
+ });
+
+ // Should not crash — unknown labels are just not in any CSV
+ expect(() => generateAllCSVs(graph, new Map())).not.toThrow();
+ });
+});
diff --git a/gitnexus-web/test/unit/graph.test.ts b/gitnexus-web/test/unit/graph.test.ts
new file mode 100644
index 0000000000..8c62e4454c
--- /dev/null
+++ b/gitnexus-web/test/unit/graph.test.ts
@@ -0,0 +1,68 @@
+import { describe, expect, it } from 'vitest';
+import { createKnowledgeGraph } from '../../src/core/graph/graph';
+import { createFileNode, createFunctionNode, createCallsRelationship, createContainsRelationship } from '../fixtures/graph';
+
+describe('createKnowledgeGraph', () => {
+ it('starts empty', () => {
+ const graph = createKnowledgeGraph();
+ expect(graph.nodeCount).toBe(0);
+ expect(graph.relationshipCount).toBe(0);
+ expect(graph.nodes).toEqual([]);
+ expect(graph.relationships).toEqual([]);
+ });
+
+ it('adds nodes', () => {
+ const graph = createKnowledgeGraph();
+ const node = createFileNode('index.ts', 'src/index.ts');
+ graph.addNode(node);
+
+ expect(graph.nodeCount).toBe(1);
+ expect(graph.nodes[0].id).toBe('File:src/index.ts');
+ });
+
+ it('deduplicates nodes by id', () => {
+ const graph = createKnowledgeGraph();
+ const node = createFileNode('index.ts', 'src/index.ts');
+ const duplicateNode = createFileNode('index.ts', 'src/index.ts');
+ graph.addNode(node);
+ graph.addNode(duplicateNode);
+
+ expect(graph.nodeCount).toBe(1);
+ });
+
+ it('adds relationships', () => {
+ const graph = createKnowledgeGraph();
+ const rel = createCallsRelationship('fn:a', 'fn:b');
+ graph.addRelationship(rel);
+
+ expect(graph.relationshipCount).toBe(1);
+ expect(graph.relationships[0].type).toBe('CALLS');
+ });
+
+ it('deduplicates relationships by id', () => {
+ const graph = createKnowledgeGraph();
+ const rel = createCallsRelationship('fn:a', 'fn:b');
+ const duplicateRel = createCallsRelationship('fn:a', 'fn:b');
+ graph.addRelationship(rel);
+ graph.addRelationship(duplicateRel);
+
+ expect(graph.relationshipCount).toBe(1);
+ });
+
+ it('builds a multi-node graph', () => {
+ const graph = createKnowledgeGraph();
+ const file = createFileNode('app.ts', 'src/app.ts');
+ const fn1 = createFunctionNode('main', 'src/app.ts', 1);
+ const fn2 = createFunctionNode('helper', 'src/app.ts', 20);
+
+ graph.addNode(file);
+ graph.addNode(fn1);
+ graph.addNode(fn2);
+ graph.addRelationship(createContainsRelationship(file.id, fn1.id));
+ graph.addRelationship(createContainsRelationship(file.id, fn2.id));
+ graph.addRelationship(createCallsRelationship(fn1.id, fn2.id));
+
+ expect(graph.nodeCount).toBe(3);
+ expect(graph.relationshipCount).toBe(3);
+ });
+});
diff --git a/gitnexus-web/test/unit/mermaid-generator.test.ts b/gitnexus-web/test/unit/mermaid-generator.test.ts
new file mode 100644
index 0000000000..6f0b64a6be
--- /dev/null
+++ b/gitnexus-web/test/unit/mermaid-generator.test.ts
@@ -0,0 +1,107 @@
+import { describe, expect, it } from 'vitest';
+import { generateProcessMermaid, generateSimpleMermaid } from '../../src/lib/mermaid-generator';
+import type { ProcessData } from '../../src/lib/mermaid-generator';
+
+describe('generateProcessMermaid', () => {
+ it('returns placeholder for empty steps', () => {
+ const process: ProcessData = {
+ id: 'p1',
+ label: 'Empty',
+ processType: 'intra_community',
+ steps: [],
+ };
+ expect(generateProcessMermaid(process)).toContain('No steps found');
+ });
+
+ it('generates a linear chain without edges', () => {
+ const process: ProcessData = {
+ id: 'p1',
+ label: 'GET -> Handler',
+ processType: 'intra_community',
+ steps: [
+ { id: 'fn:a', name: 'handleGet', filePath: 'src/routes.ts', stepNumber: 1 },
+ { id: 'fn:b', name: 'validate', filePath: 'src/validate.ts', stepNumber: 2 },
+ { id: 'fn:c', name: 'respond', filePath: 'src/respond.ts', stepNumber: 3 },
+ ],
+ };
+
+ const result = generateProcessMermaid(process);
+ expect(result).toContain('graph TD');
+ expect(result).toContain('handleGet');
+ expect(result).toContain('validate');
+ expect(result).toContain('respond');
+ // Linear chain: a -> b -> c
+ expect(result).toContain('-->');
+ });
+
+ it('uses CALLS edges when provided', () => {
+ const process: ProcessData = {
+ id: 'p1',
+ label: 'Branching',
+ processType: 'intra_community',
+ steps: [
+ { id: 'fn:a', name: 'entry', filePath: 'src/a.ts', stepNumber: 1 },
+ { id: 'fn:b', name: 'branchA', filePath: 'src/b.ts', stepNumber: 2 },
+ { id: 'fn:c', name: 'branchB', filePath: 'src/c.ts', stepNumber: 3 },
+ ],
+ edges: [
+ { from: 'fn:a', to: 'fn:b', type: 'CALLS' },
+ { from: 'fn:a', to: 'fn:c', type: 'CALLS' },
+ ],
+ };
+
+ const result = generateProcessMermaid(process);
+ // Both edges should appear
+ expect(result).toContain('fn_a --> fn_b');
+ expect(result).toContain('fn_a --> fn_c');
+ });
+
+ it('applies entry and terminal classes', () => {
+ const process: ProcessData = {
+ id: 'p1',
+ label: 'Flow',
+ processType: 'intra_community',
+ steps: [
+ { id: 'fn:start', name: 'start', filePath: 'src/a.ts', stepNumber: 1 },
+ { id: 'fn:end', name: 'end', filePath: 'src/b.ts', stepNumber: 2 },
+ ],
+ };
+
+ const result = generateProcessMermaid(process);
+ expect(result).toContain(':::entry');
+ expect(result).toContain(':::terminal');
+ });
+
+ it('uses subgraphs for cross-community processes with clusters', () => {
+ const process: ProcessData = {
+ id: 'p1',
+ label: 'Cross',
+ processType: 'cross_community',
+ steps: [
+ { id: 'fn:a', name: 'a', filePath: 'src/a.ts', stepNumber: 1, cluster: 'Auth' },
+ { id: 'fn:b', name: 'b', filePath: 'src/b.ts', stepNumber: 2, cluster: 'DB' },
+ ],
+ };
+
+ const result = generateProcessMermaid(process);
+ expect(result).toContain('subgraph');
+ expect(result).toContain('Auth');
+ expect(result).toContain('DB');
+ });
+});
+
+describe('generateSimpleMermaid', () => {
+ it('generates a preview with entry and terminal', () => {
+ const result = generateSimpleMermaid('POST -> ShouldRedact', 5);
+ expect(result).toContain('graph LR');
+ expect(result).toContain('POST');
+ expect(result).toContain('ShouldRedact');
+ expect(result).toContain('3 steps');
+ });
+
+ it('handles labels without arrow', () => {
+ const result = generateSimpleMermaid('SingleNode', 2);
+ expect(result).toContain('graph LR');
+ expect(result).toContain('SingleNode');
+ });
+});
diff --git a/gitnexus-web/test/unit/path-resolution.test.ts b/gitnexus-web/test/unit/path-resolution.test.ts
new file mode 100644
index 0000000000..e3bd59221a
--- /dev/null
+++ b/gitnexus-web/test/unit/path-resolution.test.ts
@@ -0,0 +1,31 @@
+import { describe, expect, it } from 'vitest';
+import { normalizePath, resolveFilePath } from '../../src/lib/path-resolution';
+
+describe('path-resolution utilities', () => {
+ const contents = new Map([
+ ['src/components/Header.tsx', ''],
+ ['src/core/utils/index.ts', ''],
+ ['README.md', ''],
+ ['src/lib/path-resolution.ts', ''],
+ ]);
+
+ it('normalizes leading ./ and backslashes', () => {
+ expect(normalizePath('./src\\components\\Header.tsx')).toBe('src/components/Header.tsx');
+ });
+
+ it('prefers exact matches', () => {
+ expect(resolveFilePath(contents, 'src/components/Header.tsx')).toBe('src/components/Header.tsx');
+ });
+
+ it('resolves ends-with partials', () => {
+ expect(resolveFilePath(contents, 'core/utils/index.ts')).toBe('src/core/utils/index.ts');
+ });
+
+ it('falls back to segment matching', () => {
+ expect(resolveFilePath(contents, 'lib/path')).toBe('src/lib/path-resolution.ts');
+ });
+
+ it('returns null for empty requests', () => {
+ expect(resolveFilePath(contents, '')).toBeNull();
+ });
+});
diff --git a/gitnexus-web/test/unit/perf-optimizations.test.ts b/gitnexus-web/test/unit/perf-optimizations.test.ts
new file mode 100644
index 0000000000..a8c4cd919a
--- /dev/null
+++ b/gitnexus-web/test/unit/perf-optimizations.test.ts
@@ -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();
+ 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);
+ });
+});
diff --git a/gitnexus-web/test/unit/server-connection.test.ts b/gitnexus-web/test/unit/server-connection.test.ts
new file mode 100644
index 0000000000..28228f0c0a
--- /dev/null
+++ b/gitnexus-web/test/unit/server-connection.test.ts
@@ -0,0 +1,76 @@
+import { describe, expect, it } from 'vitest';
+import { normalizeServerUrl, extractFileContents } from '../../src/services/server-connection';
+import type { GraphNode } from '../../src/core/graph/types';
+
+describe('normalizeServerUrl', () => {
+ it('adds http:// to localhost', () => {
+ expect(normalizeServerUrl('localhost:4747')).toBe('http://localhost:4747/api');
+ });
+
+ it('adds http:// to 127.0.0.1', () => {
+ expect(normalizeServerUrl('127.0.0.1:4747')).toBe('http://127.0.0.1:4747/api');
+ });
+
+ it('adds https:// to non-local hosts', () => {
+ expect(normalizeServerUrl('example.com')).toBe('https://example.com/api');
+ });
+
+ it('strips trailing slashes', () => {
+ expect(normalizeServerUrl('http://localhost:4747/')).toBe('http://localhost:4747/api');
+ expect(normalizeServerUrl('http://localhost:4747///')).toBe('http://localhost:4747/api');
+ });
+
+ it('does not double-append /api', () => {
+ expect(normalizeServerUrl('http://localhost:4747/api')).toBe('http://localhost:4747/api');
+ });
+
+ it('trims whitespace', () => {
+ expect(normalizeServerUrl(' localhost:4747 ')).toBe('http://localhost:4747/api');
+ });
+
+ it('preserves existing https://', () => {
+ expect(normalizeServerUrl('https://gitnexus.example.com')).toBe('https://gitnexus.example.com/api');
+ });
+});
+
+describe('extractFileContents', () => {
+ it('extracts content from File nodes', () => {
+ const nodes: GraphNode[] = [
+ {
+ id: 'File:src/index.ts',
+ label: 'File',
+ properties: { name: 'index.ts', filePath: 'src/index.ts', content: 'console.log("hello")' } as any,
+ },
+ ];
+ const result = extractFileContents(nodes);
+ expect(result['src/index.ts']).toBe('console.log("hello")');
+ });
+
+ it('ignores non-File nodes', () => {
+ const nodes: GraphNode[] = [
+ {
+ id: 'Function:main',
+ label: 'Function',
+ properties: { name: 'main', filePath: 'src/index.ts', content: 'fn body' } as any,
+ },
+ ];
+ const result = extractFileContents(nodes);
+ expect(Object.keys(result)).toHaveLength(0);
+ });
+
+ it('ignores File nodes without content', () => {
+ const nodes: GraphNode[] = [
+ {
+ id: 'File:src/empty.ts',
+ label: 'File',
+ properties: { name: 'empty.ts', filePath: 'src/empty.ts' },
+ },
+ ];
+ const result = extractFileContents(nodes);
+ expect(Object.keys(result)).toHaveLength(0);
+ });
+
+ it('returns empty object for empty input', () => {
+ expect(extractFileContents([])).toEqual({});
+ });
+});
diff --git a/gitnexus-web/test/unit/settings-service.test.ts b/gitnexus-web/test/unit/settings-service.test.ts
new file mode 100644
index 0000000000..19065b5129
--- /dev/null
+++ b/gitnexus-web/test/unit/settings-service.test.ts
@@ -0,0 +1,145 @@
+import { describe, expect, it } from 'vitest';
+import {
+ loadSettings,
+ saveSettings,
+ setActiveProvider,
+ getActiveProviderConfig,
+ isProviderConfigured,
+ clearSettings,
+ getProviderDisplayName,
+ getAvailableModels,
+} from '../../src/core/llm/settings-service';
+
+describe('loadSettings', () => {
+ it('returns defaults when nothing is stored', () => {
+ const settings = loadSettings();
+ expect(settings.activeProvider).toBeDefined();
+ expect(settings.openai).toBeDefined();
+ expect(settings.ollama).toBeDefined();
+ });
+
+ it('merges stored values with defaults', () => {
+ sessionStorage.setItem('gitnexus-llm-settings', JSON.stringify({
+ activeProvider: 'ollama',
+ ollama: { model: 'qwen3-coder:30b' },
+ }));
+
+ const settings = loadSettings();
+ expect(settings.activeProvider).toBe('ollama');
+ expect(settings.ollama.model).toBe('qwen3-coder:30b');
+ // Should still have other provider defaults
+ expect(settings.openai).toBeDefined();
+ });
+
+ it('returns defaults on corrupted JSON', () => {
+ sessionStorage.setItem('gitnexus-llm-settings', 'not-json{{{');
+ const settings = loadSettings();
+ expect(settings.activeProvider).toBeDefined();
+ });
+
+ it('migrates legacy localStorage to sessionStorage', () => {
+ localStorage.setItem('gitnexus-llm-settings', JSON.stringify({
+ activeProvider: 'ollama',
+ ollama: { model: 'migrated-model' },
+ }));
+
+ const settings = loadSettings();
+ expect(settings.ollama.model).toBe('migrated-model');
+ expect(sessionStorage.getItem('gitnexus-llm-settings')).not.toBeNull();
+ expect(localStorage.getItem('gitnexus-llm-settings')).toBeNull();
+ });
+});
+
+describe('saveSettings / clearSettings', () => {
+ it('persists settings to sessionStorage', () => {
+ const settings = loadSettings();
+ settings.activeProvider = 'anthropic';
+ saveSettings(settings);
+ expect(loadSettings().activeProvider).toBe('anthropic');
+ });
+
+ it('clearSettings removes settings from both storages', () => {
+ saveSettings({ ...loadSettings(), activeProvider: 'anthropic' });
+ expect(sessionStorage.getItem('gitnexus-llm-settings')).not.toBeNull();
+ clearSettings();
+ expect(sessionStorage.getItem('gitnexus-llm-settings')).toBeNull();
+ expect(localStorage.getItem('gitnexus-llm-settings')).toBeNull();
+ });
+});
+
+describe('setActiveProvider', () => {
+ it('changes the active provider and persists', () => {
+ setActiveProvider('gemini');
+ expect(loadSettings().activeProvider).toBe('gemini');
+ });
+});
+
+describe('getActiveProviderConfig', () => {
+ it('returns null for unconfigured providers requiring API keys', () => {
+ setActiveProvider('openai');
+ expect(getActiveProviderConfig()).toBeNull();
+ });
+
+ it('returns config for ollama without API key', () => {
+ setActiveProvider('ollama');
+ const config = getActiveProviderConfig();
+ expect(config).not.toBeNull();
+ expect(config!.provider).toBe('ollama');
+ });
+
+ it('returns config for openai when API key is set', () => {
+ const settings = loadSettings();
+ settings.activeProvider = 'openai';
+ settings.openai = { ...settings.openai, apiKey: 'sk-test-123' };
+ saveSettings(settings);
+
+ const config = getActiveProviderConfig();
+ expect(config).not.toBeNull();
+ expect(config!.provider).toBe('openai');
+ });
+
+ it('returns null for openrouter with empty API key', () => {
+ const settings = loadSettings();
+ settings.activeProvider = 'openrouter';
+ settings.openrouter = { ...settings.openrouter, apiKey: ' ' };
+ saveSettings(settings);
+
+ expect(getActiveProviderConfig()).toBeNull();
+ });
+});
+
+describe('isProviderConfigured', () => {
+ it('returns false when provider requires API key and none is set', () => {
+ // Manually build a clean openai config with no API key
+ saveSettings({ ...loadSettings(), activeProvider: 'openai', openai: { apiKey: '', model: 'gpt-4o', temperature: 0.1 } });
+ expect(isProviderConfigured()).toBe(false);
+ });
+
+ it('returns true for ollama (no key required)', () => {
+ setActiveProvider('ollama');
+ expect(isProviderConfigured()).toBe(true);
+ });
+});
+
+describe('getProviderDisplayName', () => {
+ it('returns human-readable names', () => {
+ expect(getProviderDisplayName('openai')).toBe('OpenAI');
+ expect(getProviderDisplayName('azure-openai')).toBe('Azure OpenAI');
+ expect(getProviderDisplayName('gemini')).toBe('Google Gemini');
+ expect(getProviderDisplayName('anthropic')).toBe('Anthropic');
+ expect(getProviderDisplayName('ollama')).toBe('Ollama (Local)');
+ expect(getProviderDisplayName('openrouter')).toBe('OpenRouter');
+ });
+});
+
+describe('getAvailableModels', () => {
+ it('returns models for known providers', () => {
+ expect(getAvailableModels('openai').length).toBeGreaterThan(0);
+ expect(getAvailableModels('ollama').length).toBeGreaterThan(0);
+ expect(getAvailableModels('anthropic')).toContain('claude-sonnet-4-20250514');
+ });
+
+ it('returns empty array for unknown provider', () => {
+ expect(getAvailableModels('unknown' as any)).toEqual([]);
+ });
+});
diff --git a/gitnexus-web/test/unit/utils.test.ts b/gitnexus-web/test/unit/utils.test.ts
new file mode 100644
index 0000000000..066b06a6e1
--- /dev/null
+++ b/gitnexus-web/test/unit/utils.test.ts
@@ -0,0 +1,17 @@
+import { describe, expect, it } from 'vitest';
+import { generateId } from '../../src/lib/utils';
+
+describe('generateId', () => {
+ it('creates label:name format', () => {
+ expect(generateId('File', 'index.ts')).toBe('File:index.ts');
+ expect(generateId('Function', 'main')).toBe('Function:main');
+ });
+
+ it('handles empty strings', () => {
+ expect(generateId('', '')).toBe(':');
+ });
+
+ it('preserves special characters in name', () => {
+ expect(generateId('File', 'src/components/App.tsx')).toBe('File:src/components/App.tsx');
+ });
+});
diff --git a/gitnexus-web/vitest.config.ts b/gitnexus-web/vitest.config.ts
index d2d63450d2..1af686ab5b 100644
--- a/gitnexus-web/vitest.config.ts
+++ b/gitnexus-web/vitest.config.ts
@@ -1,17 +1,40 @@
import { defineConfig } from 'vitest/config';
+import react from '@vitejs/plugin-react';
import path from 'path';
export default defineConfig({
- test: {
- environment: 'jsdom',
- globals: true,
- setupFiles: ['test/setup.ts'],
- include: ['test/**/*.test.ts'],
- exclude: ['**/node_modules/**', '**/dist/**'],
- },
+ plugins: [react()],
resolve: {
alias: {
'@': path.resolve(__dirname, './src'),
+ '@anthropic-ai/sdk/lib/transform-json-schema': path.resolve(__dirname, 'node_modules/@anthropic-ai/sdk/lib/transform-json-schema.mjs'),
+ 'mermaid': path.resolve(__dirname, 'node_modules/mermaid/dist/mermaid.esm.min.mjs'),
+ },
+ },
+ test: {
+ globals: true,
+ environment: 'jsdom',
+ setupFiles: ['./test/setup.ts'],
+ include: ['test/**/*.test.{ts,tsx}'],
+ testTimeout: 15000,
+ coverage: {
+ provider: 'v8',
+ include: ['src/**/*.{ts,tsx}'],
+ exclude: [
+ 'src/workers/**', // Web workers (require worker env)
+ 'src/core/lbug/**', // WASM (requires SharedArrayBuffer)
+ 'src/core/tree-sitter/**', // WASM (requires tree-sitter binaries)
+ 'src/core/embeddings/**', // WASM (requires ML model)
+ 'src/main.tsx', // Entry point
+ 'src/vite-env.d.ts', // Type declarations
+ ],
+ thresholds: {
+ statements: 10,
+ branches: 10,
+ functions: 10,
+ lines: 10,
+ },
},
},
});
+
diff --git a/gitnexus/vitest.config.ts b/gitnexus/vitest.config.ts
index 475bf8eadd..fbec3724b5 100644
--- a/gitnexus/vitest.config.ts
+++ b/gitnexus/vitest.config.ts
@@ -32,7 +32,6 @@ export default defineConfig({
branches: 23,
functions: 28,
lines: 27,
- autoUpdate: true,
},
},
@@ -90,3 +89,4 @@ export default defineConfig({
],
},
});
+