first commit

This commit is contained in:
caraction
2025-06-25 14:29:07 +00:00
commit 5ea0e36207
60 changed files with 14674 additions and 0 deletions

491
src/App.jsx Normal file
View File

@@ -0,0 +1,491 @@
/*
* App.jsx - Main Application Component with Session Protection System
*
* SESSION PROTECTION SYSTEM OVERVIEW:
* ===================================
*
* Problem: Automatic project updates from WebSocket would refresh the sidebar and clear chat messages
* during active conversations, creating a poor user experience.
*
* Solution: Track "active sessions" and pause project updates during conversations.
*
* How it works:
* 1. When user sends message → session marked as "active"
* 2. Project updates are skipped while session is active
* 3. When conversation completes/aborts → session marked as "inactive"
* 4. Project updates resume normally
*
* Handles both existing sessions (with real IDs) and new sessions (with temporary IDs).
*/
import React, { useState, useEffect } from 'react';
import { BrowserRouter as Router, Routes, Route, useNavigate, useParams } from 'react-router-dom';
import Sidebar from './components/Sidebar';
import MainContent from './components/MainContent';
import MobileNav from './components/MobileNav';
import ToolsSettings from './components/ToolsSettings';
import { useWebSocket } from './utils/websocket';
// Main App component with routing
function AppContent() {
const navigate = useNavigate();
const { sessionId } = useParams();
const [projects, setProjects] = useState([]);
const [selectedProject, setSelectedProject] = useState(null);
const [selectedSession, setSelectedSession] = useState(null);
const [activeTab, setActiveTab] = useState('chat'); // 'chat' or 'files'
const [isMobile, setIsMobile] = useState(false);
const [sidebarOpen, setSidebarOpen] = useState(false);
const [isLoadingProjects, setIsLoadingProjects] = useState(true);
const [isInputFocused, setIsInputFocused] = useState(false);
const [showToolsSettings, setShowToolsSettings] = useState(false);
// Session Protection System: Track sessions with active conversations to prevent
// automatic project updates from interrupting ongoing chats. When a user sends
// a message, the session is marked as "active" and project updates are paused
// until the conversation completes or is aborted.
const [activeSessions, setActiveSessions] = useState(new Set()); // Track sessions with active conversations
const { ws, sendMessage, messages } = useWebSocket();
useEffect(() => {
const checkMobile = () => {
setIsMobile(window.innerWidth < 768);
};
checkMobile();
window.addEventListener('resize', checkMobile);
return () => window.removeEventListener('resize', checkMobile);
}, []);
useEffect(() => {
// Fetch projects on component mount
fetchProjects();
}, []);
// Helper function to determine if an update is purely additive (new sessions/projects)
// vs modifying existing selected items that would interfere with active conversations
const isUpdateAdditive = (currentProjects, updatedProjects, selectedProject, selectedSession) => {
if (!selectedProject || !selectedSession) {
// No active session to protect, allow all updates
return true;
}
// Find the selected project in both current and updated data
const currentSelectedProject = currentProjects?.find(p => p.name === selectedProject.name);
const updatedSelectedProject = updatedProjects?.find(p => p.name === selectedProject.name);
if (!currentSelectedProject || !updatedSelectedProject) {
// Project structure changed significantly, not purely additive
return false;
}
// Find the selected session in both current and updated project data
const currentSelectedSession = currentSelectedProject.sessions?.find(s => s.id === selectedSession.id);
const updatedSelectedSession = updatedSelectedProject.sessions?.find(s => s.id === selectedSession.id);
if (!currentSelectedSession || !updatedSelectedSession) {
// Selected session was deleted or significantly changed, not purely additive
return false;
}
// Check if the selected session's content has changed (modification vs addition)
// Compare key fields that would affect the loaded chat interface
const sessionUnchanged =
currentSelectedSession.id === updatedSelectedSession.id &&
currentSelectedSession.title === updatedSelectedSession.title &&
currentSelectedSession.created_at === updatedSelectedSession.created_at &&
currentSelectedSession.updated_at === updatedSelectedSession.updated_at;
// This is considered additive if the selected session is unchanged
// (new sessions may have been added elsewhere, but active session is protected)
return sessionUnchanged;
};
// Handle WebSocket messages for real-time project updates
useEffect(() => {
if (messages.length > 0) {
const latestMessage = messages[messages.length - 1];
if (latestMessage.type === 'projects_updated') {
// Session Protection Logic: Allow additions but prevent changes during active conversations
// This allows new sessions/projects to appear in sidebar while protecting active chat messages
// We check for two types of active sessions:
// 1. Existing sessions: selectedSession.id exists in activeSessions
// 2. New sessions: temporary "new-session-*" identifiers in activeSessions (before real session ID is received)
const hasActiveSession = (selectedSession && activeSessions.has(selectedSession.id)) ||
(activeSessions.size > 0 && Array.from(activeSessions).some(id => id.startsWith('new-session-')));
if (hasActiveSession) {
// Allow updates but be selective: permit additions, prevent changes to existing items
const updatedProjects = latestMessage.projects;
const currentProjects = projects;
// Check if this is purely additive (new sessions/projects) vs modification of existing ones
const isAdditiveUpdate = isUpdateAdditive(currentProjects, updatedProjects, selectedProject, selectedSession);
if (!isAdditiveUpdate) {
// Skip updates that would modify existing selected session/project
return;
}
// Continue with additive updates below
}
// Update projects state with the new data from WebSocket
const updatedProjects = latestMessage.projects;
setProjects(updatedProjects);
// Update selected project if it exists in the updated projects
if (selectedProject) {
const updatedSelectedProject = updatedProjects.find(p => p.name === selectedProject.name);
if (updatedSelectedProject) {
setSelectedProject(updatedSelectedProject);
// Update selected session only if it was deleted - avoid unnecessary reloads
if (selectedSession) {
const updatedSelectedSession = updatedSelectedProject.sessions?.find(s => s.id === selectedSession.id);
if (!updatedSelectedSession) {
// Session was deleted
setSelectedSession(null);
}
// Don't update if session still exists with same ID - prevents reload
}
}
}
}
}
}, [messages, selectedProject, selectedSession, activeSessions]);
const fetchProjects = async () => {
try {
setIsLoadingProjects(true);
const response = await fetch('/api/projects');
const data = await response.json();
// Optimize to preserve object references when data hasn't changed
setProjects(prevProjects => {
// If no previous projects, just set the new data
if (prevProjects.length === 0) {
return data;
}
// Check if the projects data has actually changed
const hasChanges = data.some((newProject, index) => {
const prevProject = prevProjects[index];
if (!prevProject) return true;
// Compare key properties that would affect UI
return (
newProject.name !== prevProject.name ||
newProject.displayName !== prevProject.displayName ||
newProject.fullPath !== prevProject.fullPath ||
JSON.stringify(newProject.sessionMeta) !== JSON.stringify(prevProject.sessionMeta) ||
JSON.stringify(newProject.sessions) !== JSON.stringify(prevProject.sessions)
);
}) || data.length !== prevProjects.length;
// Only update if there are actual changes
return hasChanges ? data : prevProjects;
});
// Don't auto-select any project - user should choose manually
} catch (error) {
console.error('Error fetching projects:', error);
} finally {
setIsLoadingProjects(false);
}
};
// Expose fetchProjects globally for component access
window.refreshProjects = fetchProjects;
// Handle URL-based session loading
useEffect(() => {
if (sessionId && projects.length > 0) {
// Find the session across all projects
for (const project of projects) {
const session = project.sessions?.find(s => s.id === sessionId);
if (session) {
setSelectedProject(project);
setSelectedSession(session);
setActiveTab('chat');
return;
}
}
// If session not found, it might be a newly created session
// Just navigate to it and it will be found when the sidebar refreshes
// Don't redirect to home, let the session load naturally
}
}, [sessionId, projects, navigate]);
const handleProjectSelect = (project) => {
setSelectedProject(project);
setSelectedSession(null);
navigate('/');
if (isMobile) {
setSidebarOpen(false);
}
};
const handleSessionSelect = (session) => {
setSelectedSession(session);
setActiveTab('chat');
if (isMobile) {
setSidebarOpen(false);
}
navigate(`/session/${session.id}`);
};
const handleNewSession = (project) => {
setSelectedProject(project);
setSelectedSession(null);
setActiveTab('chat');
navigate('/');
if (isMobile) {
setSidebarOpen(false);
}
};
const handleSessionDelete = (sessionId) => {
// If the deleted session was currently selected, clear it
if (selectedSession?.id === sessionId) {
setSelectedSession(null);
navigate('/');
}
// Update projects state locally instead of full refresh
setProjects(prevProjects =>
prevProjects.map(project => ({
...project,
sessions: project.sessions?.filter(session => session.id !== sessionId) || [],
sessionMeta: {
...project.sessionMeta,
total: Math.max(0, (project.sessionMeta?.total || 0) - 1)
}
}))
);
};
const handleSidebarRefresh = async () => {
// Refresh only the sessions for all projects, don't change selected state
try {
const response = await fetch('/api/projects');
const freshProjects = await response.json();
// Optimize to preserve object references and minimize re-renders
setProjects(prevProjects => {
// Check if projects data has actually changed
const hasChanges = freshProjects.some((newProject, index) => {
const prevProject = prevProjects[index];
if (!prevProject) return true;
return (
newProject.name !== prevProject.name ||
newProject.displayName !== prevProject.displayName ||
newProject.fullPath !== prevProject.fullPath ||
JSON.stringify(newProject.sessionMeta) !== JSON.stringify(prevProject.sessionMeta) ||
JSON.stringify(newProject.sessions) !== JSON.stringify(prevProject.sessions)
);
}) || freshProjects.length !== prevProjects.length;
return hasChanges ? freshProjects : prevProjects;
});
// If we have a selected project, make sure it's still selected after refresh
if (selectedProject) {
const refreshedProject = freshProjects.find(p => p.name === selectedProject.name);
if (refreshedProject) {
// Only update selected project if it actually changed
if (JSON.stringify(refreshedProject) !== JSON.stringify(selectedProject)) {
setSelectedProject(refreshedProject);
}
// If we have a selected session, try to find it in the refreshed project
if (selectedSession) {
const refreshedSession = refreshedProject.sessions?.find(s => s.id === selectedSession.id);
if (refreshedSession && JSON.stringify(refreshedSession) !== JSON.stringify(selectedSession)) {
setSelectedSession(refreshedSession);
}
}
}
}
} catch (error) {
console.error('Error refreshing sidebar:', error);
}
};
const handleProjectDelete = (projectName) => {
// If the deleted project was currently selected, clear it
if (selectedProject?.name === projectName) {
setSelectedProject(null);
setSelectedSession(null);
navigate('/');
}
// Update projects state locally instead of full refresh
setProjects(prevProjects =>
prevProjects.filter(project => project.name !== projectName)
);
};
// Session Protection Functions: Manage the lifecycle of active sessions
// markSessionAsActive: Called when user sends a message to mark session as protected
// This includes both real session IDs and temporary "new-session-*" identifiers
const markSessionAsActive = (sessionId) => {
if (sessionId) {
setActiveSessions(prev => new Set([...prev, sessionId]));
}
};
// markSessionAsInactive: Called when conversation completes/aborts to re-enable project updates
const markSessionAsInactive = (sessionId) => {
if (sessionId) {
setActiveSessions(prev => {
const newSet = new Set(prev);
newSet.delete(sessionId);
return newSet;
});
}
};
// replaceTemporarySession: Called when WebSocket provides real session ID for new sessions
// Removes temporary "new-session-*" identifiers and adds the real session ID
// This maintains protection continuity during the transition from temporary to real session
const replaceTemporarySession = (realSessionId) => {
if (realSessionId) {
setActiveSessions(prev => {
const newSet = new Set();
// Keep all non-temporary sessions and add the real session ID
for (const sessionId of prev) {
if (!sessionId.startsWith('new-session-')) {
newSet.add(sessionId);
}
}
newSet.add(realSessionId);
return newSet;
});
}
};
return (
<div className="fixed inset-0 flex bg-background">
{/* Fixed Desktop Sidebar */}
{!isMobile && (
<div className="w-80 flex-shrink-0 border-r border-border bg-card">
<div className="h-full overflow-hidden">
<Sidebar
projects={projects}
selectedProject={selectedProject}
selectedSession={selectedSession}
onProjectSelect={handleProjectSelect}
onSessionSelect={handleSessionSelect}
onNewSession={handleNewSession}
onSessionDelete={handleSessionDelete}
onProjectDelete={handleProjectDelete}
isLoading={isLoadingProjects}
onRefresh={handleSidebarRefresh}
onShowSettings={() => setShowToolsSettings(true)}
/>
</div>
</div>
)}
{/* Mobile Sidebar Overlay */}
{isMobile && (
<div className={`fixed inset-0 z-50 flex transition-all duration-150 ease-out ${
sidebarOpen ? 'opacity-100 visible' : 'opacity-0 invisible'
}`}>
<div
className="fixed inset-0 bg-background/80 backdrop-blur-sm transition-opacity duration-150 ease-out"
onClick={(e) => {
e.stopPropagation();
setSidebarOpen(false);
}}
onTouchStart={(e) => {
e.preventDefault();
e.stopPropagation();
setSidebarOpen(false);
}}
/>
<div
className={`relative w-[85vw] max-w-sm sm:w-80 bg-card border-r border-border h-full transform transition-transform duration-150 ease-out ${
sidebarOpen ? 'translate-x-0' : '-translate-x-full'
}`}
onClick={(e) => e.stopPropagation()}
onTouchStart={(e) => e.stopPropagation()}
>
<Sidebar
projects={projects}
selectedProject={selectedProject}
selectedSession={selectedSession}
onProjectSelect={handleProjectSelect}
onSessionSelect={handleSessionSelect}
onNewSession={handleNewSession}
onSessionDelete={handleSessionDelete}
onProjectDelete={handleProjectDelete}
isLoading={isLoadingProjects}
onRefresh={handleSidebarRefresh}
onShowSettings={() => setShowToolsSettings(true)}
/>
</div>
</div>
)}
{/* Main Content Area - Flexible */}
<div className="flex-1 flex flex-col min-w-0">
<MainContent
selectedProject={selectedProject}
selectedSession={selectedSession}
activeTab={activeTab}
setActiveTab={setActiveTab}
ws={ws}
sendMessage={sendMessage}
messages={messages}
isMobile={isMobile}
onMenuClick={() => setSidebarOpen(true)}
isLoading={isLoadingProjects}
onInputFocusChange={setIsInputFocused}
onSessionActive={markSessionAsActive}
onSessionInactive={markSessionAsInactive}
onReplaceTemporarySession={replaceTemporarySession}
onNavigateToSession={(sessionId) => navigate(`/session/${sessionId}`)}
/>
</div>
{/* Mobile Bottom Navigation */}
{isMobile && (
<MobileNav
activeTab={activeTab}
setActiveTab={setActiveTab}
isInputFocused={isInputFocused}
/>
)}
{/* Tools Settings Modal */}
<ToolsSettings
isOpen={showToolsSettings}
onClose={() => setShowToolsSettings(false)}
/>
</div>
);
}
// Root App component with router
function App() {
return (
<Router>
<Routes>
<Route path="/" element={<AppContent />} />
<Route path="/session/:sessionId" element={<AppContent />} />
</Routes>
</Router>
);
}
export default App;

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,428 @@
import React, { useState, useEffect, useRef } from 'react';
import CodeMirror from '@uiw/react-codemirror';
import { javascript } from '@codemirror/lang-javascript';
import { python } from '@codemirror/lang-python';
import { html } from '@codemirror/lang-html';
import { css } from '@codemirror/lang-css';
import { json } from '@codemirror/lang-json';
import { markdown } from '@codemirror/lang-markdown';
import { oneDark } from '@codemirror/theme-one-dark';
import { EditorView, Decoration } from '@codemirror/view';
import { StateField, StateEffect, RangeSetBuilder } from '@codemirror/state';
import { X, Save, Download, Maximize2, Minimize2, Eye, EyeOff } from 'lucide-react';
function CodeEditor({ file, onClose, projectPath }) {
const [content, setContent] = useState('');
const [loading, setLoading] = useState(true);
const [saving, setSaving] = useState(false);
const [isFullscreen, setIsFullscreen] = useState(false);
const [isDarkMode, setIsDarkMode] = useState(true);
const [saveSuccess, setSaveSuccess] = useState(false);
const [showDiff, setShowDiff] = useState(!!file.diffInfo);
// Create diff highlighting
const diffEffect = StateEffect.define();
const diffField = StateField.define({
create() {
return Decoration.none;
},
update(decorations, tr) {
decorations = decorations.map(tr.changes);
for (let effect of tr.effects) {
if (effect.is(diffEffect)) {
decorations = effect.value;
}
}
return decorations;
},
provide: f => EditorView.decorations.from(f)
});
const createDiffDecorations = (content, diffInfo) => {
if (!diffInfo || !showDiff) return Decoration.none;
const builder = new RangeSetBuilder();
const lines = content.split('\n');
const oldLines = diffInfo.old_string.split('\n');
// Find the line where the old content starts
let startLineIndex = -1;
for (let i = 0; i <= lines.length - oldLines.length; i++) {
let matches = true;
for (let j = 0; j < oldLines.length; j++) {
if (lines[i + j] !== oldLines[j]) {
matches = false;
break;
}
}
if (matches) {
startLineIndex = i;
break;
}
}
if (startLineIndex >= 0) {
let pos = 0;
// Calculate position to start of old content
for (let i = 0; i < startLineIndex; i++) {
pos += lines[i].length + 1; // +1 for newline
}
// Highlight old lines (to be removed)
for (let i = 0; i < oldLines.length; i++) {
const lineStart = pos;
const lineEnd = pos + oldLines[i].length;
builder.add(lineStart, lineEnd, Decoration.line({
class: isDarkMode ? 'diff-removed-dark' : 'diff-removed-light'
}));
pos += oldLines[i].length + 1;
}
}
return builder.finish();
};
// Diff decoration theme
const diffTheme = EditorView.theme({
'.diff-removed-light': {
backgroundColor: '#fef2f2',
borderLeft: '3px solid #ef4444'
},
'.diff-removed-dark': {
backgroundColor: 'rgba(239, 68, 68, 0.1)',
borderLeft: '3px solid #ef4444'
},
'.diff-added-light': {
backgroundColor: '#f0fdf4',
borderLeft: '3px solid #22c55e'
},
'.diff-added-dark': {
backgroundColor: 'rgba(34, 197, 94, 0.1)',
borderLeft: '3px solid #22c55e'
}
});
// Get language extension based on file extension
const getLanguageExtension = (filename) => {
const ext = filename.split('.').pop()?.toLowerCase();
switch (ext) {
case 'js':
case 'jsx':
case 'ts':
case 'tsx':
return [javascript({ jsx: true, typescript: ext.includes('ts') })];
case 'py':
return [python()];
case 'html':
case 'htm':
return [html()];
case 'css':
case 'scss':
case 'less':
return [css()];
case 'json':
return [json()];
case 'md':
case 'markdown':
return [markdown()];
default:
return [];
}
};
// Load file content
useEffect(() => {
const loadFileContent = async () => {
try {
setLoading(true);
const response = await fetch(`/api/projects/${file.projectName}/file?filePath=${encodeURIComponent(file.path)}`);
if (!response.ok) {
throw new Error(`Failed to load file: ${response.status} ${response.statusText}`);
}
const data = await response.json();
setContent(data.content);
} catch (error) {
console.error('Error loading file:', error);
setContent(`// Error loading file: ${error.message}\n// File: ${file.name}\n// Path: ${file.path}`);
} finally {
setLoading(false);
}
};
loadFileContent();
}, [file, projectPath]);
// Update diff decorations when content or diff info changes
const editorRef = useRef(null);
useEffect(() => {
if (editorRef.current && content && file.diffInfo && showDiff) {
const decorations = createDiffDecorations(content, file.diffInfo);
const view = editorRef.current.view;
if (view) {
view.dispatch({
effects: diffEffect.of(decorations)
});
}
}
}, [content, file.diffInfo, showDiff, isDarkMode]);
const handleSave = async () => {
setSaving(true);
try {
const response = await fetch(`/api/projects/${file.projectName}/file`, {
method: 'PUT',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
filePath: file.path,
content: content
})
});
if (!response.ok) {
const errorData = await response.json();
throw new Error(errorData.error || `Save failed: ${response.status}`);
}
const result = await response.json();
// Show success feedback
setSaveSuccess(true);
setTimeout(() => setSaveSuccess(false), 2000); // Hide after 2 seconds
} catch (error) {
console.error('Error saving file:', error);
alert(`Error saving file: ${error.message}`);
} finally {
setSaving(false);
}
};
const handleDownload = () => {
const blob = new Blob([content], { type: 'text/plain' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = file.name;
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
URL.revokeObjectURL(url);
};
const toggleFullscreen = () => {
setIsFullscreen(!isFullscreen);
};
// Handle keyboard shortcuts
useEffect(() => {
const handleKeyDown = (e) => {
if (e.ctrlKey || e.metaKey) {
if (e.key === 's') {
e.preventDefault();
handleSave();
} else if (e.key === 'Escape') {
e.preventDefault();
onClose();
}
}
};
document.addEventListener('keydown', handleKeyDown);
return () => document.removeEventListener('keydown', handleKeyDown);
}, [content]);
if (loading) {
return (
<>
<style>
{`
.code-editor-loading {
background-color: ${isDarkMode ? '#111827' : '#ffffff'} !important;
}
.code-editor-loading:hover {
background-color: ${isDarkMode ? '#111827' : '#ffffff'} !important;
}
`}
</style>
<div className="fixed inset-0 z-50 md:bg-black/50 md:flex md:items-center md:justify-center">
<div className="code-editor-loading w-full h-full md:rounded-lg md:w-auto md:h-auto p-8 flex items-center justify-center">
<div className="flex items-center gap-3">
<div className="animate-spin rounded-full h-6 w-6 border-b-2 border-blue-600"></div>
<span className="text-gray-900 dark:text-white">Loading {file.name}...</span>
</div>
</div>
</div>
</>
);
}
return (
<>
<style>
{`
.code-editor-modal {
background-color: ${isDarkMode ? '#111827' : '#ffffff'} !important;
}
.code-editor-modal:hover {
background-color: ${isDarkMode ? '#111827' : '#ffffff'} !important;
}
`}
</style>
<div className={`fixed inset-0 z-50 ${
// Mobile: native fullscreen, Desktop: modal with backdrop
'md:bg-black/50 md:flex md:items-center md:justify-center md:p-4'
} ${isFullscreen ? 'md:p-0' : ''}`}>
<div className={`code-editor-modal shadow-2xl flex flex-col ${
// Mobile: always fullscreen, Desktop: modal sizing
'w-full h-full md:rounded-lg md:shadow-2xl' +
(isFullscreen ? ' md:w-full md:h-full md:rounded-none' : ' md:w-full md:max-w-6xl md:h-[80vh] md:max-h-[80vh]')
}`}>
{/* Header */}
<div className="flex items-center justify-between p-4 border-b border-gray-200 dark:border-gray-700 flex-shrink-0 min-w-0">
<div className="flex items-center gap-3 min-w-0 flex-1">
<div className="w-8 h-8 bg-blue-600 rounded flex items-center justify-center flex-shrink-0">
<span className="text-white text-sm font-mono">
{file.name.split('.').pop()?.toUpperCase() || 'FILE'}
</span>
</div>
<div className="min-w-0 flex-1">
<div className="flex items-center gap-2 min-w-0">
<h3 className="font-medium text-gray-900 dark:text-white truncate">{file.name}</h3>
{file.diffInfo && (
<span className="text-xs bg-blue-100 dark:bg-blue-900/30 text-blue-600 dark:text-blue-400 px-2 py-1 rounded whitespace-nowrap">
📝 Has changes
</span>
)}
</div>
<p className="text-sm text-gray-500 dark:text-gray-400 truncate">{file.path}</p>
</div>
</div>
<div className="flex items-center gap-1 md:gap-2 flex-shrink-0">
{file.diffInfo && (
<button
onClick={() => setShowDiff(!showDiff)}
className="p-2 md:p-2 text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-white rounded-md hover:bg-gray-100 dark:hover:bg-gray-800 min-w-[44px] min-h-[44px] md:min-w-0 md:min-h-0 flex items-center justify-center"
title={showDiff ? "Hide diff highlighting" : "Show diff highlighting"}
>
{showDiff ? <EyeOff className="w-5 h-5 md:w-4 md:h-4" /> : <Eye className="w-5 h-5 md:w-4 md:h-4" />}
</button>
)}
<button
onClick={() => setIsDarkMode(!isDarkMode)}
className="p-2 md:p-2 text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-white rounded-md hover:bg-gray-100 dark:hover:bg-gray-800 min-w-[44px] min-h-[44px] md:min-w-0 md:min-h-0 flex items-center justify-center"
title="Toggle theme"
>
<span className="text-lg md:text-base">{isDarkMode ? '☀️' : '🌙'}</span>
</button>
<button
onClick={handleDownload}
className="p-2 md:p-2 text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-white rounded-md hover:bg-gray-100 dark:hover:bg-gray-800 min-w-[44px] min-h-[44px] md:min-w-0 md:min-h-0 flex items-center justify-center"
title="Download file"
>
<Download className="w-5 h-5 md:w-4 md:h-4" />
</button>
<button
onClick={handleSave}
disabled={saving}
className={`px-3 py-2 text-white rounded-md disabled:opacity-50 flex items-center gap-2 transition-colors min-h-[44px] md:min-h-0 ${
saveSuccess
? 'bg-green-600 hover:bg-green-700'
: 'bg-blue-600 hover:bg-blue-700'
}`}
>
{saveSuccess ? (
<>
<svg className="w-5 h-5 md:w-4 md:h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 13l4 4L19 7" />
</svg>
<span className="hidden sm:inline">Saved!</span>
</>
) : (
<>
<Save className="w-5 h-5 md:w-4 md:h-4" />
<span className="hidden sm:inline">{saving ? 'Saving...' : 'Save'}</span>
</>
)}
</button>
<button
onClick={toggleFullscreen}
className="hidden md:flex p-2 text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-white rounded-md hover:bg-gray-100 dark:hover:bg-gray-800 items-center justify-center"
title={isFullscreen ? 'Exit fullscreen' : 'Fullscreen'}
>
{isFullscreen ? <Minimize2 className="w-4 h-4" /> : <Maximize2 className="w-4 h-4" />}
</button>
<button
onClick={onClose}
className="p-2 md:p-2 text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-white rounded-md hover:bg-gray-100 dark:hover:bg-gray-800 min-w-[44px] min-h-[44px] md:min-w-0 md:min-h-0 flex items-center justify-center"
title="Close"
>
<X className="w-6 h-6 md:w-4 md:h-4" />
</button>
</div>
</div>
{/* Editor */}
<div className="flex-1 overflow-hidden">
<CodeMirror
ref={editorRef}
value={content}
onChange={setContent}
extensions={[
...getLanguageExtension(file.name),
diffField,
diffTheme
]}
theme={isDarkMode ? oneDark : undefined}
height="100%"
style={{
fontSize: '14px',
height: '100%',
}}
basicSetup={{
lineNumbers: true,
foldGutter: true,
dropCursor: false,
allowMultipleSelections: false,
indentOnInput: true,
bracketMatching: true,
closeBrackets: true,
autocompletion: true,
highlightSelectionMatches: true,
searchKeymap: true,
}}
/>
</div>
{/* Footer */}
<div className="flex items-center justify-between p-3 border-t border-gray-200 dark:border-gray-700 bg-gray-50 dark:bg-gray-800 flex-shrink-0">
<div className="flex items-center gap-4 text-sm text-gray-600 dark:text-gray-400">
<span>Lines: {content.split('\n').length}</span>
<span>Characters: {content.length}</span>
<span>Language: {file.name.split('.').pop()?.toUpperCase() || 'Text'}</span>
</div>
<div className="text-sm text-gray-500 dark:text-gray-400">
Press Ctrl+S to save Esc to close
</div>
</div>
</div>
</div>
</>
);
}
export default CodeEditor;

188
src/components/FileTree.jsx Normal file
View File

@@ -0,0 +1,188 @@
import React, { useState, useEffect } from 'react';
import { ScrollArea } from './ui/scroll-area';
import { Button } from './ui/button';
import { Folder, FolderOpen, File, FileText, FileCode } from 'lucide-react';
import { cn } from '../lib/utils';
import CodeEditor from './CodeEditor';
import ImageViewer from './ImageViewer';
function FileTree({ selectedProject }) {
const [files, setFiles] = useState([]);
const [loading, setLoading] = useState(false);
const [expandedDirs, setExpandedDirs] = useState(new Set());
const [selectedFile, setSelectedFile] = useState(null);
const [selectedImage, setSelectedImage] = useState(null);
useEffect(() => {
if (selectedProject) {
fetchFiles();
}
}, [selectedProject]);
const fetchFiles = async () => {
setLoading(true);
try {
const response = await fetch(`/api/projects/${selectedProject.name}/files`);
if (!response.ok) {
const errorText = await response.text();
console.error('❌ File fetch failed:', response.status, errorText);
setFiles([]);
return;
}
const data = await response.json();
setFiles(data);
} catch (error) {
console.error('❌ Error fetching files:', error);
setFiles([]);
} finally {
setLoading(false);
}
};
const toggleDirectory = (path) => {
const newExpanded = new Set(expandedDirs);
if (newExpanded.has(path)) {
newExpanded.delete(path);
} else {
newExpanded.add(path);
}
setExpandedDirs(newExpanded);
};
const renderFileTree = (items, level = 0) => {
return items.map((item) => (
<div key={item.path} className="select-none">
<Button
variant="ghost"
className={cn(
"w-full justify-start p-2 h-auto font-normal text-left hover:bg-accent",
)}
style={{ paddingLeft: `${level * 16 + 12}px` }}
onClick={() => {
if (item.type === 'directory') {
toggleDirectory(item.path);
} else if (isImageFile(item.name)) {
// Open image in viewer
setSelectedImage({
name: item.name,
path: item.path,
projectPath: selectedProject.path,
projectName: selectedProject.name
});
} else {
// Open file in editor
setSelectedFile({
name: item.name,
path: item.path,
projectPath: selectedProject.path,
projectName: selectedProject.name
});
}
}}
>
<div className="flex items-center gap-2 min-w-0 w-full">
{item.type === 'directory' ? (
expandedDirs.has(item.path) ? (
<FolderOpen className="w-4 h-4 text-blue-500 flex-shrink-0" />
) : (
<Folder className="w-4 h-4 text-muted-foreground flex-shrink-0" />
)
) : (
getFileIcon(item.name)
)}
<span className="text-sm truncate text-foreground">
{item.name}
</span>
</div>
</Button>
{item.type === 'directory' &&
expandedDirs.has(item.path) &&
item.children &&
item.children.length > 0 && (
<div>
{renderFileTree(item.children, level + 1)}
</div>
)}
</div>
));
};
const isImageFile = (filename) => {
const ext = filename.split('.').pop()?.toLowerCase();
const imageExtensions = ['png', 'jpg', 'jpeg', 'gif', 'svg', 'webp', 'ico', 'bmp'];
return imageExtensions.includes(ext);
};
const getFileIcon = (filename) => {
const ext = filename.split('.').pop()?.toLowerCase();
const codeExtensions = ['js', 'jsx', 'ts', 'tsx', 'py', 'java', 'cpp', 'c', 'php', 'rb', 'go', 'rs'];
const docExtensions = ['md', 'txt', 'doc', 'pdf'];
const imageExtensions = ['png', 'jpg', 'jpeg', 'gif', 'svg', 'webp', 'ico', 'bmp'];
if (codeExtensions.includes(ext)) {
return <FileCode className="w-4 h-4 text-green-500 flex-shrink-0" />;
} else if (docExtensions.includes(ext)) {
return <FileText className="w-4 h-4 text-blue-500 flex-shrink-0" />;
} else if (imageExtensions.includes(ext)) {
return <File className="w-4 h-4 text-purple-500 flex-shrink-0" />;
} else {
return <File className="w-4 h-4 text-muted-foreground flex-shrink-0" />;
}
};
if (loading) {
return (
<div className="h-full flex items-center justify-center">
<div className="text-gray-500 dark:text-gray-400">
Loading files...
</div>
</div>
);
}
return (
<div className="h-full flex flex-col bg-card">
<ScrollArea className="flex-1 p-4">
{files.length === 0 ? (
<div className="text-center py-8">
<div className="w-12 h-12 bg-muted rounded-lg flex items-center justify-center mx-auto mb-3">
<Folder className="w-6 h-6 text-muted-foreground" />
</div>
<h4 className="font-medium text-foreground mb-1">No files found</h4>
<p className="text-sm text-muted-foreground">
Check if the project path is accessible
</p>
</div>
) : (
<div className="space-y-1">
{renderFileTree(files)}
</div>
)}
</ScrollArea>
{/* Code Editor Modal */}
{selectedFile && (
<CodeEditor
file={selectedFile}
onClose={() => setSelectedFile(null)}
projectPath={selectedFile.projectPath}
/>
)}
{/* Image Viewer Modal */}
{selectedImage && (
<ImageViewer
file={selectedImage}
onClose={() => setSelectedImage(null)}
/>
)}
</div>
);
}
export default FileTree;

View File

@@ -0,0 +1,54 @@
import React from 'react';
import { Button } from './ui/button';
import { X } from 'lucide-react';
function ImageViewer({ file, onClose }) {
const imagePath = `/api/projects/${file.projectName}/files/content?path=${encodeURIComponent(file.path)}`;
return (
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50">
<div className="bg-white dark:bg-gray-800 rounded-lg shadow-xl max-w-4xl max-h-[90vh] w-full mx-4 overflow-hidden">
<div className="flex items-center justify-between p-4 border-b">
<h3 className="text-lg font-semibold text-gray-900 dark:text-white">
{file.name}
</h3>
<Button
variant="ghost"
size="sm"
onClick={onClose}
className="h-8 w-8 p-0"
>
<X className="h-4 w-4" />
</Button>
</div>
<div className="p-4 flex justify-center items-center bg-gray-50 dark:bg-gray-900 min-h-[400px]">
<img
src={imagePath}
alt={file.name}
className="max-w-full max-h-[70vh] object-contain rounded-lg shadow-md"
onError={(e) => {
e.target.style.display = 'none';
e.target.nextSibling.style.display = 'block';
}}
/>
<div
className="text-center text-gray-500 dark:text-gray-400"
style={{ display: 'none' }}
>
<p>Unable to load image</p>
<p className="text-sm mt-2">{file.path}</p>
</div>
</div>
<div className="p-4 border-t bg-gray-50 dark:bg-gray-800">
<p className="text-sm text-gray-600 dark:text-gray-400">
{file.path}
</p>
</div>
</div>
</div>
);
}
export default ImageViewer;

View File

@@ -0,0 +1,268 @@
/*
* MainContent.jsx - Main Content Area with Session Protection Props Passthrough
*
* SESSION PROTECTION PASSTHROUGH:
* ===============================
*
* This component serves as a passthrough layer for Session Protection functions:
* - Receives session management functions from App.jsx
* - Passes them down to ChatInterface.jsx
*
* No session protection logic is implemented here - it's purely a props bridge.
*/
import React, { useState } from 'react';
import ChatInterface from './ChatInterface';
import FileTree from './FileTree';
import CodeEditor from './CodeEditor';
import Shell from './Shell';
function MainContent({
selectedProject,
selectedSession,
activeTab,
setActiveTab,
ws,
sendMessage,
messages,
isMobile,
onMenuClick,
isLoading,
onInputFocusChange,
// Session Protection Props: Functions passed down from App.jsx to manage active session state
// These functions control when project updates are paused during active conversations
onSessionActive, // Mark session as active when user sends message
onSessionInactive, // Mark session as inactive when conversation completes/aborts
onReplaceTemporarySession, // Replace temporary session ID with real session ID from WebSocket
onNavigateToSession // Navigate to a specific session (for Claude CLI session duplication workaround)
}) {
const [editingFile, setEditingFile] = useState(null);
const handleFileOpen = (filePath, diffInfo = null) => {
// Create a file object that CodeEditor expects
const file = {
name: filePath.split('/').pop(),
path: filePath,
projectName: selectedProject?.name,
diffInfo: diffInfo // Pass along diff information if available
};
setEditingFile(file);
};
const handleCloseEditor = () => {
setEditingFile(null);
};
if (isLoading) {
return (
<div className="h-full flex flex-col">
{/* Header with menu button for mobile */}
{isMobile && (
<div className="bg-white dark:bg-gray-800 border-b border-gray-200 dark:border-gray-700 p-3 sm:p-4 flex-shrink-0">
<button
onClick={onMenuClick}
className="p-1.5 text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-white rounded-md hover:bg-gray-100 dark:hover:bg-gray-700"
>
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 6h16M4 12h16M4 18h16" />
</svg>
</button>
</div>
)}
<div className="flex-1 flex items-center justify-center">
<div className="text-center text-gray-500 dark:text-gray-400">
<div className="w-16 h-16 mx-auto mb-4">
<div className="w-16 h-16 animate-spin rounded-full border-4 border-gray-300 dark:border-gray-600 border-t-blue-600" />
</div>
<h2 className="text-xl font-semibold mb-2">Loading Claude Code UI</h2>
<p>Setting up your workspace...</p>
</div>
</div>
</div>
);
}
if (!selectedProject) {
return (
<div className="h-full flex flex-col">
{/* Header with menu button for mobile */}
{isMobile && (
<div className="bg-white dark:bg-gray-800 border-b border-gray-200 dark:border-gray-700 p-3 sm:p-4 flex-shrink-0">
<button
onClick={onMenuClick}
className="p-1.5 text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-white rounded-md hover:bg-gray-100 dark:hover:bg-gray-700"
>
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 6h16M4 12h16M4 18h16" />
</svg>
</button>
</div>
)}
<div className="flex-1 flex items-center justify-center">
<div className="text-center text-gray-500 dark:text-gray-400 max-w-md mx-auto px-6">
<div className="w-16 h-16 mx-auto mb-6 bg-gray-100 dark:bg-gray-800 rounded-full flex items-center justify-center">
<svg className="w-8 h-8 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M3 7v10a2 2 0 002 2h14a2 2 0 002-2V9a2 2 0 00-2-2h-5l-2-2H5a2 2 0 00-2 2z" />
</svg>
</div>
<h2 className="text-2xl font-semibold mb-3 text-gray-900 dark:text-white">Choose Your Project</h2>
<p className="text-gray-600 dark:text-gray-300 mb-6 leading-relaxed">
Select a project from the sidebar to start coding with Claude. Each project contains your chat sessions and file history.
</p>
<div className="bg-blue-50 dark:bg-blue-900/20 rounded-lg p-4 border border-blue-200 dark:border-blue-800">
<p className="text-sm text-blue-700 dark:text-blue-300">
💡 <strong>Tip:</strong> {isMobile ? 'Tap the menu button above to access projects' : 'Create a new project by clicking the folder icon in the sidebar'}
</p>
</div>
</div>
</div>
</div>
);
}
return (
<div className="h-full flex flex-col">
{/* Header with tabs */}
<div className="bg-white dark:bg-gray-800 border-b border-gray-200 dark:border-gray-700 p-3 sm:p-4 flex-shrink-0">
<div className="flex items-center justify-between">
<div className="flex items-center space-x-2 sm:space-x-3">
{isMobile && (
<button
onClick={onMenuClick}
onTouchStart={(e) => {
e.preventDefault();
onMenuClick();
}}
className="p-2.5 text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-white rounded-md hover:bg-gray-100 dark:hover:bg-gray-700 touch-manipulation active:scale-95 transition-transform duration-75"
>
<svg className="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 6h16M4 12h16M4 18h16" />
</svg>
</button>
)}
<div className="min-w-0">
{activeTab === 'chat' && selectedSession ? (
<div>
<h2 className="text-base sm:text-lg font-semibold text-gray-900 dark:text-white truncate">
{selectedSession.summary}
</h2>
<div className="text-xs text-gray-500 dark:text-gray-400 truncate">
{selectedProject.displayName} <span className="hidden sm:inline"> {selectedSession.id}</span>
</div>
</div>
) : activeTab === 'chat' && !selectedSession ? (
<div>
<h2 className="text-base sm:text-lg font-semibold text-gray-900 dark:text-white">
New Session
</h2>
<div className="text-xs text-gray-500 dark:text-gray-400 truncate">
{selectedProject.displayName}
</div>
</div>
) : (
<div>
<h2 className="text-base sm:text-lg font-semibold text-gray-900 dark:text-white">
Project Files
</h2>
<div className="text-xs text-gray-500 dark:text-gray-400 truncate">
{selectedProject.displayName}
</div>
</div>
)}
</div>
</div>
{/* Modern Tab Navigation - Right Side */}
<div className="flex-shrink-0 hidden sm:block">
<div className="relative flex bg-gray-100 dark:bg-gray-800 rounded-lg p-1">
<button
onClick={() => setActiveTab('chat')}
className={`relative px-2 sm:px-3 py-1.5 text-xs sm:text-sm font-medium rounded-md transition-all duration-200 ${
activeTab === 'chat'
? 'bg-white dark:bg-gray-700 text-gray-900 dark:text-white shadow-sm'
: 'text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-white hover:bg-gray-200 dark:hover:bg-gray-700'
}`}
>
<span className="flex items-center gap-1 sm:gap-1.5">
<svg className="w-3 sm:w-3.5 h-3 sm:h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M8 12h.01M12 12h.01M16 12h.01M21 12c0 4.418-4.03 8-9 8a9.863 9.863 0 01-4.255-.949L3 20l1.395-3.72C3.512 15.042 3 13.574 3 12c0-4.418 4.03-8 9-8s9 3.582 9 8z" />
</svg>
<span className="hidden sm:inline">Chat</span>
</span>
</button>
<button
onClick={() => setActiveTab('shell')}
className={`relative px-2 sm:px-3 py-1.5 text-xs sm:text-sm font-medium rounded-md transition-all duration-200 ${
activeTab === 'shell'
? 'bg-white dark:bg-gray-700 text-gray-900 dark:text-white shadow-sm'
: 'text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-white hover:bg-gray-200 dark:hover:bg-gray-700'
}`}
>
<span className="flex items-center gap-1 sm:gap-1.5">
<svg className="w-3 sm:w-3.5 h-3 sm:h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M8 9l3 3-3 3m5 0h3M5 20h14a2 2 0 002-2V6a2 2 0 00-2-2H5a2 2 0 00-2 2v14a2 2 0 002 2z" />
</svg>
<span className="hidden sm:inline">Shell</span>
</span>
</button>
<button
onClick={() => setActiveTab('files')}
className={`relative px-2 sm:px-3 py-1.5 text-xs sm:text-sm font-medium rounded-md transition-all duration-200 ${
activeTab === 'files'
? 'bg-white dark:bg-gray-700 text-gray-900 dark:text-white shadow-sm'
: 'text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-white hover:bg-gray-200 dark:hover:bg-gray-700'
}`}
>
<span className="flex items-center gap-1 sm:gap-1.5">
<svg className="w-3 sm:w-3.5 h-3 sm:h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M3 7v10a2 2 0 002 2h14a2 2 0 002-2V9a2 2 0 00-2-2h-5l-2-2H5a2 2 0 00-2 2z" />
</svg>
<span className="hidden sm:inline">Files</span>
</span>
</button>
</div>
</div>
</div>
</div>
{/* Content Area */}
<div className="flex-1 flex flex-col min-h-0 overflow-hidden">
<div className={`h-full ${activeTab === 'chat' ? 'block' : 'hidden'}`}>
<ChatInterface
selectedProject={selectedProject}
selectedSession={selectedSession}
ws={ws}
sendMessage={sendMessage}
messages={messages}
onFileOpen={handleFileOpen}
onInputFocusChange={onInputFocusChange}
onSessionActive={onSessionActive}
onSessionInactive={onSessionInactive}
onReplaceTemporarySession={onReplaceTemporarySession}
onNavigateToSession={onNavigateToSession}
/>
</div>
<div className={`h-full overflow-hidden ${activeTab === 'files' ? 'block' : 'hidden'}`}>
<FileTree selectedProject={selectedProject} />
</div>
<div className={`h-full overflow-hidden ${activeTab === 'shell' ? 'block' : 'hidden'}`}>
<Shell
selectedProject={selectedProject}
selectedSession={selectedSession}
isActive={activeTab === 'shell'}
/>
</div>
</div>
{/* Code Editor Modal */}
{editingFile && (
<CodeEditor
file={editingFile}
onClose={handleCloseEditor}
projectPath={selectedProject?.path}
/>
)}
</div>
);
}
export default React.memo(MainContent);

View File

@@ -0,0 +1,75 @@
import React from 'react';
import { MessageSquare, Folder, Terminal } from 'lucide-react';
function MobileNav({ activeTab, setActiveTab, isInputFocused }) {
// Detect dark mode
const isDarkMode = document.documentElement.classList.contains('dark');
const navItems = [
{
id: 'chat',
icon: MessageSquare,
onClick: () => setActiveTab('chat')
},
{
id: 'shell',
icon: Terminal,
onClick: () => setActiveTab('shell')
},
{
id: 'files',
icon: Folder,
onClick: () => setActiveTab('files')
}
];
return (
<>
<style>
{`
.mobile-nav-container {
background-color: ${isDarkMode ? '#1f2937' : '#ffffff'} !important;
}
.mobile-nav-container:hover {
background-color: ${isDarkMode ? '#1f2937' : '#ffffff'} !important;
}
`}
</style>
<div
className={`mobile-nav-container fixed bottom-0 left-0 right-0 border-t border-gray-200 dark:border-gray-700 z-50 ios-bottom-safe transform transition-transform duration-300 ease-in-out shadow-lg ${
isInputFocused ? 'translate-y-full' : 'translate-y-0'
}`}
>
<div className="flex items-center justify-around py-1">
{navItems.map((item) => {
const Icon = item.icon;
const isActive = activeTab === item.id;
return (
<button
key={item.id}
onClick={item.onClick}
onTouchStart={(e) => {
e.preventDefault();
item.onClick();
}}
className={`flex items-center justify-center p-2 rounded-lg transition-colors duration-75 min-h-[40px] min-w-[40px] relative touch-manipulation ${
isActive
? 'text-blue-600 dark:text-blue-400'
: 'text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-white'
}`}
aria-label={item.id}
>
<Icon className="w-5 h-5" />
{isActive && (
<div className="absolute top-0 left-1/2 transform -translate-x-1/2 w-6 h-0.5 bg-blue-600 dark:bg-blue-400 rounded-full" />
)}
</button>
);
})}
</div>
</div>
</>
);
}
export default MobileNav;

537
src/components/Shell.jsx Normal file
View File

@@ -0,0 +1,537 @@
import React, { useEffect, useRef, useState } from 'react';
import { Terminal } from 'xterm';
import { FitAddon } from 'xterm-addon-fit';
import { ClipboardAddon } from '@xterm/addon-clipboard';
import { WebglAddon } from '@xterm/addon-webgl';
import 'xterm/css/xterm.css';
// Global store for shell sessions to persist across tab switches
const shellSessions = new Map();
function Shell({ selectedProject, selectedSession, isActive }) {
const terminalRef = useRef(null);
const terminal = useRef(null);
const fitAddon = useRef(null);
const ws = useRef(null);
const [isConnected, setIsConnected] = useState(false);
const [isInitialized, setIsInitialized] = useState(false);
const [isRestarting, setIsRestarting] = useState(false);
const [lastSessionId, setLastSessionId] = useState(null);
const [isConnecting, setIsConnecting] = useState(false);
// Connect to shell function
const connectToShell = () => {
if (!isInitialized || isConnected || isConnecting) return;
setIsConnecting(true);
// Start the WebSocket connection
connectWebSocket();
};
// Disconnect from shell function
const disconnectFromShell = () => {
if (ws.current) {
ws.current.close();
ws.current = null;
}
// Clear terminal content completely
if (terminal.current) {
terminal.current.clear();
terminal.current.write('\x1b[2J\x1b[H'); // Clear screen and move cursor to home
}
setIsConnected(false);
setIsConnecting(false);
};
// Restart shell function
const restartShell = () => {
setIsRestarting(true);
// Clear ALL session storage for this project to force fresh start
const sessionKeys = Array.from(shellSessions.keys()).filter(key =>
key.includes(selectedProject.name)
);
sessionKeys.forEach(key => shellSessions.delete(key));
// Close existing WebSocket
if (ws.current) {
ws.current.close();
ws.current = null;
}
// Clear and dispose existing terminal
if (terminal.current) {
// Dispose terminal immediately without writing text
terminal.current.dispose();
terminal.current = null;
fitAddon.current = null;
}
// Reset states
setIsConnected(false);
setIsInitialized(false);
// Force re-initialization after cleanup
setTimeout(() => {
setIsRestarting(false);
}, 200);
};
// Watch for session changes and restart shell
useEffect(() => {
const currentSessionId = selectedSession?.id || null;
// Disconnect when session changes (user will need to manually reconnect)
if (lastSessionId !== null && lastSessionId !== currentSessionId && isInitialized) {
// Disconnect from current shell
disconnectFromShell();
// Clear stored sessions for this project
const allKeys = Array.from(shellSessions.keys());
allKeys.forEach(key => {
if (key.includes(selectedProject.name)) {
shellSessions.delete(key);
}
});
}
setLastSessionId(currentSessionId);
}, [selectedSession?.id, isInitialized]);
// Initialize terminal when component mounts
useEffect(() => {
if (!terminalRef.current || !selectedProject || isRestarting) {
return;
}
// Create session key for this project/session combination
const sessionKey = selectedSession?.id || `project-${selectedProject.name}`;
// Check if we have an existing session
const existingSession = shellSessions.get(sessionKey);
if (existingSession && !terminal.current) {
try {
// Reuse existing terminal
terminal.current = existingSession.terminal;
fitAddon.current = existingSession.fitAddon;
ws.current = existingSession.ws;
setIsConnected(existingSession.isConnected);
// Reattach to DOM - dispose existing element first if needed
if (terminal.current.element && terminal.current.element.parentNode) {
terminal.current.element.parentNode.removeChild(terminal.current.element);
}
terminal.current.open(terminalRef.current);
setTimeout(() => {
if (fitAddon.current) {
fitAddon.current.fit();
}
}, 100);
setIsInitialized(true);
return;
} catch (error) {
// Clear the broken session and continue to create a new one
shellSessions.delete(sessionKey);
terminal.current = null;
fitAddon.current = null;
ws.current = null;
}
}
if (terminal.current) {
return;
}
// Initialize new terminal
terminal.current = new Terminal({
cursorBlink: true,
fontSize: 14,
fontFamily: 'Menlo, Monaco, "Courier New", monospace',
allowProposedApi: true, // Required for clipboard addon
allowTransparency: false,
convertEol: true,
scrollback: 10000,
tabStopWidth: 4,
// Enable full color support
windowsMode: false,
macOptionIsMeta: true,
macOptionClickForcesSelection: false,
// Enhanced theme with full 16-color ANSI support + true colors
theme: {
// Basic colors
background: '#1e1e1e',
foreground: '#d4d4d4',
cursor: '#ffffff',
cursorAccent: '#1e1e1e',
selection: '#264f78',
selectionForeground: '#ffffff',
// Standard ANSI colors (0-7)
black: '#000000',
red: '#cd3131',
green: '#0dbc79',
yellow: '#e5e510',
blue: '#2472c8',
magenta: '#bc3fbc',
cyan: '#11a8cd',
white: '#e5e5e5',
// Bright ANSI colors (8-15)
brightBlack: '#666666',
brightRed: '#f14c4c',
brightGreen: '#23d18b',
brightYellow: '#f5f543',
brightBlue: '#3b8eea',
brightMagenta: '#d670d6',
brightCyan: '#29b8db',
brightWhite: '#ffffff',
// Extended colors for better Claude output
extendedAnsi: [
// 16-color palette extension for 256-color support
'#000000', '#800000', '#008000', '#808000',
'#000080', '#800080', '#008080', '#c0c0c0',
'#808080', '#ff0000', '#00ff00', '#ffff00',
'#0000ff', '#ff00ff', '#00ffff', '#ffffff'
]
}
});
fitAddon.current = new FitAddon();
const clipboardAddon = new ClipboardAddon();
const webglAddon = new WebglAddon();
terminal.current.loadAddon(fitAddon.current);
terminal.current.loadAddon(clipboardAddon);
try {
terminal.current.loadAddon(webglAddon);
} catch (error) {
}
terminal.current.open(terminalRef.current);
// Add keyboard shortcuts for copy/paste
terminal.current.attachCustomKeyEventHandler((event) => {
// Ctrl+C or Cmd+C for copy (when text is selected)
if ((event.ctrlKey || event.metaKey) && event.key === 'c' && terminal.current.hasSelection()) {
document.execCommand('copy');
return false;
}
// Ctrl+V or Cmd+V for paste
if ((event.ctrlKey || event.metaKey) && event.key === 'v') {
navigator.clipboard.readText().then(text => {
if (ws.current && ws.current.readyState === WebSocket.OPEN) {
ws.current.send(JSON.stringify({
type: 'input',
data: text
}));
}
}).catch(err => {
// Failed to read clipboard
});
return false;
}
return true;
});
// Ensure terminal takes full space
setTimeout(() => {
if (fitAddon.current) {
fitAddon.current.fit();
}
}, 100);
setIsInitialized(true);
// Handle terminal input
terminal.current.onData((data) => {
if (ws.current && ws.current.readyState === WebSocket.OPEN) {
ws.current.send(JSON.stringify({
type: 'input',
data: data
}));
}
});
// Add resize observer to handle container size changes
const resizeObserver = new ResizeObserver(() => {
if (fitAddon.current && terminal.current) {
setTimeout(() => {
fitAddon.current.fit();
}, 50);
}
});
if (terminalRef.current) {
resizeObserver.observe(terminalRef.current);
}
return () => {
resizeObserver.disconnect();
// Store session for reuse instead of disposing
if (terminal.current && selectedProject) {
const sessionKey = selectedSession?.id || `project-${selectedProject.name}`;
try {
shellSessions.set(sessionKey, {
terminal: terminal.current,
fitAddon: fitAddon.current,
ws: ws.current,
isConnected: isConnected
});
} catch (error) {
}
}
};
}, [terminalRef.current, selectedProject, selectedSession, isRestarting]);
// Fit terminal when tab becomes active
useEffect(() => {
if (!isActive || !isInitialized) return;
// Fit terminal when tab becomes active
setTimeout(() => {
if (fitAddon.current) {
fitAddon.current.fit();
}
}, 100);
}, [isActive, isInitialized]);
// WebSocket connection function (called manually)
const connectWebSocket = async () => {
if (isConnecting || isConnected) return;
try {
// Fetch server configuration to get the correct WebSocket URL
let wsBaseUrl;
try {
const configResponse = await fetch('/api/config');
const config = await configResponse.json();
wsBaseUrl = config.wsUrl;
// If the config returns localhost but we're not on localhost, use current host but with API server port
if (wsBaseUrl.includes('localhost') && !window.location.hostname.includes('localhost')) {
const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
// For development, API server is typically on port 3002 when Vite is on 3001
const apiPort = window.location.port === '3001' ? '3002' : window.location.port;
wsBaseUrl = `${protocol}//${window.location.hostname}:${apiPort}`;
}
} catch (error) {
const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
// For development, API server is typically on port 3002 when Vite is on 3001
const apiPort = window.location.port === '3001' ? '3002' : window.location.port;
wsBaseUrl = `${protocol}//${window.location.hostname}:${apiPort}`;
}
const wsUrl = `${wsBaseUrl}/shell`;
ws.current = new WebSocket(wsUrl);
ws.current.onopen = () => {
setIsConnected(true);
setIsConnecting(false);
// Send initial setup with project path and session info
const initPayload = {
type: 'init',
projectPath: selectedProject.fullPath || selectedProject.path,
sessionId: selectedSession?.id,
hasSession: !!selectedSession
};
ws.current.send(JSON.stringify(initPayload));
};
ws.current.onmessage = (event) => {
try {
const data = JSON.parse(event.data);
if (data.type === 'output') {
// Check for URLs in the output and make them clickable
const urlRegex = /(https?:\/\/[^\s\x1b\x07]+)/g;
let output = data.data;
// Find URLs in the text (excluding ANSI escape sequences)
const urls = [];
let match;
while ((match = urlRegex.exec(output.replace(/\x1b\[[0-9;]*m/g, ''))) !== null) {
urls.push(match[1]);
}
// If URLs found, log them for potential opening
terminal.current.write(output);
} else if (data.type === 'url_open') {
// Handle explicit URL opening requests from server
window.open(data.url, '_blank');
}
} catch (error) {
}
};
ws.current.onclose = (event) => {
setIsConnected(false);
setIsConnecting(false);
// Clear terminal content when connection closes
if (terminal.current) {
terminal.current.clear();
terminal.current.write('\x1b[2J\x1b[H'); // Clear screen and move cursor to home
}
// Don't auto-reconnect anymore - user must manually connect
};
ws.current.onerror = (error) => {
setIsConnected(false);
setIsConnecting(false);
};
} catch (error) {
setIsConnected(false);
setIsConnecting(false);
}
};
if (!selectedProject) {
return (
<div className="h-full flex items-center justify-center">
<div className="text-center text-gray-500 dark:text-gray-400">
<div className="w-16 h-16 mx-auto mb-4 bg-gray-100 dark:bg-gray-800 rounded-full flex items-center justify-center">
<svg className="w-8 h-8 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M8 9l3 3-3 3m5 0h3M5 20h14a2 2 0 002-2V6a2 2 0 00-2-2H5a2 2 0 00-2 2v14a2 2 0 002 2z" />
</svg>
</div>
<h3 className="text-lg font-semibold mb-2">Select a Project</h3>
<p>Choose a project to open an interactive shell in that directory</p>
</div>
</div>
);
}
return (
<div className="h-full flex flex-col bg-gray-900">
{/* Header */}
<div className="flex-shrink-0 bg-gray-800 border-b border-gray-700 px-4 py-2">
<div className="flex items-center justify-between">
<div className="flex items-center space-x-2">
<div className={`w-2 h-2 rounded-full ${isConnected ? 'bg-green-500' : 'bg-red-500'}`} />
{selectedSession && (
<span className="text-xs text-blue-300">
({selectedSession.summary.slice(0, 30)}...)
</span>
)}
{!selectedSession && (
<span className="text-xs text-gray-400">(New Session)</span>
)}
{!isInitialized && (
<span className="text-xs text-yellow-400">(Initializing...)</span>
)}
{isRestarting && (
<span className="text-xs text-blue-400">(Restarting...)</span>
)}
</div>
<div className="flex items-center space-x-3">
{isConnected && (
<button
onClick={disconnectFromShell}
className="px-3 py-1 text-xs bg-red-600 text-white rounded hover:bg-red-700 flex items-center space-x-1"
title="Disconnect from shell"
>
<svg className="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
</svg>
<span>Disconnect</span>
</button>
)}
<button
onClick={restartShell}
disabled={isRestarting || isConnected}
className="text-xs text-gray-400 hover:text-white disabled:opacity-50 disabled:cursor-not-allowed flex items-center space-x-1"
title="Restart Shell (disconnect first)"
>
<svg className="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15" />
</svg>
<span>Restart</span>
</button>
</div>
</div>
</div>
{/* Terminal */}
<div className="flex-1 p-2 overflow-hidden relative">
<div ref={terminalRef} className="h-full w-full" />
{/* Loading state */}
{!isInitialized && (
<div className="absolute inset-0 flex items-center justify-center bg-gray-900 bg-opacity-90">
<div className="text-white">Loading terminal...</div>
</div>
)}
{/* Connect button when not connected */}
{isInitialized && !isConnected && !isConnecting && (
<div className="absolute inset-0 flex items-center justify-center bg-gray-900 bg-opacity-90">
<div className="text-center">
<button
onClick={connectToShell}
className="px-6 py-3 bg-green-600 text-white rounded-lg hover:bg-green-700 transition-colors flex items-center space-x-2 text-base font-medium"
title="Connect to shell"
>
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M13 10V3L4 14h7v7l9-11h-7z" />
</svg>
<span>Continue in Shell</span>
</button>
<p className="text-gray-400 text-sm mt-3">
{selectedSession ?
`Resume session: ${selectedSession.summary.slice(0, 50)}...` :
'Start a new Claude session'
}
</p>
</div>
</div>
)}
{/* Connecting state */}
{isConnecting && (
<div className="absolute inset-0 flex items-center justify-center bg-gray-900 bg-opacity-90">
<div className="text-center">
<div className="flex items-center space-x-3 text-yellow-400">
<div className="w-6 h-6 animate-spin rounded-full border-2 border-yellow-400 border-t-transparent"></div>
<span className="text-base font-medium">Connecting to shell...</span>
</div>
<p className="text-gray-400 text-sm mt-3">
Starting Claude CLI in {selectedProject.displayName}
</p>
</div>
</div>
)}
</div>
</div>
);
}
export default Shell;

974
src/components/Sidebar.jsx Normal file
View File

@@ -0,0 +1,974 @@
import React, { useState, useEffect } from 'react';
import { ScrollArea } from './ui/scroll-area';
import { Button } from './ui/button';
import { Badge } from './ui/badge';
import { Input } from './ui/input';
import { FolderOpen, Folder, Plus, MessageSquare, Clock, ChevronDown, ChevronRight, Edit3, Check, X, Trash2, Settings, FolderPlus, RefreshCw } from 'lucide-react';
import { cn } from '../lib/utils';
// Move formatTimeAgo outside component to avoid recreation on every render
const formatTimeAgo = (dateString, currentTime) => {
const date = new Date(dateString);
const now = currentTime;
// Check if date is valid
if (isNaN(date.getTime())) {
return 'Unknown';
}
const diffInMs = now - date;
const diffInSeconds = Math.floor(diffInMs / 1000);
const diffInMinutes = Math.floor(diffInMs / (1000 * 60));
const diffInHours = Math.floor(diffInMs / (1000 * 60 * 60));
const diffInDays = Math.floor(diffInMs / (1000 * 60 * 60 * 24));
if (diffInSeconds < 60) return 'Just now';
if (diffInMinutes === 1) return '1 min ago';
if (diffInMinutes < 60) return `${diffInMinutes} mins ago`;
if (diffInHours === 1) return '1 hour ago';
if (diffInHours < 24) return `${diffInHours} hours ago`;
if (diffInDays === 1) return '1 day ago';
if (diffInDays < 7) return `${diffInDays} days ago`;
return date.toLocaleDateString();
};
function Sidebar({
projects,
selectedProject,
selectedSession,
onProjectSelect,
onSessionSelect,
onNewSession,
onSessionDelete,
onProjectDelete,
isLoading,
onRefresh,
onShowSettings
}) {
const [expandedProjects, setExpandedProjects] = useState(new Set());
const [editingProject, setEditingProject] = useState(null);
const [showNewProject, setShowNewProject] = useState(false);
const [editingName, setEditingName] = useState('');
const [newProjectPath, setNewProjectPath] = useState('');
const [creatingProject, setCreatingProject] = useState(false);
const [loadingSessions, setLoadingSessions] = useState({});
const [additionalSessions, setAdditionalSessions] = useState({});
const [initialSessionsLoaded, setInitialSessionsLoaded] = useState(new Set());
const [currentTime, setCurrentTime] = useState(new Date());
const [isRefreshing, setIsRefreshing] = useState(false);
// Touch handler to prevent double-tap issues on iPad
const handleTouchClick = (callback) => {
return (e) => {
e.preventDefault();
e.stopPropagation();
callback();
};
};
// Auto-update timestamps every minute
useEffect(() => {
const timer = setInterval(() => {
setCurrentTime(new Date());
}, 60000); // Update every 60 seconds
return () => clearInterval(timer);
}, []);
// Clear additional sessions when projects list changes (e.g., after refresh)
useEffect(() => {
setAdditionalSessions({});
setInitialSessionsLoaded(new Set());
}, [projects]);
// Auto-expand project folder when a session is selected
useEffect(() => {
if (selectedSession && selectedProject) {
setExpandedProjects(prev => new Set([...prev, selectedProject.name]));
}
}, [selectedSession, selectedProject]);
// Mark sessions as loaded when projects come in
useEffect(() => {
if (projects.length > 0 && !isLoading) {
const newLoaded = new Set();
projects.forEach(project => {
if (project.sessions && project.sessions.length >= 0) {
newLoaded.add(project.name);
}
});
setInitialSessionsLoaded(newLoaded);
}
}, [projects, isLoading]);
const toggleProject = (projectName) => {
const newExpanded = new Set(expandedProjects);
if (newExpanded.has(projectName)) {
newExpanded.delete(projectName);
} else {
newExpanded.add(projectName);
}
setExpandedProjects(newExpanded);
};
const startEditing = (project) => {
setEditingProject(project.name);
setEditingName(project.displayName);
};
const cancelEditing = () => {
setEditingProject(null);
setEditingName('');
};
const saveProjectName = async (projectName) => {
try {
const response = await fetch(`/api/projects/${projectName}/rename`, {
method: 'PUT',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({ displayName: editingName }),
});
if (response.ok) {
// Refresh projects to get updated data
if (window.refreshProjects) {
window.refreshProjects();
} else {
window.location.reload();
}
} else {
console.error('Failed to rename project');
}
} catch (error) {
console.error('Error renaming project:', error);
}
setEditingProject(null);
setEditingName('');
};
const deleteSession = async (projectName, sessionId) => {
if (!confirm('Are you sure you want to delete this session? This action cannot be undone.')) {
return;
}
try {
const response = await fetch(`/api/projects/${projectName}/sessions/${sessionId}`, {
method: 'DELETE',
});
if (response.ok) {
// Call parent callback if provided
if (onSessionDelete) {
onSessionDelete(sessionId);
}
} else {
console.error('Failed to delete session');
alert('Failed to delete session. Please try again.');
}
} catch (error) {
console.error('Error deleting session:', error);
alert('Error deleting session. Please try again.');
}
};
const deleteProject = async (projectName) => {
if (!confirm('Are you sure you want to delete this empty project? This action cannot be undone.')) {
return;
}
try {
const response = await fetch(`/api/projects/${projectName}`, {
method: 'DELETE',
});
if (response.ok) {
// Call parent callback if provided
if (onProjectDelete) {
onProjectDelete(projectName);
}
} else {
const error = await response.json();
console.error('Failed to delete project');
alert(error.error || 'Failed to delete project. Please try again.');
}
} catch (error) {
console.error('Error deleting project:', error);
alert('Error deleting project. Please try again.');
}
};
const createNewProject = async () => {
if (!newProjectPath.trim()) {
alert('Please enter a project path');
return;
}
setCreatingProject(true);
try {
const response = await fetch('/api/projects/create', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
path: newProjectPath.trim()
}),
});
if (response.ok) {
const result = await response.json();
setShowNewProject(false);
setNewProjectPath('');
// Refresh projects to show the new one
if (window.refreshProjects) {
window.refreshProjects();
} else {
window.location.reload();
}
} else {
const error = await response.json();
alert(error.error || 'Failed to create project. Please try again.');
}
} catch (error) {
console.error('Error creating project:', error);
alert('Error creating project. Please try again.');
} finally {
setCreatingProject(false);
}
};
const cancelNewProject = () => {
setShowNewProject(false);
setNewProjectPath('');
};
const loadMoreSessions = async (project) => {
// Check if we can load more sessions
const canLoadMore = project.sessionMeta?.hasMore !== false;
if (!canLoadMore || loadingSessions[project.name]) {
return;
}
setLoadingSessions(prev => ({ ...prev, [project.name]: true }));
try {
const currentSessionCount = (project.sessions?.length || 0) + (additionalSessions[project.name]?.length || 0);
const response = await fetch(
`/api/projects/${project.name}/sessions?limit=5&offset=${currentSessionCount}`
);
if (response.ok) {
const result = await response.json();
// Store additional sessions locally
setAdditionalSessions(prev => ({
...prev,
[project.name]: [
...(prev[project.name] || []),
...result.sessions
]
}));
// Update project metadata if needed
if (result.hasMore === false) {
// Mark that there are no more sessions to load
project.sessionMeta = { ...project.sessionMeta, hasMore: false };
}
}
} catch (error) {
console.error('Error loading more sessions:', error);
} finally {
setLoadingSessions(prev => ({ ...prev, [project.name]: false }));
}
};
// Helper function to get all sessions for a project (initial + additional)
const getAllSessions = (project) => {
const initialSessions = project.sessions || [];
const additional = additionalSessions[project.name] || [];
return [...initialSessions, ...additional];
};
return (
<div className="h-full flex flex-col bg-card md:select-none">
{/* Header */}
<div className="md:p-4 md:border-b md:border-border">
{/* Desktop Header */}
<div className="hidden md:flex items-center justify-between">
<div className="flex items-center gap-3">
<div className="w-8 h-8 bg-primary rounded-lg flex items-center justify-center shadow-sm">
<MessageSquare className="w-4 h-4 text-primary-foreground" />
</div>
<div>
<h1 className="text-lg font-bold text-foreground">Claude Code UI</h1>
<p className="text-sm text-muted-foreground">AI coding assistant interface</p>
</div>
</div>
<div className="flex gap-2">
<Button
variant="ghost"
size="sm"
className="h-9 w-9 px-0 hover:bg-accent transition-colors duration-200 group"
onClick={async () => {
setIsRefreshing(true);
try {
await onRefresh();
} finally {
setIsRefreshing(false);
}
}}
disabled={isRefreshing}
title="Refresh projects and sessions (Ctrl+R)"
>
<RefreshCw className={`w-4 h-4 ${isRefreshing ? 'animate-spin' : ''} group-hover:rotate-180 transition-transform duration-300`} />
</Button>
<Button
variant="default"
size="sm"
className="h-9 w-9 px-0 bg-primary hover:bg-primary/90 transition-all duration-200 shadow-sm hover:shadow-md"
onClick={() => setShowNewProject(true)}
title="Create new project (Ctrl+N)"
>
<FolderPlus className="w-4 h-4" />
</Button>
</div>
</div>
{/* Mobile Header */}
<div className="md:hidden p-3 border-b border-border">
<div className="flex items-center justify-between">
<div className="flex items-center gap-3">
<div className="w-8 h-8 bg-primary rounded-lg flex items-center justify-center">
<MessageSquare className="w-4 h-4 text-primary-foreground" />
</div>
<div>
<h1 className="text-lg font-semibold text-foreground">Claude Code UI</h1>
<p className="text-sm text-muted-foreground">Projects</p>
</div>
</div>
<div className="flex gap-2">
<button
className="w-8 h-8 rounded-md bg-background border border-border flex items-center justify-center active:scale-95 transition-all duration-150"
onClick={async () => {
setIsRefreshing(true);
try {
await onRefresh();
} finally {
setIsRefreshing(false);
}
}}
disabled={isRefreshing}
>
<RefreshCw className={`w-4 h-4 text-foreground ${isRefreshing ? 'animate-spin' : ''}`} />
</button>
<button
className="w-8 h-8 rounded-md bg-primary text-primary-foreground flex items-center justify-center active:scale-95 transition-all duration-150"
onClick={() => setShowNewProject(true)}
>
<FolderPlus className="w-4 h-4" />
</button>
</div>
</div>
</div>
</div>
{/* New Project Form */}
{showNewProject && (
<div className="md:p-3 md:border-b md:border-border md:bg-muted/30">
{/* Desktop Form */}
<div className="hidden md:block space-y-2">
<div className="flex items-center gap-2 text-sm font-medium text-foreground">
<FolderPlus className="w-4 h-4" />
Create New Project
</div>
<Input
value={newProjectPath}
onChange={(e) => setNewProjectPath(e.target.value)}
placeholder="/path/to/project or relative/path"
className="text-sm focus:ring-2 focus:ring-primary/20"
autoFocus
onKeyDown={(e) => {
if (e.key === 'Enter') createNewProject();
if (e.key === 'Escape') cancelNewProject();
}}
/>
<div className="flex gap-2">
<Button
size="sm"
onClick={createNewProject}
disabled={!newProjectPath.trim() || creatingProject}
className="flex-1 h-8 text-xs hover:bg-primary/90 transition-colors"
>
{creatingProject ? 'Creating...' : 'Create Project'}
</Button>
<Button
size="sm"
variant="outline"
onClick={cancelNewProject}
disabled={creatingProject}
className="h-8 text-xs hover:bg-accent transition-colors"
>
Cancel
</Button>
</div>
</div>
{/* Mobile Form - Simple Overlay */}
<div className="md:hidden fixed inset-0 z-50 bg-black/50 backdrop-blur-sm">
<div className="absolute bottom-0 left-0 right-0 bg-card rounded-t-lg border-t border-border p-4 space-y-4 animate-in slide-in-from-bottom duration-300">
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<div className="w-6 h-6 bg-primary/10 rounded-md flex items-center justify-center">
<FolderPlus className="w-3 h-3 text-primary" />
</div>
<div>
<h2 className="text-base font-semibold text-foreground">New Project</h2>
</div>
</div>
<button
onClick={cancelNewProject}
disabled={creatingProject}
className="w-6 h-6 rounded-md bg-muted flex items-center justify-center active:scale-95 transition-transform"
>
<X className="w-3 h-3" />
</button>
</div>
<div className="space-y-3">
<Input
value={newProjectPath}
onChange={(e) => setNewProjectPath(e.target.value)}
placeholder="/path/to/project or relative/path"
className="text-sm h-10 rounded-md focus:border-primary transition-colors"
autoFocus
onKeyDown={(e) => {
if (e.key === 'Enter') createNewProject();
if (e.key === 'Escape') cancelNewProject();
}}
/>
<div className="flex gap-2">
<Button
onClick={cancelNewProject}
disabled={creatingProject}
variant="outline"
className="flex-1 h-9 text-sm rounded-md active:scale-95 transition-transform"
>
Cancel
</Button>
<Button
onClick={createNewProject}
disabled={!newProjectPath.trim() || creatingProject}
className="flex-1 h-9 text-sm rounded-md bg-primary hover:bg-primary/90 active:scale-95 transition-all"
>
{creatingProject ? 'Creating...' : 'Create'}
</Button>
</div>
</div>
{/* Safe area for mobile */}
<div className="h-4" />
</div>
</div>
</div>
)}
{/* Projects List */}
<ScrollArea className="flex-1 md:px-2 md:py-3 overflow-y-auto overscroll-contain">
<div className="md:space-y-1 pb-safe-area-inset-bottom">
{isLoading ? (
<div className="text-center py-12 md:py-8 px-4">
<div className="w-12 h-12 bg-muted rounded-lg flex items-center justify-center mx-auto mb-4 md:mb-3">
<div className="w-6 h-6 animate-spin rounded-full border-2 border-muted-foreground border-t-transparent" />
</div>
<h3 className="text-base font-medium text-foreground mb-2 md:mb-1">Loading projects...</h3>
<p className="text-sm text-muted-foreground">
Fetching your Claude projects and sessions
</p>
</div>
) : projects.length === 0 ? (
<div className="text-center py-12 md:py-8 px-4">
<div className="w-12 h-12 bg-muted rounded-lg flex items-center justify-center mx-auto mb-4 md:mb-3">
<Folder className="w-6 h-6 text-muted-foreground" />
</div>
<h3 className="text-base font-medium text-foreground mb-2 md:mb-1">No projects found</h3>
<p className="text-sm text-muted-foreground">
Run Claude CLI in a project directory to get started
</p>
</div>
) : (
projects.map((project) => {
const isExpanded = expandedProjects.has(project.name);
const isSelected = selectedProject?.name === project.name;
return (
<div key={project.name} className="md:space-y-1">
{/* Project Header */}
<div className="group md:group">
{/* Mobile Project Item */}
<div className="md:hidden">
<div
className={cn(
"p-3 mx-3 my-1 rounded-lg bg-card border border-border/50 active:scale-[0.98] transition-all duration-150",
isSelected && "bg-primary/5 border-primary/20"
)}
onClick={() => {
// On mobile, just toggle the folder - don't select the project
toggleProject(project.name);
}}
onTouchEnd={handleTouchClick(() => toggleProject(project.name))}
>
<div className="flex items-center justify-between">
<div className="flex items-center gap-3 min-w-0 flex-1">
<div className={cn(
"w-8 h-8 rounded-lg flex items-center justify-center transition-colors",
isExpanded ? "bg-primary/10" : "bg-muted"
)}>
{isExpanded ? (
<FolderOpen className="w-4 h-4 text-primary" />
) : (
<Folder className="w-4 h-4 text-muted-foreground" />
)}
</div>
<div className="min-w-0 flex-1">
{editingProject === project.name ? (
<input
type="text"
value={editingName}
onChange={(e) => setEditingName(e.target.value)}
className="w-full px-3 py-2 text-sm border-2 border-primary/40 focus:border-primary rounded-lg bg-background text-foreground shadow-sm focus:shadow-md transition-all duration-200 focus:outline-none"
placeholder="Project name"
autoFocus
autoComplete="off"
onClick={(e) => e.stopPropagation()}
onKeyDown={(e) => {
if (e.key === 'Enter') saveProjectName(project.name);
if (e.key === 'Escape') cancelEditing();
}}
style={{
fontSize: '16px', // Prevents zoom on iOS
WebkitAppearance: 'none',
borderRadius: '8px'
}}
/>
) : (
<>
<h3 className="text-sm font-medium text-foreground truncate">
{project.displayName}
</h3>
<p className="text-xs text-muted-foreground">
{(() => {
const sessionCount = getAllSessions(project).length;
const hasMore = project.sessionMeta?.hasMore !== false;
const count = hasMore && sessionCount >= 5 ? `${sessionCount}+` : sessionCount;
return `${count} session${count === 1 ? '' : 's'}`;
})()}
</p>
</>
)}
</div>
</div>
<div className="flex items-center gap-1">
{editingProject === project.name ? (
<>
<button
className="w-8 h-8 rounded-lg bg-green-500 dark:bg-green-600 flex items-center justify-center active:scale-90 transition-all duration-150 shadow-sm active:shadow-none"
onClick={(e) => {
e.stopPropagation();
saveProjectName(project.name);
}}
>
<Check className="w-4 h-4 text-white" />
</button>
<button
className="w-8 h-8 rounded-lg bg-gray-500 dark:bg-gray-600 flex items-center justify-center active:scale-90 transition-all duration-150 shadow-sm active:shadow-none"
onClick={(e) => {
e.stopPropagation();
cancelEditing();
}}
>
<X className="w-4 h-4 text-white" />
</button>
</>
) : (
<>
{getAllSessions(project).length === 0 && (
<button
className="w-8 h-8 rounded-lg bg-red-500/10 dark:bg-red-900/30 flex items-center justify-center active:scale-90 transition-all duration-150 border border-red-200 dark:border-red-800"
onClick={(e) => {
e.stopPropagation();
deleteProject(project.name);
}}
onTouchEnd={handleTouchClick(() => deleteProject(project.name))}
>
<Trash2 className="w-4 h-4 text-red-600 dark:text-red-400" />
</button>
)}
<button
className="w-8 h-8 rounded-lg bg-primary/10 dark:bg-primary/20 flex items-center justify-center active:scale-90 transition-all duration-150 border border-primary/20 dark:border-primary/30"
onClick={(e) => {
e.stopPropagation();
startEditing(project);
}}
onTouchEnd={handleTouchClick(() => startEditing(project))}
>
<Edit3 className="w-4 h-4 text-primary" />
</button>
<div className="w-6 h-6 rounded-md bg-muted/30 flex items-center justify-center">
{isExpanded ? (
<ChevronDown className="w-3 h-3 text-muted-foreground" />
) : (
<ChevronRight className="w-3 h-3 text-muted-foreground" />
)}
</div>
</>
)}
</div>
</div>
</div>
</div>
{/* Desktop Project Item */}
<Button
variant="ghost"
className={cn(
"hidden md:flex w-full justify-between p-2 h-auto font-normal hover:bg-accent/50 transition-colors duration-200",
isSelected && "bg-accent text-accent-foreground"
)}
onClick={() => {
// Desktop behavior: select project and toggle
if (selectedProject?.name !== project.name) {
onProjectSelect(project);
}
toggleProject(project.name);
}}
onTouchEnd={handleTouchClick(() => {
if (selectedProject?.name !== project.name) {
onProjectSelect(project);
}
toggleProject(project.name);
})}
>
<div className="flex items-center gap-3 min-w-0 flex-1">
{isExpanded ? (
<FolderOpen className="w-4 h-4 text-primary flex-shrink-0" />
) : (
<Folder className="w-4 h-4 text-muted-foreground flex-shrink-0" />
)}
<div className="min-w-0 flex-1 text-left">
{editingProject === project.name ? (
<div className="space-y-1">
<input
type="text"
value={editingName}
onChange={(e) => setEditingName(e.target.value)}
className="w-full px-2 py-1 text-sm border border-border rounded bg-background text-foreground focus:ring-2 focus:ring-primary/20"
placeholder="Project name"
autoFocus
onKeyDown={(e) => {
if (e.key === 'Enter') saveProjectName(project.name);
if (e.key === 'Escape') cancelEditing();
}}
/>
<div className="text-xs text-muted-foreground truncate" title={project.fullPath}>
{project.fullPath}
</div>
</div>
) : (
<div>
<div className="text-sm font-semibold truncate text-foreground" title={project.displayName}>
{project.displayName}
</div>
<div className="text-xs text-muted-foreground">
{(() => {
const sessionCount = getAllSessions(project).length;
const hasMore = project.sessionMeta?.hasMore !== false;
return hasMore && sessionCount >= 5 ? `${sessionCount}+` : sessionCount;
})()}
{project.fullPath !== project.displayName && (
<span className="ml-1 opacity-60" title={project.fullPath}>
{project.fullPath.length > 25 ? '...' + project.fullPath.slice(-22) : project.fullPath}
</span>
)}
</div>
</div>
)}
</div>
</div>
<div className="flex items-center gap-1 flex-shrink-0">
{editingProject === project.name ? (
<>
<div
className="w-6 h-6 text-green-600 hover:text-green-700 hover:bg-green-50 dark:hover:bg-green-900/20 flex items-center justify-center rounded cursor-pointer transition-colors"
onClick={(e) => {
e.stopPropagation();
saveProjectName(project.name);
}}
>
<Check className="w-3 h-3" />
</div>
<div
className="w-6 h-6 text-gray-500 hover:text-gray-700 hover:bg-gray-50 dark:hover:bg-gray-800 flex items-center justify-center rounded cursor-pointer transition-colors"
onClick={(e) => {
e.stopPropagation();
cancelEditing();
}}
>
<X className="w-3 h-3" />
</div>
</>
) : (
<>
<div
className="w-6 h-6 opacity-0 group-hover:opacity-100 transition-all duration-200 hover:bg-accent flex items-center justify-center rounded cursor-pointer touch:opacity-100"
onClick={(e) => {
e.stopPropagation();
startEditing(project);
}}
title="Rename project (F2)"
>
<Edit3 className="w-3 h-3" />
</div>
{getAllSessions(project).length === 0 && (
<div
className="w-6 h-6 opacity-0 group-hover:opacity-100 transition-all duration-200 hover:bg-red-50 dark:hover:bg-red-900/20 flex items-center justify-center rounded cursor-pointer touch:opacity-100"
onClick={(e) => {
e.stopPropagation();
deleteProject(project.name);
}}
title="Delete empty project (Delete)"
>
<Trash2 className="w-3 h-3 text-red-600 dark:text-red-400" />
</div>
)}
{isExpanded ? (
<ChevronDown className="w-4 h-4 text-muted-foreground group-hover:text-foreground transition-colors" />
) : (
<ChevronRight className="w-4 h-4 text-muted-foreground group-hover:text-foreground transition-colors" />
)}
</>
)}
</div>
</Button>
</div>
{/* Sessions List */}
{isExpanded && (
<div className="ml-3 space-y-1 border-l border-border pl-3">
{!initialSessionsLoaded.has(project.name) ? (
// Loading skeleton for sessions
Array.from({ length: 3 }).map((_, i) => (
<div key={i} className="p-2 rounded-md">
<div className="flex items-start gap-2">
<div className="w-3 h-3 bg-muted rounded-full animate-pulse mt-0.5" />
<div className="flex-1 space-y-1">
<div className="h-3 bg-muted rounded animate-pulse" style={{ width: `${60 + i * 15}%` }} />
<div className="h-2 bg-muted rounded animate-pulse w-1/2" />
</div>
</div>
</div>
))
) : getAllSessions(project).length === 0 && !loadingSessions[project.name] ? (
<div className="py-2 px-3 text-left">
<p className="text-xs text-muted-foreground">No sessions yet</p>
</div>
) : (
getAllSessions(project).map((session) => (
<div key={session.id} className="group relative">
{/* Mobile Session Item */}
<div className="md:hidden">
<div
className={cn(
"p-2 mx-3 my-0.5 rounded-md bg-card border border-border/30 active:scale-[0.98] transition-all duration-150 relative",
selectedSession?.id === session.id && "bg-primary/5 border-primary/20"
)}
onClick={() => {
onProjectSelect(project);
onSessionSelect(session);
}}
onTouchEnd={handleTouchClick(() => {
onProjectSelect(project);
onSessionSelect(session);
})}
>
<div className="flex items-center gap-2">
<div className={cn(
"w-5 h-5 rounded-md flex items-center justify-center flex-shrink-0",
selectedSession?.id === session.id ? "bg-primary/10" : "bg-muted/50"
)}>
<MessageSquare className={cn(
"w-3 h-3",
selectedSession?.id === session.id ? "text-primary" : "text-muted-foreground"
)} />
</div>
<div className="min-w-0 flex-1">
<div className="text-xs font-medium truncate text-foreground">
{session.summary || 'New Session'}
</div>
<div className="flex items-center gap-1 mt-0.5">
<Clock className="w-2.5 h-2.5 text-muted-foreground" />
<span className="text-xs text-muted-foreground">
{formatTimeAgo(session.lastActivity, currentTime)}
</span>
{session.messageCount > 0 && (
<Badge variant="secondary" className="text-xs px-1 py-0 ml-auto">
{session.messageCount}
</Badge>
)}
</div>
</div>
{/* Mobile delete button */}
<button
className="w-5 h-5 rounded-md bg-red-50 dark:bg-red-900/20 flex items-center justify-center active:scale-95 transition-transform opacity-70 ml-1"
onClick={(e) => {
e.stopPropagation();
deleteSession(project.name, session.id);
}}
onTouchEnd={handleTouchClick(() => deleteSession(project.name, session.id))}
>
<Trash2 className="w-2.5 h-2.5 text-red-600 dark:text-red-400" />
</button>
</div>
</div>
</div>
{/* Desktop Session Item */}
<div className="hidden md:block">
<Button
variant="ghost"
className={cn(
"w-full justify-start p-2 h-auto font-normal text-left hover:bg-accent/50 transition-colors duration-200",
selectedSession?.id === session.id && "bg-accent text-accent-foreground"
)}
onClick={() => onSessionSelect(session)}
onTouchEnd={handleTouchClick(() => onSessionSelect(session))}
>
<div className="flex items-start gap-2 min-w-0 w-full">
<MessageSquare className="w-3 h-3 text-muted-foreground mt-0.5 flex-shrink-0" />
<div className="min-w-0 flex-1">
<div className="text-xs font-medium truncate text-foreground">
{session.summary || 'New Session'}
</div>
<div className="flex items-center gap-1 mt-0.5">
<Clock className="w-2.5 h-2.5 text-muted-foreground" />
<span className="text-xs text-muted-foreground">
{formatTimeAgo(session.lastActivity, currentTime)}
</span>
{session.messageCount > 0 && (
<Badge variant="secondary" className="text-xs px-1 py-0 ml-auto">
{session.messageCount}
</Badge>
)}
</div>
</div>
</div>
</Button>
{/* Desktop delete button */}
<button
className="absolute right-2 top-1/2 transform -translate-y-1/2 w-6 h-6 opacity-0 group-hover:opacity-100 transition-all duration-200 bg-red-50 hover:bg-red-100 dark:bg-red-900/20 dark:hover:bg-red-900/40 rounded flex items-center justify-center touch:opacity-100"
onClick={(e) => {
e.stopPropagation();
deleteSession(project.name, session.id);
}}
title="Delete session (Delete)"
>
<Trash2 className="w-3 h-3 text-red-600 dark:text-red-400" />
</button>
</div>
</div>
))
)}
{/* Show More Sessions Button */}
{getAllSessions(project).length > 0 && project.sessionMeta?.hasMore !== false && (
<Button
variant="ghost"
size="sm"
className="w-full justify-center gap-2 mt-2 text-muted-foreground"
onClick={() => loadMoreSessions(project)}
disabled={loadingSessions[project.name]}
>
{loadingSessions[project.name] ? (
<>
<div className="w-3 h-3 animate-spin rounded-full border border-muted-foreground border-t-transparent" />
Loading...
</>
) : (
<>
<ChevronDown className="w-3 h-3" />
Show more sessions
</>
)}
</Button>
)}
{/* New Session Button */}
<div className="md:hidden px-3 pb-2">
<button
className="w-full h-8 bg-primary hover:bg-primary/90 text-primary-foreground rounded-md flex items-center justify-center gap-2 font-medium text-xs active:scale-[0.98] transition-all duration-150"
onClick={() => {
onProjectSelect(project);
onNewSession(project);
}}
>
<Plus className="w-3 h-3" />
New Session
</button>
</div>
<Button
variant="default"
size="sm"
className="hidden md:flex w-full justify-start gap-2 mt-1 h-8 text-xs font-medium bg-primary hover:bg-primary/90 text-primary-foreground transition-colors"
onClick={() => onNewSession(project)}
>
<Plus className="w-3 h-3" />
New Session
</Button>
</div>
)}
</div>
);
})
)}
</div>
</ScrollArea>
{/* Settings Section */}
<div className="md:p-2 md:border-t md:border-border flex-shrink-0">
{/* Mobile Settings */}
<div className="md:hidden p-4 pb-20 border-t border-border/50">
<button
className="w-full h-14 bg-muted/50 hover:bg-muted/70 rounded-2xl flex items-center justify-start gap-4 px-4 active:scale-[0.98] transition-all duration-150"
onClick={onShowSettings}
>
<div className="w-10 h-10 rounded-2xl bg-background/80 flex items-center justify-center">
<Settings className="w-5 h-5 text-muted-foreground" />
</div>
<span className="text-lg font-medium text-foreground">Settings</span>
</button>
</div>
{/* Desktop Settings */}
<Button
variant="ghost"
className="hidden md:flex w-full justify-start gap-2 p-2 h-auto font-normal text-muted-foreground hover:text-foreground hover:bg-accent transition-colors duration-200"
onClick={onShowSettings}
>
<Settings className="w-3 h-3" />
<span className="text-xs">Tools Settings</span>
</Button>
</div>
</div>
);
}
export default Sidebar;

