From c4f37da535f28feede86a65409f4bc0c0ca24a21 Mon Sep 17 00:00:00 2001 From: Rocky0102 Date: Tue, 3 Mar 2026 11:53:56 +0800 Subject: [PATCH 1/3] feat: Add multi-repo switching support with server backend integration - Add currentRepoName state tracking in useAppState for repo context - Update initializeAgent to accept overrideRepoName parameter for server mode - Implement server-mode query execution with repo parameter in runQuery - Fix HTTP endpoint paths in ingestion worker (remove duplicate /api prefix) - Increase agent recursion limit from 50 to 200 for complex multi-step reasoning - Pass repo context through App.tsx handleServerConnect callback Enables seamless switching between multiple indexed repositories when connected to a server backend, with proper repo context propagation through query execution and agent initialization. --- gitnexus-web/src/App.tsx | 9 ++- gitnexus-web/src/core/llm/agent.ts | 3 +- gitnexus-web/src/hooks/useAppState.tsx | 69 ++++++++++++++++++-- gitnexus-web/src/workers/ingestion.worker.ts | 6 +- 4 files changed, 74 insertions(+), 13 deletions(-) diff --git a/gitnexus-web/src/App.tsx b/gitnexus-web/src/App.tsx index 2de2a4a34a..0a123b6401 100644 --- a/gitnexus-web/src/App.tsx +++ b/gitnexus-web/src/App.tsx @@ -37,6 +37,8 @@ const AppContent = () => { isCodePanelOpen, serverBaseUrl, setServerBaseUrl, + currentRepoName, + setCurrentRepoName, availableRepos, setAvailableRepos, switchRepo, @@ -132,11 +134,12 @@ const AppContent = () => { } }, [setViewMode, setGraph, setFileContents, setProgress, setProjectName, runPipelineFromFiles, startEmbeddings, initializeAgent]); - const handleServerConnect = useCallback((result: ConnectToServerResult) => { + const handleServerConnect = useCallback((result: ConnectToServerResult, repoName?: string) => { // Extract project name from repoPath const repoPath = result.repoInfo.repoPath; - const projectName = repoPath.split('/').pop() || 'server-project'; + const projectName = result.repoInfo.name || repoPath.split('/').pop() || 'server-project'; setProjectName(projectName); + setCurrentRepoName(repoName || null); // Build KnowledgeGraph from server data (bypasses WASM pipeline entirely) const graph = createKnowledgeGraph(); @@ -171,7 +174,7 @@ const AppContent = () => { console.warn('Embeddings auto-start failed:', err); } }); - }, [setViewMode, setGraph, setFileContents, setProjectName, initializeAgent, startEmbeddings]); + }, [setViewMode, setGraph, setFileContents, setProjectName, setCurrentRepoName, initializeAgent, startEmbeddings]); // Auto-connect when ?server query param is present (bookmarkable shortcut) const autoConnectRan = useRef(false); diff --git a/gitnexus-web/src/core/llm/agent.ts b/gitnexus-web/src/core/llm/agent.ts index 6649f57421..8f46ae443a 100644 --- a/gitnexus-web/src/core/llm/agent.ts +++ b/gitnexus-web/src/core/llm/agent.ts @@ -327,7 +327,8 @@ export async function* streamAgentResponse( { streamMode: ['values', 'messages'] as any, // Allow longer tool/reasoning loops (more Cursor-like persistence) - recursionLimit: 50, + // Increased from 50 to 200 to handle complex multi-step reasoning + recursionLimit: 200, } as any ); diff --git a/gitnexus-web/src/hooks/useAppState.tsx b/gitnexus-web/src/hooks/useAppState.tsx index 233a710ee0..79d5f43eb9 100644 --- a/gitnexus-web/src/hooks/useAppState.tsx +++ b/gitnexus-web/src/hooks/useAppState.tsx @@ -116,6 +116,8 @@ interface AppState { // Multi-repo switching serverBaseUrl: string | null; setServerBaseUrl: (url: string | null) => void; + currentRepoName: string | null; + setCurrentRepoName: (name: string | null) => void; availableRepos: RepoSummary[]; setAvailableRepos: (repos: RepoSummary[]) => void; switchRepo: (repoName: string) => Promise; @@ -155,7 +157,7 @@ interface AppState { // LLM methods refreshLLMSettings: () => void; - initializeAgent: (overrideProjectName?: string) => Promise; + initializeAgent: (overrideProjectName?: string, overrideRepoName?: string) => Promise; sendChatMessage: (message: string) => Promise; stopChatResponse: () => void; clearChat: () => void; @@ -281,6 +283,7 @@ export const AppStateProvider = ({ children }: { children: ReactNode }) => { // Multi-repo switching const [serverBaseUrl, setServerBaseUrl] = useState(null); + const [currentRepoName, setCurrentRepoName] = useState(null); const [availableRepos, setAvailableRepos] = useState([]); // Embedding state @@ -467,12 +470,43 @@ export const AppStateProvider = ({ children }: { children: ReactNode }) => { }, []); const runQuery = useCallback(async (cypher: string): Promise => { + // Server mode: use HTTP API + if (serverBaseUrl) { + try { + const body: any = { cypher }; + if (currentRepoName) { + body.repo = currentRepoName; + } + const response = await fetch(`${serverBaseUrl}/query`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(body), + }); + if (!response.ok) { + const errorData = await response.json().catch(() => ({})); + throw new Error(errorData.error || `Server returned ${response.status}`); + } + const data = await response.json(); + return data.result ?? data; + } catch (err) { + console.error('Server query failed:', err); + throw err; + } + } + + // Local mode: use worker const api = apiRef.current; if (!api) throw new Error('Worker not initialized'); return api.runQuery(cypher); - }, []); + }, [serverBaseUrl, currentRepoName]); const isDatabaseReady = useCallback(async (): Promise => { + // Server mode: always ready if connected + if (serverBaseUrl) { + return true; + } + + // Local mode: check worker const api = apiRef.current; if (!api) return false; try { @@ -480,7 +514,7 @@ export const AppStateProvider = ({ children }: { children: ReactNode }) => { } catch { return false; } - }, []); + }, [serverBaseUrl]); // Embedding methods const startEmbeddings = useCallback(async (forceDevice?: 'webgpu' | 'wasm'): Promise => { @@ -565,7 +599,7 @@ export const AppStateProvider = ({ children }: { children: ReactNode }) => { setLLMSettings(loadSettings()); }, []); - const initializeAgent = useCallback(async (overrideProjectName?: string): Promise => { + const initializeAgent = useCallback(async (overrideProjectName?: string, overrideRepoName?: string): Promise => { const api = apiRef.current; if (!api) { setAgentError('Worker not initialized'); @@ -584,7 +618,25 @@ export const AppStateProvider = ({ children }: { children: ReactNode }) => { try { // Use override if provided (for fresh loads), fallback to state (for re-init) const effectiveProjectName = overrideProjectName || projectName || 'project'; - const result = await api.initializeAgent(config, effectiveProjectName); + const effectiveRepoName = overrideRepoName !== undefined ? overrideRepoName : currentRepoName; + + let result; + // Server mode: use backend agent + if (serverBaseUrl) { + // Convert fileContents Map to entries array for Comlink transfer + const fileContentsEntries = Array.from(fileContents.entries()); + result = await api.initializeBackendAgent( + config, + serverBaseUrl, + effectiveRepoName || '', + fileContentsEntries, + effectiveProjectName + ); + } else { + // Local mode: use local agent + result = await api.initializeAgent(config, effectiveProjectName); + } + if (result.success) { setIsAgentReady(true); setAgentError(null); @@ -602,7 +654,7 @@ export const AppStateProvider = ({ children }: { children: ReactNode }) => { } finally { setIsAgentInitializing(false); } - }, [projectName]); + }, [projectName, serverBaseUrl, currentRepoName, fileContents]); const sendChatMessage = useCallback(async (message: string): Promise => { const api = apiRef.current; @@ -1007,6 +1059,7 @@ export const AppStateProvider = ({ children }: { children: ReactNode }) => { const repoPath = result.repoInfo.repoPath; const pName = result.repoInfo.name || repoPath.split('/').pop() || 'server-project'; setProjectName(pName); + setCurrentRepoName(repoName || null); const graph = createKnowledgeGraph(); for (const node of result.nodes) graph.addNode(node); @@ -1019,7 +1072,7 @@ export const AppStateProvider = ({ children }: { children: ReactNode }) => { setViewMode('exploring'); - if (getActiveProviderConfig()) initializeAgent(pName); + if (getActiveProviderConfig()) initializeAgent(pName, repoName); startEmbeddings().catch((err) => { if (err?.name === 'WebGPUNotAvailableError' || err?.message?.includes('WebGPU')) { @@ -1135,6 +1188,8 @@ export const AppStateProvider = ({ children }: { children: ReactNode }) => { // Multi-repo switching serverBaseUrl, setServerBaseUrl, + currentRepoName, + setCurrentRepoName, availableRepos, setAvailableRepos, switchRepo, diff --git a/gitnexus-web/src/workers/ingestion.worker.ts b/gitnexus-web/src/workers/ingestion.worker.ts index 2de63ee3de..d3c5924757 100644 --- a/gitnexus-web/src/workers/ingestion.worker.ts +++ b/gitnexus-web/src/workers/ingestion.worker.ts @@ -74,7 +74,8 @@ const httpFetchWithTimeout = async ( const createHttpExecuteQuery = (backendUrl: string, repo: string) => { return async (cypher: string): Promise => { - const response = await httpFetchWithTimeout(`${backendUrl}/api/query`, { + // backendUrl already includes /api from normalizeServerUrl + const response = await httpFetchWithTimeout(`${backendUrl}/query`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ cypher, repo }), @@ -97,7 +98,8 @@ const createHttpExecuteQuery = (backendUrl: string, repo: string) => { const createHttpHybridSearch = (backendUrl: string, repo: string) => { return async (query: string, k: number = 15): Promise => { try { - const response = await httpFetchWithTimeout(`${backendUrl}/api/search`, { + // backendUrl already includes /api from normalizeServerUrl + const response = await httpFetchWithTimeout(`${backendUrl}/search`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ query, limit: k, repo }), From b7b7c90c001571edff6e0a4998c73cb573708937 Mon Sep 17 00:00:00 2001 From: Rocky0102 Date: Tue, 3 Mar 2026 17:13:48 +0800 Subject: [PATCH 2/3] feat: add resizable panels and per-repo chat session persistence - Implement resizable left/right panels with ResizableDivider component - Left panel (file tree): 200-600px range - Right panel (code/chat): 400-800px range - Add per-repo chat session management with server-side persistence - Sessions organized by repository (repoName field) - Auto-save sessions on task completion - Save sessions before clearing or switching repos - Load/delete/refresh session functionality per repo - Store sessions in separate files: ~/.gitnexus/sessions/{repoName}.json - No quantity limit - all sessions per repo are preserved - Add backend API endpoints: GET/POST/DELETE /api/sessions - Preserve tool call args in UI display - Add args property to ToolCallInfo type - Display tool arguments in ToolCallCard component --- gitnexus-web/src/App.tsx | 36 +++- .../src/components/ChatSessionList.tsx | 105 +++++++++++ gitnexus-web/src/components/FileTreePanel.tsx | 8 +- .../src/components/ResizableDivider.tsx | 76 ++++++++ gitnexus-web/src/components/RightPanel.tsx | 67 +++++-- gitnexus-web/src/components/ToolCallCard.tsx | 10 +- gitnexus-web/src/core/llm/agent.ts | 58 +++++- .../src/core/llm/chat-session-service.ts | 178 ++++++++++++++++++ gitnexus-web/src/core/llm/types.ts | 12 ++ gitnexus-web/src/hooks/useAppState.tsx | 135 ++++++++++++- gitnexus-web/src/services/backend.ts | 62 ++++++ gitnexus/src/server/api.ts | 143 ++++++++++++++ 12 files changed, 856 insertions(+), 34 deletions(-) create mode 100644 gitnexus-web/src/components/ChatSessionList.tsx create mode 100644 gitnexus-web/src/components/ResizableDivider.tsx create mode 100644 gitnexus-web/src/core/llm/chat-session-service.ts diff --git a/gitnexus-web/src/App.tsx b/gitnexus-web/src/App.tsx index 0a123b6401..2216c0f316 100644 --- a/gitnexus-web/src/App.tsx +++ b/gitnexus-web/src/App.tsx @@ -9,6 +9,7 @@ import { SettingsPanel } from './components/SettingsPanel'; import { StatusBar } from './components/StatusBar'; import { FileTreePanel } from './components/FileTreePanel'; import { CodeReferencesPanel } from './components/CodeReferencesPanel'; +import { ResizableDivider } from './components/ResizableDivider'; import { FileEntry } from './services/zip'; import { getActiveProviderConfig } from './core/llm/settings-service'; import { createKnowledgeGraph } from './core/graph/graph'; @@ -42,6 +43,10 @@ const AppContent = () => { availableRepos, setAvailableRepos, switchRepo, + leftPanelWidth, + setLeftPanelWidth, + rightPanelWidth, + setRightPanelWidth, } = useAppState(); const graphCanvasRef = useRef(null); @@ -235,6 +240,15 @@ const AppContent = () => { graphCanvasRef.current?.focusNode(nodeId); }, []); + // Handle panel resize + const handleLeftPanelResize = useCallback((delta: number) => { + setLeftPanelWidth(prev => Math.max(200, Math.min(600, prev + delta))); + }, [setLeftPanelWidth]); + + const handleRightPanelResize = useCallback((delta: number) => { + setRightPanelWidth(prev => Math.max(400, Math.min(800, prev + delta))); + }, [setRightPanelWidth]); + // Handle settings saved - refresh and reinitialize agent // NOTE: Must be defined BEFORE any conditional returns (React hooks rule) const handleSettingsSaved = useCallback(() => { @@ -276,7 +290,15 @@ const AppContent = () => {
{/* Left Panel - File Tree */} - + + + {/* Left Divider */} + {/* Graph area - takes remaining space */}
@@ -290,8 +312,18 @@ const AppContent = () => { )}
+ {/* Right Divider */} + {isRightPanelOpen && ( + + )} + {/* Right Panel - Code & Chat (tabbed) */} - {isRightPanelOpen && } + {isRightPanelOpen && }
diff --git a/gitnexus-web/src/components/ChatSessionList.tsx b/gitnexus-web/src/components/ChatSessionList.tsx new file mode 100644 index 0000000000..c3c0199fb0 --- /dev/null +++ b/gitnexus-web/src/components/ChatSessionList.tsx @@ -0,0 +1,105 @@ +/** + * Chat Session List Component + * + * Displays a list of saved chat sessions with options to: + * - Load a session (restore messages to chat panel) + * - Delete a session + * - Shows session name, repo, and timestamp + */ + +import { useMemo } from 'react'; +import { MessageSquare, Trash2, Clock } from 'lucide-react'; +import { useAppState } from '../hooks/useAppState'; +import type { ChatSession } from '../core/llm/types'; +import { formatSessionDate } from '../core/llm/chat-session-service'; + +interface ChatSessionListProps { + onSessionSelect?: () => void; +} + +export const ChatSessionList = ({ onSessionSelect }: ChatSessionListProps) => { + const { chatSessions, currentSessionId, loadSession, deleteSession, currentRepoName } = useAppState(); + + // Sort sessions by updatedAt (newest first) + const sortedSessions = useMemo(() => { + return [...chatSessions].sort((a, b) => b.updatedAt - a.updatedAt); + }, [chatSessions]); + + const handleLoadSession = (sessionId: string) => { + loadSession(sessionId); + onSessionSelect?.(); + }; + + const handleDeleteSession = (e: React.MouseEvent, sessionId: string) => { + e.stopPropagation(); + if (confirm('Are you sure you want to delete this session?')) { + deleteSession(sessionId); + } + }; + + if (chatSessions.length === 0) { + return ( +
+ +

No saved sessions yet

+

+ Sessions are auto-saved when tasks complete +

+
+ ); + } + + return ( +
+ {/* Header */} +
+

Chat History

+ {chatSessions.length} sessions +
+ + {/* Session List */} +
+ {sortedSessions.map((session) => ( +
handleLoadSession(session.id)} + className={`group flex items-start gap-3 px-4 py-3 cursor-pointer transition-colors hover:bg-hover border-b border-border-subtle last:border-0 ${ + currentSessionId === session.id ? 'bg-accent/10 border-l-2 border-accent' : 'border-l-2 border-transparent' + }`} + > + {/* Icon */} +
+ +
+ + {/* Content */} +
+

+ {session.name} +

+
+ + + {formatSessionDate(session.updatedAt)} + +
+
+ + {/* Delete Button */} + +
+ ))} +
+
+ ); +}; diff --git a/gitnexus-web/src/components/FileTreePanel.tsx b/gitnexus-web/src/components/FileTreePanel.tsx index 6daca37090..e1008278c3 100644 --- a/gitnexus-web/src/components/FileTreePanel.tsx +++ b/gitnexus-web/src/components/FileTreePanel.tsx @@ -192,9 +192,10 @@ const getNodeTypeIcon = (label: NodeLabel) => { interface FileTreePanelProps { onFocusNode: (nodeId: string) => void; + width: number; } -export const FileTreePanel = ({ onFocusNode }: FileTreePanelProps) => { +export const FileTreePanel = ({ onFocusNode, width }: FileTreePanelProps) => { const { graph, visibleLabels, toggleLabelVisibility, visibleEdgeTypes, toggleEdgeVisibility, selectedNode, setSelectedNode, openCodePanel, depthFilter, setDepthFilter } = useAppState(); const [isCollapsed, setIsCollapsed] = useState(false); @@ -297,7 +298,10 @@ export const FileTreePanel = ({ onFocusNode }: FileTreePanelProps) => { } return ( -
+
{/* Header */}
diff --git a/gitnexus-web/src/components/ResizableDivider.tsx b/gitnexus-web/src/components/ResizableDivider.tsx new file mode 100644 index 0000000000..017c14e93d --- /dev/null +++ b/gitnexus-web/src/components/ResizableDivider.tsx @@ -0,0 +1,76 @@ +import { useCallback, useEffect, useRef, useState } from 'react'; +import { GripVertical } from 'lucide-react'; + +interface ResizableDividerProps { + onResize: (delta: number) => void; + minWidth?: number; + maxWidth?: number; + side: 'left' | 'right'; +} + +export const ResizableDivider = ({ onResize, minWidth = 200, maxWidth = 800, side }: ResizableDividerProps) => { + const [isDragging, setIsDragging] = useState(false); + const startXRef = useRef(0); + + const handleMouseDown = useCallback((e: React.MouseEvent) => { + e.preventDefault(); + setIsDragging(true); + startXRef.current = e.clientX; + }, []); + + useEffect(() => { + if (!isDragging) return; + + const handleMouseMove = (e: MouseEvent) => { + const delta = side === 'left' + ? e.clientX - startXRef.current + : startXRef.current - e.clientX; + + startXRef.current = e.clientX; + onResize(delta); + }; + + const handleMouseUp = () => { + setIsDragging(false); + }; + + document.addEventListener('mousemove', handleMouseMove); + document.addEventListener('mouseup', handleMouseUp); + + // Add cursor style to body while dragging + document.body.style.cursor = 'col-resize'; + document.body.style.userSelect = 'none'; + + return () => { + document.removeEventListener('mousemove', handleMouseMove); + document.removeEventListener('mouseup', handleMouseUp); + document.body.style.cursor = ''; + document.body.style.userSelect = ''; + }; + }, [isDragging, onResize, side]); + + return ( +
+ {/* Wider hit area for easier grabbing */} +
+ + {/* Visual grip indicator */} +
+ +
+
+ ); +}; diff --git a/gitnexus-web/src/components/RightPanel.tsx b/gitnexus-web/src/components/RightPanel.tsx index 84be0f7385..ac50fc41fd 100644 --- a/gitnexus-web/src/components/RightPanel.tsx +++ b/gitnexus-web/src/components/RightPanel.tsx @@ -1,14 +1,20 @@ import { useState, useRef, useEffect, useCallback } from 'react'; import { Send, Square, Sparkles, User, - PanelRightClose, Loader2, AlertTriangle, GitBranch + PanelRightClose, Loader2, AlertTriangle, GitBranch, + History, Save } from 'lucide-react'; import { useAppState } from '../hooks/useAppState'; import { ToolCallCard } from './ToolCallCard'; import { isProviderConfigured } from '../core/llm/settings-service'; import { MarkdownRenderer } from './MarkdownRenderer'; import { ProcessesPanel } from './ProcessesPanel'; -export const RightPanel = () => { +import { ChatSessionList } from './ChatSessionList'; +interface RightPanelProps { + width: number; +} + +export const RightPanel = ({ width }: RightPanelProps) => { const { isRightPanelOpen, setRightPanelOpen, @@ -25,10 +31,12 @@ export const RightPanel = () => { sendChatMessage, stopChatResponse, clearChat, + saveCurrentSession, + currentSessionId, } = useAppState(); const [chatInput, setChatInput] = useState(''); - const [activeTab, setActiveTab] = useState<'chat' | 'processes'>('chat'); + const [activeTab, setActiveTab] = useState<'chat' | 'processes' | 'history'>('chat'); const textareaRef = useRef(null); const messagesEndRef = useRef(null); @@ -208,7 +216,10 @@ export const RightPanel = () => { if (!isRightPanelOpen) return null; return ( -
)} + {/* History Tab - Session List */} + {activeTab === 'history' && ( +
+ setActiveTab('chat')} /> +
+ )} + {/* Chat Content - only show when chat tab is active */} {activeTab === 'chat' && (
diff --git a/gitnexus-web/src/components/ToolCallCard.tsx b/gitnexus-web/src/components/ToolCallCard.tsx index e202f83d6b..8f7963e39a 100644 --- a/gitnexus-web/src/components/ToolCallCard.tsx +++ b/gitnexus-web/src/components/ToolCallCard.tsx @@ -99,7 +99,7 @@ export const ToolCallCard = ({ toolCall, defaultExpanded = false }: ToolCallCard const [isExpanded, setIsExpanded] = useState(defaultExpanded); const status = getStatusDisplay(toolCall.status); const formattedArgs = formatArgs(toolCall.args); - + return (
{/* Header - always visible */} @@ -130,13 +130,13 @@ export const ToolCallCard = ({ toolCall, defaultExpanded = false }: ToolCallCard {/* Expanded content */} {isExpanded && (
- {/* Arguments/Query */} + {/* Parameters/Input - always shown for all tool types */} {formattedArgs && (
-
- {toolCall.name === 'cypher' ? 'Query' : 'Input'} +
+ Parameters
-
+              
                 {formattedArgs}
               
diff --git a/gitnexus-web/src/core/llm/agent.ts b/gitnexus-web/src/core/llm/agent.ts index 8f46ae443a..cb517c8296 100644 --- a/gitnexus-web/src/core/llm/agent.ts +++ b/gitnexus-web/src/core/llm/agent.ts @@ -335,6 +335,7 @@ export async function* streamAgentResponse( // Track what we've yielded to avoid duplicates const yieldedToolCalls = new Set(); const yieldedToolResults = new Set(); + const toolCallArgsMap = new Map }>(); let lastProcessedMsgCount = formattedMessages.length; // Track if all tools are done (for distinguishing reasoning vs final content) let allToolsDone = true; @@ -416,12 +417,32 @@ export async function* streamAgentResponse( const toolId = tc.id || `tool-${Date.now()}-${Math.random().toString(36).slice(2)}`; if (!yieldedToolCalls.has(toolId)) { yieldedToolCalls.add(toolId); + const toolName = tc.name || tc.function?.name || 'unknown'; + + // Try multiple possible locations for args + let toolArgs: Record = {}; + if (tc.args && Object.keys(tc.args).length > 0) { + toolArgs = tc.args; + } else if ((tc as any).input) { + // LangChain might use 'input' field + toolArgs = (tc as any).input; + } else if (tc.function?.arguments) { + try { + toolArgs = JSON.parse(tc.function.arguments); + } catch { + toolArgs = {}; + } + } + + // Store args for later use when result comes back + toolCallArgsMap.set(toolId, { name: toolName, args: toolArgs }); + 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) : {}), + name: toolName, + args: toolArgs, status: 'running', }, }; @@ -436,12 +457,16 @@ export async function* streamAgentResponse( if (toolCallId && !yieldedToolResults.has(toolCallId)) { yieldedToolResults.add(toolCallId); const result = typeof msg.content === 'string' ? msg.content : JSON.stringify(msg.content); + + // Retrieve stored args from the original tool call + const storedToolInfo = toolCallArgsMap.get(toolCallId); + yield { type: 'tool_result', toolCall: { id: toolCallId, - name: msg.name || 'tool', - args: {}, + name: storedToolInfo?.name || msg.name || 'tool', + args: storedToolInfo?.args || {}, result: result, status: 'completed', }, @@ -462,19 +487,30 @@ export async function* streamAgentResponse( const msgType = msg._getType?.() || msg.type || 'unknown'; // Catch tool calls from values mode (backup) - if ((msgType === 'ai' || msgType === 'AIMessage') && !yieldedToolCalls.size) { + if ((msgType === 'ai' || msgType === 'AIMessage')) { const toolCalls = msg.tool_calls || []; + for (const tc of toolCalls) { const toolId = tc.id || `tool-${Date.now()}`; + const toolName = tc.name || 'unknown'; + const toolArgs = tc.args || {}; + + // Always update the map with values mode data (it has complete args) + // This will overwrite any incomplete data from messages mode + if (Object.keys(toolArgs).length > 0) { + toolCallArgsMap.set(toolId, { name: toolName, args: toolArgs }); + } + if (!yieldedToolCalls.has(toolId)) { allToolsDone = false; yieldedToolCalls.add(toolId); + yield { type: 'tool_call', toolCall: { id: toolId, - name: tc.name || 'unknown', - args: tc.args || {}, + name: toolName, + args: toolArgs, status: 'running', }, }; @@ -488,12 +524,16 @@ export async function* streamAgentResponse( if (toolCallId && !yieldedToolResults.has(toolCallId)) { yieldedToolResults.add(toolCallId); const result = typeof msg.content === 'string' ? msg.content : JSON.stringify(msg.content); + + // Retrieve stored args from the original tool call + const storedToolInfo = toolCallArgsMap.get(toolCallId); + yield { type: 'tool_result', toolCall: { id: toolCallId, - name: msg.name || 'tool', - args: {}, + name: storedToolInfo?.name || msg.name || 'tool', + args: storedToolInfo?.args || {}, result: result, status: 'completed', }, diff --git a/gitnexus-web/src/core/llm/chat-session-service.ts b/gitnexus-web/src/core/llm/chat-session-service.ts new file mode 100644 index 0000000000..66787e4df4 --- /dev/null +++ b/gitnexus-web/src/core/llm/chat-session-service.ts @@ -0,0 +1,178 @@ +/** + * Chat Session Service + * + * Manages conversation history sessions with server-side persistence. + * Provides save, load, delete, and list operations for chat sessions. + */ + +import type { ChatSession, ChatMessage } from './types'; +import { + fetchAllSessions, + fetchSession, + saveSessionToServer, + deleteSessionFromServer, + clearAllSessionsFromServer +} from '../../services/backend.js'; + +/** + * Generate a unique session ID + */ +function generateSessionId(): string { + return `session-${Date.now()}-${Math.random().toString(36).substring(2, 9)}`; +} + +/** + * Get all sessions from server + */ +export async function getAllSessions(): Promise { + try { + return await fetchAllSessions(); + } catch (error) { + console.error('Failed to load chat sessions:', error); + return []; + } +} + +/** + * Create a new session from current messages + */ +export async function createSession( + messages: ChatMessage[], + repoName?: string, + customName?: string +): Promise { + const now = Date.now(); + const session: ChatSession = { + id: generateSessionId(), + name: customName || generateSessionName(messages), + repoName, + createdAt: now, + updatedAt: now, + messages, + }; + + try { + return await saveSessionToServer(session); + } catch (error) { + console.error('Failed to create session:', error); + throw error; + } +} + +/** + * Generate a session name from messages + */ +function generateSessionName(messages: ChatMessage[]): string { + // Use first user message as session name, truncated + const firstUserMessage = messages.find(m => m.role === 'user'); + if (firstUserMessage) { + const content = firstUserMessage.content.trim(); + return content.length > 50 ? content.substring(0, 47) + '...' : content; + } + return `Session ${new Date().toLocaleString()}`; +} + +/** + * Update an existing session + */ +export async function updateSession(sessionId: string, messages: ChatMessage[]): Promise { + try { + const sessions = await getAllSessions(); + const existing = sessions.find(s => s.id === sessionId); + + if (!existing) { + // Session not found, create new one + return await createSession(messages); + } + + const updated: ChatSession = { + ...existing, + messages, + updatedAt: Date.now(), + }; + + return await saveSessionToServer(updated); + } catch (error) { + console.error('Failed to update session:', error); + return null; + } +} + +/** + * Save or update a session (upsert) + */ +export async function saveSession( + sessionId: string | null, + messages: ChatMessage[], + repoName?: string +): Promise { + if (sessionId) { + const updated = await updateSession(sessionId, messages); + if (updated) return updated; + } + return await createSession(messages, repoName); +} + +/** + * Load a specific session by ID + */ +export async function loadSession(sessionId: string): Promise { + try { + return await fetchSession(sessionId); + } catch (error) { + console.error('Failed to load session:', error); + return null; + } +} + +/** + * Delete a session + */ +export async function deleteSession(sessionId: string): Promise { + try { + await deleteSessionFromServer(sessionId); + return true; + } catch (error) { + console.error('Failed to delete session:', error); + return false; + } +} + +/** + * Clear all sessions + */ +export async function clearAllSessions(): Promise { + try { + await clearAllSessionsFromServer(); + } catch (error) { + console.error('Failed to clear sessions:', error); + throw error; + } +} + +/** + * Get sessions for a specific repository + */ +export async function getSessionsByRepo(repoName: string): Promise { + const sessions = await getAllSessions(); + return sessions.filter(s => s.repoName === repoName); +} + +/** + * Format timestamp for display + */ +export function formatSessionDate(timestamp: number): string { + const now = Date.now(); + const diff = now - timestamp; + + const minutes = Math.floor(diff / 60000); + const hours = Math.floor(diff / 3600000); + const days = Math.floor(diff / 86400000); + + if (minutes < 1) return 'Just now'; + if (minutes < 60) return `${minutes}m ago`; + if (hours < 24) return `${hours}h ago`; + if (days < 7) return `${days}d ago`; + + return new Date(timestamp).toLocaleDateString(); +} diff --git a/gitnexus-web/src/core/llm/types.ts b/gitnexus-web/src/core/llm/types.ts index 21de11b13c..12f074d943 100644 --- a/gitnexus-web/src/core/llm/types.ts +++ b/gitnexus-web/src/core/llm/types.ts @@ -220,6 +220,18 @@ export interface AgentStep { timestamp: number; } +/** + * Chat conversation session for history management + */ +export interface ChatSession { + id: string; + name: string; + repoName?: string; // Associated repository name + createdAt: number; + updatedAt: number; + messages: ChatMessage[]; +} + /** * Graph schema information for LLM context */ diff --git a/gitnexus-web/src/hooks/useAppState.tsx b/gitnexus-web/src/hooks/useAppState.tsx index 79d5f43eb9..a18a93f8f3 100644 --- a/gitnexus-web/src/hooks/useAppState.tsx +++ b/gitnexus-web/src/hooks/useAppState.tsx @@ -7,13 +7,24 @@ 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 type { LLMSettings, ProviderConfig, AgentStreamChunk, ChatMessage, ToolCallInfo, MessageStep, ChatSession } from '../core/llm/types'; import { loadSettings, getActiveProviderConfig, saveSettings } from '../core/llm/settings-service'; import type { AgentMessage } from '../core/llm/agent'; +import { saveSession, loadSession, getAllSessions, deleteSession, getSessionsByRepo } from '../core/llm/chat-session-service'; import { DEFAULT_VISIBLE_EDGES, type EdgeType } from '../lib/constants'; import type { RepoSummary, ConnectToServerResult } from '../services/server-connection'; import { fetchRepos, connectToServer } from '../services/server-connection'; +/** + * Helper function to get sessions filtered by repo if available + */ +async function getFilteredSessions(repoName: string | null): Promise { + if (repoName) { + return await getSessionsByRepo(repoName); + } + return await getAllSessions(); +} + export type ViewMode = 'onboarding' | 'loading' | 'exploring'; export type RightPanelTab = 'code' | 'chat'; export type EmbeddingStatus = 'idle' | 'loading' | 'embedding' | 'indexing' | 'ready' | 'error'; @@ -75,6 +86,12 @@ interface AppState { openCodePanel: () => void; openChatPanel: () => void; + // Panel widths (resizable) + leftPanelWidth: number; + setLeftPanelWidth: (width: number | ((prev: number) => number)) => void; + rightPanelWidth: number; + setRightPanelWidth: (width: number | ((prev: number) => number)) => void; + // Filters visibleLabels: NodeLabel[]; toggleLabelVisibility: (label: NodeLabel) => void; @@ -154,6 +171,8 @@ interface AppState { chatMessages: ChatMessage[]; isChatLoading: boolean; currentToolCalls: ToolCallInfo[]; + currentSessionId: string | null; + chatSessions: ChatSession[]; // LLM methods refreshLLMSettings: () => void; @@ -161,6 +180,10 @@ interface AppState { sendChatMessage: (message: string) => Promise; stopChatResponse: () => void; clearChat: () => void; + saveCurrentSession: () => void; + loadSession: (sessionId: string) => void; + deleteSession: (sessionId: string) => void; + refreshChatSessions: () => void; // Code References Panel codeReferences: CodeReference[]; @@ -190,6 +213,10 @@ export const AppStateProvider = ({ children }: { children: ReactNode }) => { const [isRightPanelOpen, setRightPanelOpen] = useState(false); const [rightPanelTab, setRightPanelTab] = useState('code'); + // Panel widths (resizable) + const [leftPanelWidth, setLeftPanelWidth] = useState(280); // Default 280px + const [rightPanelWidth, setRightPanelWidth] = useState(480); // Default 480px (40% of 1200px) + const openCodePanel = useCallback(() => { // Legacy API: used by graph/tree selection. // Code is now shown in the Code References Panel (left of the graph), @@ -301,6 +328,8 @@ export const AppStateProvider = ({ children }: { children: ReactNode }) => { const [chatMessages, setChatMessages] = useState([]); const [isChatLoading, setIsChatLoading] = useState(false); const [currentToolCalls, setCurrentToolCalls] = useState([]); + const [currentSessionId, setCurrentSessionId] = useState(null); + const [chatSessions, setChatSessions] = useState([]); // Code References Panel state const [codeReferences, setCodeReferences] = useState([]); @@ -883,6 +912,7 @@ export const AppStateProvider = ({ children }: { children: ReactNode }) => { if (idx >= 0) { toolCallsForMessage[idx] = { ...toolCallsForMessage[idx], + args: tc.args || toolCallsForMessage[idx].args, // Preserve args from result result: tc.result, status: 'completed' }; @@ -900,6 +930,7 @@ export const AppStateProvider = ({ children }: { children: ReactNode }) => { ...stepsForMessage[stepIdx], toolCall: { ...stepsForMessage[stepIdx].toolCall!, + args: tc.args || stepsForMessage[stepIdx].toolCall!.args, // Preserve args from result result: tc.result, status: 'completed', }, @@ -917,7 +948,7 @@ export const AppStateProvider = ({ children }: { children: ReactNode }) => { } if (targetIdx >= 0) { return prev.map((t, i) => i === targetIdx - ? { ...t, result: tc.result, status: 'completed' } + ? { ...t, args: tc.args || t.args, result: tc.result, status: 'completed' } : t ); } @@ -995,6 +1026,19 @@ export const AppStateProvider = ({ children }: { children: ReactNode }) => { case 'done': // Finalize the assistant message - just call updateMessage one more time updateMessage(); + // Auto-save session when task completes + // Use setTimeout to ensure the final message update is processed first + setTimeout(() => { + setChatMessages(prev => { + if (prev.length > 0) { + saveSession(currentSessionId, prev, currentRepoName || undefined).then(session => { + setCurrentSessionId(session.id); + getFilteredSessions(currentRepoName).then(setChatSessions); + }).catch(err => console.error('Failed to save session:', err)); + } + return prev; + }); + }, 100); break; } }); @@ -1007,7 +1051,7 @@ export const AppStateProvider = ({ children }: { children: ReactNode }) => { setIsChatLoading(false); setCurrentToolCalls([]); } - }, [chatMessages, isAgentReady, initializeAgent, resolveFilePath, findFileNodeId, addCodeReference, clearAICodeReferences, clearAIToolHighlights, graph, embeddingStatus]); + }, [chatMessages, isAgentReady, initializeAgent, resolveFilePath, findFileNodeId, addCodeReference, clearAICodeReferences, clearAIToolHighlights, graph, embeddingStatus, currentSessionId, currentRepoName]); const stopChatResponse = useCallback(() => { const api = apiRef.current; @@ -1019,15 +1063,83 @@ export const AppStateProvider = ({ children }: { children: ReactNode }) => { }, [isChatLoading]); const clearChat = useCallback(() => { + // Save current session before clearing if there are messages + if (chatMessages.length > 0) { + saveCurrentSession(); + } setChatMessages([]); setCurrentToolCalls([]); setAgentError(null); - }, []); + setCurrentSessionId(null); + }, [chatMessages]); + + const saveCurrentSession = useCallback(async () => { + if (chatMessages.length === 0) return; + + try { + const session = await saveSession(currentSessionId, chatMessages, currentRepoName || undefined); + setCurrentSessionId(session.id); + const sessions = await getFilteredSessions(currentRepoName); + setChatSessions(sessions); + } catch (err) { + console.error('Failed to save session:', err); + } + }, [chatMessages, currentSessionId, currentRepoName]); + + const loadChatSession = useCallback(async (sessionId: string) => { + try { + const session = await loadSession(sessionId); + if (session) { + // Save current session first if it has messages + if (chatMessages.length > 0 && !currentSessionId) { + await saveCurrentSession(); + } + setChatMessages(session.messages); + setCurrentSessionId(sessionId); + setRightPanelTab('chat'); + setRightPanelOpen(true); + } + } catch (err) { + console.error('Failed to load session:', err); + } + }, [chatMessages, currentSessionId, saveCurrentSession]); + + const deleteChatSession = useCallback(async (sessionId: string) => { + try { + await deleteSession(sessionId); + const sessions = await getFilteredSessions(currentRepoName); + setChatSessions(sessions); + if (currentSessionId === sessionId) { + setCurrentSessionId(null); + } + } catch (err) { + console.error('Failed to delete session:', err); + } + }, [currentSessionId, currentRepoName]); + + const refreshChatSessions = useCallback(async () => { + try { + const sessions = await getFilteredSessions(currentRepoName); + setChatSessions(sessions); + } catch (err) { + console.error('Failed to refresh sessions:', err); + } + }, [currentRepoName]); + + // Load chat sessions from localStorage on mount + useEffect(() => { + refreshChatSessions(); + }, [refreshChatSessions]); // Switch to a different repo on the connected server const switchRepo = useCallback(async (repoName: string) => { if (!serverBaseUrl) return; + // Save current session before switching if there are messages + if (chatMessages.length > 0) { + saveCurrentSession(); + } + setProgress({ phase: 'extracting', percent: 0, message: 'Switching repository...', detail: `Loading ${repoName}` }); setViewMode('loading'); @@ -1072,6 +1184,11 @@ export const AppStateProvider = ({ children }: { children: ReactNode }) => { setViewMode('exploring'); + // Clear chat and session when switching repos + setChatMessages([]); + setCurrentSessionId(null); + setCurrentToolCalls([]); + if (getActiveProviderConfig()) initializeAgent(pName, repoName); startEmbeddings().catch((err) => { @@ -1159,6 +1276,10 @@ export const AppStateProvider = ({ children }: { children: ReactNode }) => { setRightPanelTab, openCodePanel, openChatPanel, + leftPanelWidth, + setLeftPanelWidth, + rightPanelWidth, + setRightPanelWidth, visibleLabels, toggleLabelVisibility, visibleEdgeTypes, @@ -1218,12 +1339,18 @@ export const AppStateProvider = ({ children }: { children: ReactNode }) => { chatMessages, isChatLoading, currentToolCalls, + currentSessionId, + chatSessions, // LLM methods refreshLLMSettings, initializeAgent, sendChatMessage, stopChatResponse, clearChat, + saveCurrentSession, + loadSession: loadChatSession, + deleteSession: deleteChatSession, + refreshChatSessions, // Code References Panel codeReferences, isCodePanelOpen, diff --git a/gitnexus-web/src/services/backend.ts b/gitnexus-web/src/services/backend.ts index 54eaa38fbf..5e04b53132 100644 --- a/gitnexus-web/src/services/backend.ts +++ b/gitnexus-web/src/services/backend.ts @@ -228,3 +228,65 @@ export const fetchClusterDetail = async ( await assertOk(response); return response.json(); }; + +// ── Chat Session Management ──────────────────────────────────────────────── + +export interface ChatSession { + id: string; + name: string; + repoName?: string; + createdAt: number; + updatedAt: number; + messages: any[]; +} + +/** + * Fetch all chat sessions from the server. + */ +export const fetchAllSessions = async (): Promise => { + const response = await fetchWithTimeout(`${backendUrl}/api/sessions`); + await assertOk(response); + return response.json() as Promise; +}; + +/** + * Fetch a specific session by ID. + */ +export const fetchSession = async (sessionId: string): Promise => { + const response = await fetchWithTimeout(`${backendUrl}/api/sessions/${encodeURIComponent(sessionId)}`); + await assertOk(response); + return response.json() as Promise; +}; + +/** + * Save or update a session on the server. + */ +export const saveSessionToServer = async (session: ChatSession): Promise => { + const response = await fetchWithTimeout(`${backendUrl}/api/sessions`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(session), + }); + await assertOk(response); + return response.json() as Promise; +}; + +/** + * Delete a session from the server. + */ +export const deleteSessionFromServer = async (sessionId: string): Promise => { + const response = await fetchWithTimeout(`${backendUrl}/api/sessions/${encodeURIComponent(sessionId)}`, { + method: 'DELETE', + }); + await assertOk(response); +}; + +/** + * Clear all sessions from the server. + */ +export const clearAllSessionsFromServer = async (): Promise => { + const response = await fetchWithTimeout(`${backendUrl}/api/sessions`, { + method: 'DELETE', + }); + await assertOk(response); +}; diff --git a/gitnexus/src/server/api.ts b/gitnexus/src/server/api.ts index d587eff9ad..172b314a2b 100644 --- a/gitnexus/src/server/api.ts +++ b/gitnexus/src/server/api.ts @@ -12,6 +12,7 @@ import express from 'express'; import cors from 'cors'; import path from 'path'; import fs from 'fs/promises'; +import os from 'os'; import { loadMeta, listRegisteredRepos } from '../storage/repo-manager.js'; import { executeQuery, closeKuzu, withKuzuDb } from '../core/kuzu/kuzu-adapter.js'; import { NODE_TABLES } from '../core/kuzu/schema.js'; @@ -337,6 +338,148 @@ export const createServer = async (port: number, host: string = '127.0.0.1') => } }); + // ── Chat Session Management ────────────────────────────────────────────── + + // Helper: get sessions directory path + const getSessionsDir = () => { + const homeDir = os.homedir(); + return path.join(homeDir, '.gitnexus', 'sessions'); + }; + + // Helper: get sessions file path for a specific repo + const getSessionsFilePath = (repoName?: string) => { + const sessionsDir = getSessionsDir(); + // If no repo specified, use 'global' as default + const safeRepoName = repoName || 'global'; + // Sanitize repo name to be filesystem-safe + const safeFileName = safeRepoName.replace(/[^a-zA-Z0-9_-]/g, '_'); + return path.join(sessionsDir, `${safeFileName}.json`); + }; + + // Helper: ensure sessions directory exists + const ensureSessionsDir = async () => { + await fs.mkdir(getSessionsDir(), { recursive: true }); + }; + + // Helper: read sessions from a specific repo file + const readSessions = async (repoName?: string) => { + try { + const filePath = getSessionsFilePath(repoName); + const data = await fs.readFile(filePath, 'utf-8'); + const sessions = JSON.parse(data); + return Array.isArray(sessions) ? sessions : []; + } catch (err: any) { + if (err.code === 'ENOENT') return []; + throw err; + } + }; + + // Helper: write sessions to a specific repo file + const writeSessions = async (sessions: any[], repoName?: string) => { + await ensureSessionsDir(); + const filePath = getSessionsFilePath(repoName); + await fs.writeFile(filePath, JSON.stringify(sessions, null, 2), 'utf-8'); + }; + + // Helper: get repo name from request query or body + const getRepoFromRequest = (req: express.Request): string | undefined => { + const fromQuery = typeof req.query.repo === 'string' ? req.query.repo : undefined; + if (fromQuery) return fromQuery; + if (req.body && typeof req.body === 'object' && typeof req.body.repoName === 'string') { + return req.body.repoName; + } + if (req.body && typeof req.body === 'object' && typeof req.body.repo === 'string') { + return req.body.repo; + } + return undefined; + }; + + // Get all chat sessions (optionally filtered by repo) + app.get('/api/sessions', async (req, res) => { + try { + const repoName = getRepoFromRequest(req); + const sessions = await readSessions(repoName); + res.json(sessions); + } catch (err: any) { + res.status(500).json({ error: err.message || 'Failed to load sessions' }); + } + }); + + // Get a specific session by ID (optionally from a specific repo) + app.get('/api/sessions/:id', async (req, res) => { + try { + const repoName = getRepoFromRequest(req); + const sessions = await readSessions(repoName); + const session = sessions.find((s: any) => s.id === req.params.id); + if (!session) { + res.status(404).json({ error: 'Session not found' }); + return; + } + res.json(session); + } catch (err: any) { + res.status(500).json({ error: err.message || 'Failed to load session' }); + } + }); + + // Create or update a session + app.post('/api/sessions', async (req, res) => { + try { + const session = req.body; + if (!session || !session.id) { + res.status(400).json({ error: 'Invalid session data' }); + return; + } + + const repoName = session.repoName; + const sessions = await readSessions(repoName); + const index = sessions.findIndex((s: any) => s.id === session.id); + + if (index >= 0) { + // Update existing session + sessions[index] = { ...session, updatedAt: Date.now() }; + } else { + // Add new session + sessions.push({ ...session, updatedAt: Date.now() }); + } + + // No limit - store all sessions per repo + await writeSessions(sessions, repoName); + res.json(session); + } catch (err: any) { + res.status(500).json({ error: err.message || 'Failed to save session' }); + } + }); + + // Delete a session + app.delete('/api/sessions/:id', async (req, res) => { + try { + const repoName = getRepoFromRequest(req); + const sessions = await readSessions(repoName); + const filtered = sessions.filter((s: any) => s.id !== req.params.id); + + if (filtered.length === sessions.length) { + res.status(404).json({ error: 'Session not found' }); + return; + } + + await writeSessions(filtered, repoName); + res.json({ success: true }); + } catch (err: any) { + res.status(500).json({ error: err.message || 'Failed to delete session' }); + } + }); + + // Clear all sessions (optionally for a specific repo) + app.delete('/api/sessions', async (req, res) => { + try { + const repoName = getRepoFromRequest(req); + await writeSessions([], repoName); + res.json({ success: true }); + } catch (err: any) { + res.status(500).json({ error: err.message || 'Failed to clear sessions' }); + } + }); + // Global error handler — catch anything the route handlers miss app.use((err: any, _req: express.Request, res: express.Response, _next: express.NextFunction) => { console.error('Unhandled error:', err); From fff5289bce2b7feb14291c0a7e650d77c04a63d9 Mon Sep 17 00:00:00 2001 From: Rocky0102 Date: Wed, 4 Mar 2026 11:30:42 +0800 Subject: [PATCH 3/3] 1. grep tool: support maxResults as number or string 2. session history: save separately by repo with individual files per session 3. rename Clear button to New to better reflect starting a new session --- .../src/components/ChatSessionList.tsx | 7 + gitnexus-web/src/components/RightPanel.tsx | 4 +- .../src/core/llm/chat-session-service.ts | 53 +++-- gitnexus-web/src/core/llm/tools.ts | 2 +- gitnexus-web/src/core/llm/types.ts | 2 + gitnexus-web/src/hooks/useAppState.tsx | 127 ++++++++---- gitnexus-web/src/services/backend.ts | 10 +- gitnexus/src/server/api.ts | 188 ++++++++++++++---- 8 files changed, 294 insertions(+), 99 deletions(-) diff --git a/gitnexus-web/src/components/ChatSessionList.tsx b/gitnexus-web/src/components/ChatSessionList.tsx index c3c0199fb0..c08e92f100 100644 --- a/gitnexus-web/src/components/ChatSessionList.tsx +++ b/gitnexus-web/src/components/ChatSessionList.tsx @@ -87,6 +87,13 @@ export const ChatSessionList = ({ onSessionSelect }: ChatSessionListProps) => { {formatSessionDate(session.updatedAt)}
+ {session.modelProvider && session.modelName && ( +
+ + {session.modelProvider}/{session.modelName} + +
+ )}
{/* Delete Button */} diff --git a/gitnexus-web/src/components/RightPanel.tsx b/gitnexus-web/src/components/RightPanel.tsx index ac50fc41fd..e0ec7d5d2d 100644 --- a/gitnexus-web/src/components/RightPanel.tsx +++ b/gitnexus-web/src/components/RightPanel.tsx @@ -451,9 +451,9 @@ export const RightPanel = ({ width }: RightPanelProps) => { {isChatLoading ? (