From 0d9a2ca6b676894604184bea096bf3208bd269c2 Mon Sep 17 00:00:00 2001 From: jreakin Date: Mon, 23 Mar 2026 10:16:33 -0500 Subject: [PATCH 1/8] fix(web): allow foreignObject in DOMPurify SVG sanitization Mermaid uses foreignObject for HTML text labels inside flowchart nodes. The SVG profile strips them by default, causing empty boxes. ADD_TAGS: ['foreignObject'] preserves text while still sanitizing against XSS. Co-Authored-By: Claude Opus 4.6 (1M context) --- gitnexus-web/src/components/MermaidDiagram.tsx | 2 +- gitnexus-web/src/components/ProcessFlowModal.tsx | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/gitnexus-web/src/components/MermaidDiagram.tsx b/gitnexus-web/src/components/MermaidDiagram.tsx index 0c6ebc239..5a0598b25 100644 --- a/gitnexus-web/src/components/MermaidDiagram.tsx +++ b/gitnexus-web/src/components/MermaidDiagram.tsx @@ -141,7 +141,7 @@ export const MermaidDiagram = ({ code }: MermaidDiagramProps) => {
diff --git a/gitnexus-web/src/components/ProcessFlowModal.tsx b/gitnexus-web/src/components/ProcessFlowModal.tsx index fabad1eed..93017f376 100644 --- a/gitnexus-web/src/components/ProcessFlowModal.tsx +++ b/gitnexus-web/src/components/ProcessFlowModal.tsx @@ -148,7 +148,7 @@ export const ProcessFlowModal = ({ process, onClose, onFocusInGraph, isFullScree const { svg } = await mermaid.render(id, mermaidCode); if (!diagramRef.current) return; - diagramRef.current!.innerHTML = DOMPurify.sanitize(svg, { USE_PROFILES: { svg: true, svgFilters: true } }); + diagramRef.current!.innerHTML = DOMPurify.sanitize(svg, { USE_PROFILES: { svg: true, svgFilters: true }, ADD_TAGS: ['foreignObject'] }); } catch (error) { console.error('Mermaid render error:', error); const errorMessage = error instanceof Error ? error.message : String(error); From 4199c5e4f3c4d18615c51249d2d5eeb66c0705b2 Mon Sep 17 00:00:00 2001 From: jreakin Date: Mon, 23 Mar 2026 02:55:06 -0500 Subject: [PATCH 2/8] perf(web): O(1) lookups, memoization, bundle optimizations, React fixes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- gitnexus-web/src/App.tsx | 50 +-- .../src/components/CodeReferencesPanel.tsx | 13 +- gitnexus-web/src/components/DropZone.tsx | 18 +- gitnexus-web/src/components/FileTreePanel.tsx | 14 +- gitnexus-web/src/components/GraphCanvas.tsx | 33 +- .../src/components/MarkdownRenderer.tsx | 18 +- gitnexus-web/src/components/SettingsPanel.tsx | 400 ++++++------------ gitnexus-web/src/components/StatusBar.tsx | 9 +- gitnexus-web/src/config/ui-constants.ts | 7 + gitnexus-web/src/core/llm/agent.ts | 30 +- gitnexus-web/src/core/llm/settings-service.ts | 244 ++++++----- gitnexus-web/src/core/llm/types.ts | 6 +- gitnexus-web/src/hooks/app-state/graph.tsx | 75 ++++ gitnexus-web/src/hooks/useAppState.tsx | 210 +++++---- gitnexus-web/src/hooks/useBackend.ts | 14 +- gitnexus-web/src/lib/grounding-patterns.ts | 7 + gitnexus-web/src/lib/lucide-icons.tsx | 198 +++++++++ gitnexus-web/src/lib/path-resolution.ts | 45 ++ 18 files changed, 811 insertions(+), 580 deletions(-) create mode 100644 gitnexus-web/src/config/ui-constants.ts create mode 100644 gitnexus-web/src/hooks/app-state/graph.tsx create mode 100644 gitnexus-web/src/lib/grounding-patterns.ts create mode 100644 gitnexus-web/src/lib/lucide-icons.tsx create mode 100644 gitnexus-web/src/lib/path-resolution.ts diff --git a/gitnexus-web/src/App.tsx b/gitnexus-web/src/App.tsx index 553a4c7cd..33809a9af 100644 --- a/gitnexus-web/src/App.tsx +++ b/gitnexus-web/src/App.tsx @@ -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(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 => { // 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} /> - setHelpDialogBoxOpen(false)} - nodeCount={graph!.nodes.length} - edgeCount={graph!.relationships.length} - /> - ); }; diff --git a/gitnexus-web/src/components/CodeReferencesPanel.tsx b/gitnexus-web/src/components/CodeReferencesPanel.tsx index 021ccd902..b1bcd1897 100644 --- a/gitnexus-web/src/components/CodeReferencesPanel.tsx +++ b/gitnexus-web/src/components/CodeReferencesPanel.tsx @@ -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(() => { diff --git a/gitnexus-web/src/components/DropZone.tsx b/gitnexus-web/src/components/DropZone.tsx index fa7857668..64e5798eb 100644 --- a/gitnexus-web/src/components/DropZone.tsx +++ b/gitnexus-web/src/components/DropZone.tsx @@ -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(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); diff --git a/gitnexus-web/src/components/FileTreePanel.tsx b/gitnexus-web/src/components/FileTreePanel.tsx index 6daca3709..2007e54d4 100644 --- a/gitnexus-web/src/components/FileTreePanel.tsx +++ b/gitnexus-web/src/components/FileTreePanel.tsx @@ -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 diff --git a/gitnexus-web/src/components/GraphCanvas.tsx b/gitnexus-web/src/components/GraphCanvas.tsx index 7618478a0..e5afc2ce9 100644 --- a/gitnexus-web/src/components/GraphCanvas.tsx +++ b/gitnexus-web/src/components/GraphCanvas.tsx @@ -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((_, ref) => { return animatedNodes; }, [animatedNodes, isAIHighlightsEnabled]); + const nodeById = useMemo(() => { + if (!graph) return new Map(); + 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((_, 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((_, 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((_, 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((_, ref) => { const sigmaGraph = knowledgeGraphToGraphology(graph, communityMemberships); setSigmaGraph(sigmaGraph); - }, [graph, setSigmaGraph]); + }, [graph, nodeById, setSigmaGraph]); // Update node visibility when filters change useEffect(() => { diff --git a/gitnexus-web/src/components/MarkdownRenderer.tsx b/gitnexus-web/src/components/MarkdownRenderer.tsx index 77f6e6f8d..8981c5756 100644 --- a/gitnexus-web/src/components/MarkdownRenderer.tsx +++ b/gitnexus-web/src/components/MarkdownRenderer.tsx @@ -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 = ({ showCopyButton = false }) => { const [copied, setCopied] = useState(false); + const copyTimerRef = useRef>(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); } diff --git a/gitnexus-web/src/components/SettingsPanel.tsx b/gitnexus-web/src/components/SettingsPanel.tsx index d60475107..c93dd7df9 100644 --- a/gitnexus-web/src/components/SettingsPanel.tsx +++ b/gitnexus-web/src/components/SettingsPanel.tsx @@ -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(loadSettings); const [showApiKey, setShowApiKey] = useState>({}); const [saveStatus, setSaveStatus] = useState<'idle' | 'saved' | 'error'>('idle'); + const saveTimerRef = useRef>(undefined); // Ollama connection state const [ollamaError, setOllamaError] = useState(null); const [isCheckingOllama, setIsCheckingOllama] = useState(false); @@ -223,6 +226,15 @@ export const SettingsPanel = ({ isOpen, onClose, onSettingsSaved, backendUrl, is const [openRouterModels, setOpenRouterModels] = useState>([]); 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 +
+ API keys are stored in session storage and will be cleared when you close this tab. +
+ {/* OpenAI Settings */} {settings.activeProvider === 'openai' && ( -
-
- -
- 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" - /> - -
-

- Get your API key from{' '} - - OpenAI Platform - -

-
- -
- - 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" - /> -
- + 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 } + })), + }} + >
-
+ )} {/* Gemini Settings */} {settings.activeProvider === 'gemini' && ( -
-
- -
- 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" - /> - -
-

- Get your API key from{' '} - - Google AI Studio - -

-
- -
- - 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" - /> -
-
+ 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' && ( -
-
- -
- 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" - /> - -
-

- Get your API key from{' '} - - Anthropic Console - -

-
- -
- - 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" - /> -
-
+ 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
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" /> -
-

- Get your API key from{' '} - - OpenRouter Keys - -

- - + setSettings(prev => ({ + ...prev, + openrouter: { ...prev.openrouter!, apiKey: value } + })), + onToggleVisibility: () => toggleApiKeyVisibility('openrouter'), + }} + >

- +
)} {/* MiniMax Settings */} {settings.activeProvider === 'minimax' && ( -
-
- -
- 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" - /> - -
-

- Get your API key from{' '} - - MiniMax Platform - -

-
- -
- - 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" - /> -

- Available models: MiniMax-M2.5 (default), MiniMax-M2.5-highspeed (faster) -

-
-
+ 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 πŸ”’
- Privacy: 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. + Privacy: 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.
diff --git a/gitnexus-web/src/components/StatusBar.tsx b/gitnexus-web/src/components/StatusBar.tsx index 3240a008a..6a6bed6a1 100644 --- a/gitnexus-web/src/components/StatusBar.tsx +++ b/gitnexus-web/src/components/StatusBar.tsx @@ -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); return Object.entries(counts).sort((a, b) => b[1] - a[1])[0]?.[0]; - })(); + }, [graph]); return (