View File

@@ -0,0 +1,91 @@
import React from 'react';
import { Badge } from './ui/badge';
import { CheckCircle2, Clock, Circle } from 'lucide-react';
const TodoList = ({ todos, isResult = false }) => {
if (!todos || !Array.isArray(todos)) {
return null;
}
const getStatusIcon = (status) => {
switch (status) {
case 'completed':
return <CheckCircle2 className="w-4 h-4 text-green-500" />;
case 'in_progress':
return <Clock className="w-4 h-4 text-blue-500" />;
case 'pending':
default:
return <Circle className="w-4 h-4 text-gray-400" />;
}
};
const getStatusColor = (status) => {
switch (status) {
case 'completed':
return 'bg-green-100 text-green-800 border-green-200';
case 'in_progress':
return 'bg-blue-100 text-blue-800 border-blue-200';
case 'pending':
default:
return 'bg-gray-100 text-gray-600 border-gray-200';
}
};
const getPriorityColor = (priority) => {
switch (priority) {
case 'high':
return 'bg-red-100 text-red-700 border-red-200';
case 'medium':
return 'bg-yellow-100 text-yellow-700 border-yellow-200';
case 'low':
default:
return 'bg-gray-100 text-gray-600 border-gray-200';
}
};
return (
<div className="space-y-3">
{isResult && (
<div className="text-sm font-medium text-gray-700 mb-3">
Todo List ({todos.length} {todos.length === 1 ? 'item' : 'items'})
</div>
)}
{todos.map((todo) => (
<div
key={todo.id}
className="flex items-start gap-3 p-3 bg-white border rounded-lg shadow-sm hover:shadow-md transition-shadow"
>
<div className="flex-shrink-0 mt-0.5">
{getStatusIcon(todo.status)}
</div>
<div className="flex-1 min-w-0">
<div className="flex items-start justify-between gap-2 mb-2">
<p className={`text-sm font-medium ${todo.status === 'completed' ? 'line-through text-gray-500' : 'text-gray-900'}`}>
{todo.content}
</p>
<div className="flex gap-1 flex-shrink-0">
<Badge
variant="outline"
className={`text-xs px-2 py-0.5 ${getPriorityColor(todo.priority)}`}
>
{todo.priority}
</Badge>
<Badge
variant="outline"
className={`text-xs px-2 py-0.5 ${getStatusColor(todo.status)}`}
>
{todo.status.replace('_', ' ')}
</Badge>
</div>
</div>
</div>
</div>
))}
</div>
);
};
export default TodoList;

