Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
45 changes: 40 additions & 5 deletions gitnexus-web/src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -37,9 +38,15 @@ const AppContent = () => {
isCodePanelOpen,
serverBaseUrl,
setServerBaseUrl,
currentRepoName,
setCurrentRepoName,
availableRepos,
setAvailableRepos,
switchRepo,
leftPanelWidth,
setLeftPanelWidth,
rightPanelWidth,
setRightPanelWidth,
} = useAppState();

const graphCanvasRef = useRef<GraphCanvasHandle>(null);
Expand Down Expand Up @@ -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();
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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(() => {
Expand Down Expand Up @@ -273,7 +290,15 @@ const AppContent = () => {

<main className="flex-1 flex min-h-0">
{/* Left Panel - File Tree */}
<FileTreePanel onFocusNode={handleFocusNode} />
<FileTreePanel onFocusNode={handleFocusNode} width={leftPanelWidth} />

{/* Left Divider */}
<ResizableDivider
onResize={handleLeftPanelResize}
minWidth={200}
maxWidth={600}
side="left"
/>

{/* Graph area - takes remaining space */}
<div className="flex-1 relative min-w-0">
Expand All @@ -287,8 +312,18 @@ const AppContent = () => {
)}
</div>

{/* Right Divider */}
{isRightPanelOpen && (
<ResizableDivider
onResize={handleRightPanelResize}
minWidth={400}
maxWidth={800}
side="right"
/>
)}

{/* Right Panel - Code & Chat (tabbed) */}
{isRightPanelOpen && <RightPanel />}
{isRightPanelOpen && <RightPanel width={rightPanelWidth} />}
</main>

<StatusBar />
Expand Down
112 changes: 112 additions & 0 deletions gitnexus-web/src/components/ChatSessionList.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<div className="flex flex-col items-center justify-center py-8 text-center">
<MessageSquare className="w-10 h-10 text-text-muted mb-3 opacity-50" />
<p className="text-sm text-text-secondary">No saved sessions yet</p>
<p className="text-xs text-text-muted mt-1">
Sessions are auto-saved when tasks complete
</p>
</div>
);
}

return (
<div className="flex flex-col h-full overflow-hidden">
{/* Header */}
<div className="flex items-center justify-between px-4 py-2 bg-surface border-b border-border-subtle">
<h3 className="text-sm font-medium text-text-primary">Chat History</h3>
<span className="text-xs text-text-muted">{chatSessions.length} sessions</span>
</div>

{/* Session List */}
<div className="flex-1 overflow-y-auto scrollbar-thin">
{sortedSessions.map((session) => (
<div
key={session.id}
onClick={() => 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 */}
<div className={`mt-0.5 p-1.5 rounded-md ${
currentSessionId === session.id
? 'bg-accent/20 text-accent'
: 'bg-surface text-text-muted group-hover:text-text-primary'
}`}>
<MessageSquare className="w-4 h-4" />
</div>

{/* Content */}
<div className="flex-1 min-w-0">
<h4 className="text-sm font-medium text-text-primary truncate">
{session.name}
</h4>
<div className="flex items-center gap-2 mt-1">
<Clock className="w-3 h-3 text-text-muted" />
<span className="text-xs text-text-muted">
{formatSessionDate(session.updatedAt)}
</span>
</div>
{session.modelProvider && session.modelName && (
<div className="mt-1">
<span className="text-xs text-text-muted bg-surface px-1.5 py-0.5 rounded">
{session.modelProvider}/{session.modelName}
</span>
</div>
)}
</div>

{/* Delete Button */}
<button
onClick={(e) => handleDeleteSession(e, session.id)}
className="opacity-0 group-hover:opacity-100 p-1.5 text-text-muted hover:text-rose-400 hover:bg-rose-500/10 rounded transition-all"
title="Delete session"
>
<Trash2 className="w-4 h-4" />
</button>
</div>
))}
</div>
</div>
);
};
8 changes: 6 additions & 2 deletions gitnexus-web/src/components/FileTreePanel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down Expand Up @@ -297,7 +298,10 @@ export const FileTreePanel = ({ onFocusNode }: FileTreePanelProps) => {
}

return (
<div className="h-full w-64 bg-surface border-r border-border-subtle flex flex-col animate-slide-in">
<div
className="h-full bg-surface border-r border-border-subtle flex flex-col animate-slide-in"
style={{ width: `${width}px` }}
>
{/* Header */}
<div className="flex items-center justify-between px-3 py-2 border-b border-border-subtle">
<div className="flex items-center gap-1">
Expand Down
76 changes: 76 additions & 0 deletions gitnexus-web/src/components/ResizableDivider.tsx
Original file line number Diff line number Diff line change
@@ -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<number>(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 (
<div
className={`
relative flex items-center justify-center
w-1 bg-border-subtle hover:bg-accent/50
cursor-col-resize transition-colors
${isDragging ? 'bg-accent' : ''}
group
`}
onMouseDown={handleMouseDown}
>
{/* Wider hit area for easier grabbing */}
<div className="absolute inset-y-0 -left-1 -right-1 z-10" />

{/* Visual grip indicator */}
<div className={`
absolute inset-y-0 flex items-center justify-center
opacity-0 group-hover:opacity-100 transition-opacity
${isDragging ? 'opacity-100' : ''}
`}>
<GripVertical className="w-3 h-3 text-accent" />
</div>
</div>
);
};
Loading