diff --git a/gitnexus-web/src/App.tsx b/gitnexus-web/src/App.tsx index 2de2a4a34a..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'; @@ -37,9 +38,15 @@ const AppContent = () => { isCodePanelOpen, serverBaseUrl, setServerBaseUrl, + currentRepoName, + setCurrentRepoName, availableRepos, setAvailableRepos, switchRepo, + leftPanelWidth, + setLeftPanelWidth, + rightPanelWidth, + setRightPanelWidth, } = useAppState(); const graphCanvasRef = useRef(null); @@ -132,11 +139,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 +179,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); @@ -232,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(() => { @@ -273,7 +290,15 @@ const AppContent = () => {
{/* Left Panel - File Tree */} - + + + {/* Left Divider */} + {/* Graph area - takes remaining space */}
@@ -287,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..c08e92f100 --- /dev/null +++ b/gitnexus-web/src/components/ChatSessionList.tsx @@ -0,0 +1,112 @@ +/** + * 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)} + +
+ {session.modelProvider && session.modelName && ( +
+ + {session.modelProvider}/{session.modelName} + +
+ )} +
+ + {/* 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..e0ec7d5d2d 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' && (
@@ -408,9 +451,9 @@ export const RightPanel = () => { {isChatLoading ? (