View File

@@ -0,0 +1,377 @@
import React, { useState, useEffect } from 'react';
import { Button } from './ui/button';
import { Input } from './ui/input';
import { ScrollArea } from './ui/scroll-area';
import { Badge } from './ui/badge';
import { X, Plus, Settings, Shield, AlertTriangle } from 'lucide-react';
function ToolsSettings({ isOpen, onClose }) {
const [allowedTools, setAllowedTools] = useState([]);
const [disallowedTools, setDisallowedTools] = useState([]);
const [newAllowedTool, setNewAllowedTool] = useState('');
const [newDisallowedTool, setNewDisallowedTool] = useState('');
const [skipPermissions, setSkipPermissions] = useState(false);
const [isSaving, setIsSaving] = useState(false);
const [saveStatus, setSaveStatus] = useState(null);
// Common tool patterns
const commonTools = [
'Bash(git log:*)',
'Bash(git diff:*)',
'Bash(git status:*)',
'Write',
'Read',
'Edit',
'Glob',
'Grep',
'MultiEdit',
'Task',
'TodoWrite',
'TodoRead',
'WebFetch',
'WebSearch'
];
useEffect(() => {
if (isOpen) {
loadSettings();
}
}, [isOpen]);
const loadSettings = () => {
try {
// Load from localStorage
const savedSettings = localStorage.getItem('claude-tools-settings');
if (savedSettings) {
const settings = JSON.parse(savedSettings);
setAllowedTools(settings.allowedTools || []);
setDisallowedTools(settings.disallowedTools || []);
setSkipPermissions(settings.skipPermissions || false);
} else {
// Set defaults
setAllowedTools([]);
setDisallowedTools([]);
setSkipPermissions(false);
}
} catch (error) {
console.error('Error loading tool settings:', error);
// Set defaults on error
setAllowedTools([]);
setDisallowedTools([]);
setSkipPermissions(false);
}
};
const saveSettings = () => {
setIsSaving(true);
setSaveStatus(null);
try {
const settings = {
allowedTools,
disallowedTools,
skipPermissions,
lastUpdated: new Date().toISOString()
};
// Save to localStorage
localStorage.setItem('claude-tools-settings', JSON.stringify(settings));
setSaveStatus('success');
setTimeout(() => {
onClose();
}, 1000);
} catch (error) {
console.error('Error saving tool settings:', error);
setSaveStatus('error');
} finally {
setIsSaving(false);
}
};
const addAllowedTool = (tool) => {
if (tool && !allowedTools.includes(tool)) {
setAllowedTools([...allowedTools, tool]);
setNewAllowedTool('');
}
};
const removeAllowedTool = (tool) => {
setAllowedTools(allowedTools.filter(t => t !== tool));
};
const addDisallowedTool = (tool) => {
if (tool && !disallowedTools.includes(tool)) {
setDisallowedTools([...disallowedTools, tool]);
setNewDisallowedTool('');
}
};
const removeDisallowedTool = (tool) => {
setDisallowedTools(disallowedTools.filter(t => t !== tool));
};
if (!isOpen) return null;
return (
<div className="modal-backdrop fixed inset-0 flex items-center justify-center z-[100] md:p-4 bg-background/95">
<div className="bg-background border border-border md:rounded-lg shadow-xl w-full md:max-w-4xl h-full md:h-[90vh] flex flex-col">
<div className="flex items-center justify-between p-4 md:p-6 border-b border-border flex-shrink-0">
<div className="flex items-center gap-3">
<Settings className="w-5 h-5 md:w-6 md:h-6 text-blue-600" />
<h2 className="text-lg md:text-xl font-semibold text-foreground">
Tools Settings
</h2>
</div>
<Button
variant="ghost"
size="sm"
onClick={onClose}
className="text-muted-foreground hover:text-foreground touch-manipulation"
>
<X className="w-5 h-5" />
</Button>
</div>
<div className="flex-1 overflow-y-auto">
<div className="p-4 md:p-6 space-y-6 md:space-y-8 pb-safe-area-inset-bottom">
{/* Skip Permissions */}
<div className="space-y-4">
<div className="flex items-center gap-3">
<AlertTriangle className="w-5 h-5 text-orange-500" />
<h3 className="text-lg font-medium text-foreground">
Permission Settings
</h3>
</div>
<div className="bg-orange-50 dark:bg-orange-900/20 border border-orange-200 dark:border-orange-800 rounded-lg p-4">
<label className="flex items-center gap-3">
<input
type="checkbox"
checked={skipPermissions}
onChange={(e) => setSkipPermissions(e.target.checked)}
className="w-4 h-4 text-blue-600 bg-gray-100 border-gray-300 rounded focus:ring-blue-500"
/>
<div>
<div className="font-medium text-orange-900 dark:text-orange-100">
Skip permission prompts (use with caution)
</div>
<div className="text-sm text-orange-700 dark:text-orange-300">
Equivalent to --dangerously-skip-permissions flag
</div>
</div>
</label>
</div>
</div>
{/* Allowed Tools */}
<div className="space-y-4">
<div className="flex items-center gap-3">
<Shield className="w-5 h-5 text-green-500" />
<h3 className="text-lg font-medium text-foreground">
Allowed Tools
</h3>
</div>
<p className="text-sm text-muted-foreground">
Tools that are automatically allowed without prompting for permission
</p>
<div className="flex flex-col sm:flex-row gap-2">
<Input
value={newAllowedTool}
onChange={(e) => setNewAllowedTool(e.target.value)}
placeholder='e.g., "Bash(git log:*)" or "Write"'
onKeyPress={(e) => {
if (e.key === 'Enter') {
addAllowedTool(newAllowedTool);
}
}}
className="flex-1 h-10 touch-manipulation"
style={{ fontSize: '16px' }}
/>
<Button
onClick={() => addAllowedTool(newAllowedTool)}
disabled={!newAllowedTool}
size="sm"
className="h-10 px-4 touch-manipulation"
>
<Plus className="w-4 h-4 mr-2 sm:mr-0" />
<span className="sm:hidden">Add Tool</span>
</Button>
</div>
{/* Common tools quick add */}
<div className="space-y-2">
<p className="text-sm font-medium text-gray-700 dark:text-gray-300">
Quick add common tools:
</p>
<div className="grid grid-cols-2 sm:flex sm:flex-wrap gap-2">
{commonTools.map(tool => (
<Button
key={tool}
variant="outline"
size="sm"
onClick={() => addAllowedTool(tool)}
disabled={allowedTools.includes(tool)}
className="text-xs h-8 touch-manipulation truncate"
>
{tool}
</Button>
))}
</div>
</div>
<div className="space-y-2">
{allowedTools.map(tool => (
<div key={tool} className="flex items-center justify-between bg-green-50 dark:bg-green-900/20 border border-green-200 dark:border-green-800 rounded-lg p-3">
<span className="font-mono text-sm text-green-800 dark:text-green-200">
{tool}
</span>
<Button
variant="ghost"
size="sm"
onClick={() => removeAllowedTool(tool)}
className="text-green-600 hover:text-green-700 dark:text-green-400 dark:hover:text-green-300"
>
<X className="w-4 h-4" />
</Button>
</div>
))}
{allowedTools.length === 0 && (
<div className="text-center py-8 text-gray-500 dark:text-gray-400">
No allowed tools configured
</div>
)}
</div>
</div>
{/* Disallowed Tools */}
<div className="space-y-4">
<div className="flex items-center gap-3">
<AlertTriangle className="w-5 h-5 text-red-500" />
<h3 className="text-lg font-medium text-foreground">
Disallowed Tools
</h3>
</div>
<p className="text-sm text-muted-foreground">
Tools that are automatically blocked without prompting for permission
</p>
<div className="flex flex-col sm:flex-row gap-2">
<Input
value={newDisallowedTool}
onChange={(e) => setNewDisallowedTool(e.target.value)}
placeholder='e.g., "Bash(rm:*)" or "Write"'
onKeyPress={(e) => {
if (e.key === 'Enter') {
addDisallowedTool(newDisallowedTool);
}
}}
className="flex-1 h-10 touch-manipulation"
style={{ fontSize: '16px' }}
/>
<Button
onClick={() => addDisallowedTool(newDisallowedTool)}
disabled={!newDisallowedTool}
size="sm"
className="h-10 px-4 touch-manipulation"
>
<Plus className="w-4 h-4 mr-2 sm:mr-0" />
<span className="sm:hidden">Add Tool</span>
</Button>
</div>
<div className="space-y-2">
{disallowedTools.map(tool => (
<div key={tool} className="flex items-center justify-between bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800 rounded-lg p-3">
<span className="font-mono text-sm text-red-800 dark:text-red-200">
{tool}
</span>
<Button
variant="ghost"
size="sm"
onClick={() => removeDisallowedTool(tool)}
className="text-red-600 hover:text-red-700 dark:text-red-400 dark:hover:text-red-300"
>
<X className="w-4 h-4" />
</Button>
</div>
))}
{disallowedTools.length === 0 && (
<div className="text-center py-8 text-gray-500 dark:text-gray-400">
No disallowed tools configured
</div>
)}
</div>
</div>
{/* Help Section */}
<div className="bg-blue-50 dark:bg-blue-900/20 border border-blue-200 dark:border-blue-800 rounded-lg p-4">
<h4 className="font-medium text-blue-900 dark:text-blue-100 mb-2">
Tool Pattern Examples:
</h4>
<ul className="text-sm text-blue-800 dark:text-blue-200 space-y-1">
<li><code className="bg-blue-100 dark:bg-blue-800 px-1 rounded">"Bash(git log:*)"</code> - Allow all git log commands</li>
<li><code className="bg-blue-100 dark:bg-blue-800 px-1 rounded">"Bash(git diff:*)"</code> - Allow all git diff commands</li>
<li><code className="bg-blue-100 dark:bg-blue-800 px-1 rounded">"Write"</code> - Allow all Write tool usage</li>
<li><code className="bg-blue-100 dark:bg-blue-800 px-1 rounded">"Read"</code> - Allow all Read tool usage</li>
<li><code className="bg-blue-100 dark:bg-blue-800 px-1 rounded">"Bash(rm:*)"</code> - Block all rm commands (dangerous)</li>
</ul>
</div>
</div>
</div>
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between p-4 md:p-6 border-t border-border flex-shrink-0 gap-3 pb-safe-area-inset-bottom">
<div className="flex items-center justify-center sm:justify-start gap-2 order-2 sm:order-1">
{saveStatus === 'success' && (
<div className="text-green-600 dark:text-green-400 text-sm flex items-center gap-1">
<svg className="w-4 h-4" fill="currentColor" viewBox="0 0 20 20">
<path fillRule="evenodd" d="M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z" clipRule="evenodd" />
</svg>
Settings saved successfully!
</div>
)}
{saveStatus === 'error' && (
<div className="text-red-600 dark:text-red-400 text-sm flex items-center gap-1">
<svg className="w-4 h-4" fill="currentColor" viewBox="0 0 20 20">
<path fillRule="evenodd" d="M18 10a8 8 0 11-16 0 8 8 0 0116 0zm-7 4a1 1 0 11-2 0 1 1 0 012 0zm-1-9a1 1 0 00-1 1v4a1 1 0 102 0V6a1 1 0 00-1-1z" clipRule="evenodd" />
</svg>
Failed to save settings
</div>
)}
</div>
<div className="flex items-center gap-3 order-1 sm:order-2">
<Button
variant="outline"
onClick={onClose}
disabled={isSaving}
className="flex-1 sm:flex-none h-10 touch-manipulation"
>
Cancel
</Button>
<Button
onClick={saveSettings}
disabled={isSaving}
className="flex-1 sm:flex-none h-10 bg-blue-600 hover:bg-blue-700 disabled:opacity-50 touch-manipulation"
>
{isSaving ? (
<div className="flex items-center gap-2">
<div className="w-4 h-4 animate-spin rounded-full border-2 border-white border-t-transparent" />
Saving...
</div>
) : (
'Save Settings'
)}
</Button>
</div>
</div>
</div>
</div>
);
}
export default ToolsSettings;

View File

@@ -0,0 +1,31 @@
import * as React from "react"
import { cva } from "class-variance-authority"
import { cn } from "../../lib/utils"
const badgeVariants = cva(
"inline-flex items-center rounded-md border px-2.5 py-0.5 text-xs font-semibold transition-colors focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2",
{
variants: {
variant: {
default:
"border-transparent bg-primary text-primary-foreground shadow hover:bg-primary/80",
secondary:
"border-transparent bg-secondary text-secondary-foreground hover:bg-secondary/80",
destructive:
"border-transparent bg-destructive text-destructive-foreground shadow hover:bg-destructive/80",
outline: "text-foreground",
},
},
defaultVariants: {
variant: "default",
},
}
)
function Badge({ className, variant, ...props }) {
return (
<div className={cn(badgeVariants({ variant }), className)} {...props} />
)
}
export { Badge, badgeVariants }

View File

@@ -0,0 +1,46 @@
import * as React from "react"
import { cva } from "class-variance-authority"
import { cn } from "../../lib/utils"
const buttonVariants = cva(
"inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0",
{
variants: {
variant: {
default:
"bg-primary text-primary-foreground shadow hover:bg-primary/90",
destructive:
"bg-destructive text-destructive-foreground shadow-sm hover:bg-destructive/90",
outline:
"border border-input bg-background shadow-sm hover:bg-accent hover:text-accent-foreground",
secondary:
"bg-secondary text-secondary-foreground shadow-sm hover:bg-secondary/80",
ghost: "hover:bg-accent hover:text-accent-foreground",
link: "text-primary underline-offset-4 hover:underline",
},
size: {
default: "h-9 px-4 py-2",
sm: "h-8 rounded-md px-3 text-xs",
lg: "h-10 rounded-md px-8",
icon: "h-9 w-9",
},
},
defaultVariants: {
variant: "default",
size: "default",
},
}
)
const Button = React.forwardRef(({ className, variant, size, ...props }, ref) => {
return (
<button
className={cn(buttonVariants({ variant, size, className }))}
ref={ref}
{...props}
/>
)
})
Button.displayName = "Button"
export { Button, buttonVariants }

View File

@@ -0,0 +1,19 @@
import * as React from "react"
import { cn } from "../../lib/utils"
const Input = React.forwardRef(({ className, type, ...props }, ref) => {
return (
<input
type={type}
className={cn(
"flex h-9 w-full rounded-md border border-input bg-transparent px-3 py-1 text-sm shadow-sm transition-colors file:border-0 file:bg-transparent file:text-sm file:font-medium file:text-foreground placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50",
className
)}
ref={ref}
{...props}
/>
)
})
Input.displayName = "Input"
export { Input }

View File

@@ -0,0 +1,17 @@
import * as React from "react"
import { cn } from "../../lib/utils"
const ScrollArea = React.forwardRef(({ className, children, ...props }, ref) => (
<div
ref={ref}
className={cn("relative overflow-hidden", className)}
{...props}
>
<div className="h-full w-full rounded-[inherit] overflow-auto">
{children}
</div>
</div>
))
ScrollArea.displayName = "ScrollArea"
export { ScrollArea }

254
src/index.css Normal file
View File

@@ -0,0 +1,254 @@
@tailwind base;
@tailwind components;
@tailwind utilities;
@layer base {
:root {
--background: 0 0% 100%;
--foreground: 240 10% 3.9%;
--card: 0 0% 100%;
--card-foreground: 240 10% 3.9%;
--popover: 0 0% 100%;
--popover-foreground: 240 10% 3.9%;
--primary: 240 5.9% 10%;
--primary-foreground: 0 0% 98%;
--secondary: 240 4.8% 95.9%;
--secondary-foreground: 240 5.9% 10%;
--muted: 240 4.8% 95.9%;
--muted-foreground: 240 3.8% 46.1%;
--accent: 240 4.8% 95.9%;
--accent-foreground: 240 5.9% 10%;
--destructive: 0 84.2% 60.2%;
--destructive-foreground: 0 0% 98%;
--border: 240 5.9% 90%;
--input: 240 5.9% 90%;
--ring: 240 5.9% 10%;
--radius: 0.5rem;
}
.dark {
--background: 240 10% 3.9%;
--foreground: 0 0% 98%;
--card: 240 10% 3.9%;
--card-foreground: 0 0% 98%;
--popover: 240 10% 3.9%;
--popover-foreground: 0 0% 98%;
--primary: 0 0% 98%;
--primary-foreground: 240 5.9% 10%;
--secondary: 240 3.7% 15.9%;
--secondary-foreground: 0 0% 98%;
--muted: 240 3.7% 15.9%;
--muted-foreground: 240 5% 64.9%;
--accent: 240 3.7% 15.9%;
--accent-foreground: 0 0% 98%;
--destructive: 0 62.8% 30.6%;
--destructive-foreground: 0 0% 98%;
--border: 240 3.7% 15.9%;
--input: 240 3.7% 15.9%;
--ring: 240 4.9% 83.9%;
}
}
@layer base {
* {
@apply border-border;
box-sizing: border-box;
}
body {
@apply bg-background text-foreground;
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;
margin: 0;
padding: 0;
}
html, body, #root {
height: 100%;
margin: 0;
padding: 0;
}
}
@layer utilities {
.scrollbar-thin {
scrollbar-width: thin;
scrollbar-color: hsl(var(--muted-foreground)) transparent;
}
.scrollbar-thin::-webkit-scrollbar {
width: 6px;
}
.scrollbar-thin::-webkit-scrollbar-track {
background: transparent;
}
.scrollbar-thin::-webkit-scrollbar-thumb {
background-color: hsl(var(--muted-foreground));
border-radius: 3px;
}
.scrollbar-thin::-webkit-scrollbar-thumb:hover {
background-color: hsl(var(--muted-foreground) / 0.8);
}
}
/* Mobile optimizations and components */
@layer components {
/* Mobile touch optimization and safe areas */
@media (max-width: 768px) {
* {
touch-action: manipulation;
-webkit-tap-highlight-color: transparent;
}
button,
[role="button"],
.clickable,
a,
.cursor-pointer {
-webkit-tap-highlight-color: transparent;
user-select: none;
-webkit-user-select: none;
touch-action: manipulation;
}
/* Better mobile touch targets */
.mobile-touch-target {
@apply min-h-[44px] min-w-[44px];
}
/* Chat message improvements */
.chat-message.user {
@apply justify-end;
}
.chat-message.user > div {
@apply max-w-[85%];
}
.chat-message.assistant > div,
.chat-message.error > div {
@apply w-full sm:max-w-[95%];
}
/* Enable text selection on mobile for terminal */
.xterm,
.xterm .xterm-viewport {
-webkit-user-select: text !important;
user-select: text !important;
-webkit-touch-callout: default !important;
}
/* Fix mobile scrolling */
.overflow-y-auto {
touch-action: pan-y;
-webkit-overflow-scrolling: touch;
}
.chat-message {
touch-action: pan-y;
}
/* Fix hover states on mobile */
.group:active .group-hover\:opacity-100,
.group .group-hover\:opacity-100 {
opacity: 1 !important;
}
@media (hover: none) and (pointer: coarse) {
.group-hover\:opacity-100 {
opacity: 1 !important;
}
.hover\:bg-gray-50:hover,
.hover\:bg-gray-100:hover,
.hover\:bg-red-200:hover,
.dark\:hover\:bg-gray-700:hover,
.dark\:hover\:bg-red-900\/50:hover {
background-color: inherit;
}
}
}
/* Touch device optimizations for all screen sizes */
@media (hover: none) and (pointer: coarse) {
.touch\:opacity-100 {
opacity: 1 !important;
}
/* Completely disable hover states on touch devices */
* {
-webkit-tap-highlight-color: transparent !important;
}
/* Only disable hover states for interactive elements, not containers */
button:hover,
[role="button"]:hover,
.cursor-pointer:hover,
a:hover,
.hover\:bg-gray-50:hover,
.hover\:bg-gray-100:hover,
.hover\:text-gray-900:hover,
.hover\:opacity-100:hover {
background-color: inherit !important;
color: inherit !important;
opacity: inherit !important;
transform: inherit !important;
}
/* Preserve backgrounds for containers and modals */
.fixed:hover,
.modal:hover,
.bg-white:hover,
.bg-gray-800:hover,
.bg-gray-900:hover,
[class*="bg-"]:hover {
background-color: revert !important;
}
/* Force buttons to be immediately clickable */
button, [role="button"], .cursor-pointer {
cursor: pointer !important;
pointer-events: auto !important;
}
/* Keep active states for immediate feedback */
.active\:scale-\[0\.98\]:active,
.active\:scale-95:active {
transform: scale(0.98) !important;
}
}
/* Safe area support for iOS devices */
.ios-bottom-safe {
padding-bottom: max(env(safe-area-inset-bottom), 12px);
}
@media screen and (max-width: 768px) {
.chat-input-mobile {
padding-bottom: calc(60px + max(env(safe-area-inset-bottom), 12px));
}
}
/* Text wrapping improvements */
.chat-message {
word-wrap: break-word;
overflow-wrap: break-word;
hyphens: auto;
}
/* Force wrap long URLs and code */
.chat-message pre,
.chat-message code {
white-space: pre-wrap !important;
word-break: break-all;
overflow-wrap: break-word;
}
/* Prevent horizontal scroll in chat area */
.chat-message * {
max-width: 100%;
box-sizing: border-box;
}
}

6
src/lib/utils.js Normal file
View File

@@ -0,0 +1,6 @@
import { clsx } from "clsx"
import { twMerge } from "tailwind-merge"
export function cn(...inputs) {
return twMerge(clsx(inputs))
}

10
src/main.jsx Normal file
View File

@@ -0,0 +1,10 @@
import React from 'react'
import ReactDOM from 'react-dom/client'
import App from './App.jsx'
import './index.css'
ReactDOM.createRoot(document.getElementById('root')).render(
<React.StrictMode>
<App />
</React.StrictMode>,
)

97
src/utils/websocket.js Normal file
View File

@@ -0,0 +1,97 @@
import { useState, useEffect, useRef } from 'react';
export function useWebSocket() {
const [ws, setWs] = useState(null);
const [messages, setMessages] = useState([]);
const [isConnected, setIsConnected] = useState(false);
const reconnectTimeoutRef = useRef(null);
useEffect(() => {
connect();
return () => {
if (reconnectTimeoutRef.current) {
clearTimeout(reconnectTimeoutRef.current);
}
if (ws) {
ws.close();
}
};
}, []);
const connect = async () => {
try {
// Fetch server configuration to get the correct WebSocket URL
let wsBaseUrl;
try {
const configResponse = await fetch('/api/config');
const config = await configResponse.json();
wsBaseUrl = config.wsUrl;
// If the config returns localhost but we're not on localhost, use current host but with API server port
if (wsBaseUrl.includes('localhost') && !window.location.hostname.includes('localhost')) {
console.warn('Config returned localhost, using current host with API server port instead');
const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
// For development, API server is typically on port 3002 when Vite is on 3001
const apiPort = window.location.port === '3001' ? '3002' : window.location.port;
wsBaseUrl = `${protocol}//${window.location.hostname}:${apiPort}`;
}
} catch (error) {
console.warn('Could not fetch server config, falling back to current host with API server port');
const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
// For development, API server is typically on port 3002 when Vite is on 3001
const apiPort = window.location.port === '3001' ? '3002' : window.location.port;
wsBaseUrl = `${protocol}//${window.location.hostname}:${apiPort}`;
}
const wsUrl = `${wsBaseUrl}/ws`;
const websocket = new WebSocket(wsUrl);
websocket.onopen = () => {
setIsConnected(true);
setWs(websocket);
};
websocket.onmessage = (event) => {
try {
const data = JSON.parse(event.data);
setMessages(prev => [...prev, data]);
} catch (error) {
console.error('Error parsing WebSocket message:', error);
}
};
websocket.onclose = () => {
setIsConnected(false);
setWs(null);
// Attempt to reconnect after 3 seconds
reconnectTimeoutRef.current = setTimeout(() => {
connect();
}, 3000);
};
websocket.onerror = (error) => {
console.error('WebSocket error:', error);
};
} catch (error) {
console.error('Error creating WebSocket connection:', error);
}
};
const sendMessage = (message) => {
if (ws && isConnected) {
ws.send(JSON.stringify(message));
} else {
console.warn('WebSocket not connected');
}
};
return {
ws,
sendMessage,
messages,
isConnected
};
}