mirror of
https://github.com/siteboon/claudecodeui.git
synced 2025-12-11 16:59:37 +00:00
Add global settings integration and persistent user preferences for the code editor. Settings are now stored in localStorage and persist across sessions. Changes: - Add theme, word wrap, minimap, line numbers, and font size settings - Load editor preferences from localStorage on initialization - Expose global openSettings function for cross-component access - Add settingsInitialTab state to control which settings tab opens - Pass initialTab prop to Settings component for navigation This improves UX by remembering user preferences and allows other components to open settings to specific tabs programmatically.
970 lines
40 KiB
JavaScript
970 lines
40 KiB
JavaScript
/*
|
|
* 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, useCallback } from 'react';
|
|
import { BrowserRouter as Router, Routes, Route, useNavigate, useParams } from 'react-router-dom';
|
|
import { Settings as SettingsIcon, Sparkles } from 'lucide-react';
|
|
import Sidebar from './components/Sidebar';
|
|
import MainContent from './components/MainContent';
|
|
import MobileNav from './components/MobileNav';
|
|
import Settings from './components/Settings';
|
|
import QuickSettingsPanel from './components/QuickSettingsPanel';
|
|
|
|
import { ThemeProvider } from './contexts/ThemeContext';
|
|
import { AuthProvider } from './contexts/AuthContext';
|
|
import { TaskMasterProvider } from './contexts/TaskMasterContext';
|
|
import { TasksSettingsProvider } from './contexts/TasksSettingsContext';
|
|
import { WebSocketProvider, useWebSocketContext } from './contexts/WebSocketContext';
|
|
import ProtectedRoute from './components/ProtectedRoute';
|
|
import { useVersionCheck } from './hooks/useVersionCheck';
|
|
import useLocalStorage from './hooks/useLocalStorage';
|
|
import { api, authenticatedFetch } from './utils/api';
|
|
|
|
|
|
// Main App component with routing
|
|
function AppContent() {
|
|
const navigate = useNavigate();
|
|
const { sessionId } = useParams();
|
|
|
|
const { updateAvailable, latestVersion, currentVersion, releaseInfo } = useVersionCheck('siteboon', 'claudecodeui');
|
|
const [showVersionModal, setShowVersionModal] = useState(false);
|
|
|
|
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 [showSettings, setShowSettings] = useState(false);
|
|
const [settingsInitialTab, setSettingsInitialTab] = useState('tools');
|
|
const [showQuickSettings, setShowQuickSettings] = useState(false);
|
|
const [autoExpandTools, setAutoExpandTools] = useLocalStorage('autoExpandTools', false);
|
|
const [showRawParameters, setShowRawParameters] = useLocalStorage('showRawParameters', false);
|
|
const [showThinking, setShowThinking] = useLocalStorage('showThinking', true);
|
|
const [autoScrollToBottom, setAutoScrollToBottom] = useLocalStorage('autoScrollToBottom', true);
|
|
const [sendByCtrlEnter, setSendByCtrlEnter] = useLocalStorage('sendByCtrlEnter', false);
|
|
const [sidebarVisible, setSidebarVisible] = useLocalStorage('sidebarVisible', true);
|
|
// 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
|
|
|
|
// Processing Sessions: Track which sessions are currently thinking/processing
|
|
// This allows us to restore the "Thinking..." banner when switching back to a processing session
|
|
const [processingSessions, setProcessingSessions] = useState(new Set());
|
|
|
|
// External Message Update Trigger: Incremented when external CLI modifies current session's JSONL
|
|
// Triggers ChatInterface to reload messages without switching sessions
|
|
const [externalMessageUpdate, setExternalMessageUpdate] = useState(0);
|
|
|
|
const { ws, sendMessage, messages } = useWebSocketContext();
|
|
|
|
// Detect if running as PWA
|
|
const [isPWA, setIsPWA] = useState(false);
|
|
|
|
useEffect(() => {
|
|
// Check if running in standalone mode (PWA)
|
|
const checkPWA = () => {
|
|
const isStandalone = window.matchMedia('(display-mode: standalone)').matches ||
|
|
window.navigator.standalone ||
|
|
document.referrer.includes('android-app://');
|
|
setIsPWA(isStandalone);
|
|
|
|
// Add class to html and body for CSS targeting
|
|
if (isStandalone) {
|
|
document.documentElement.classList.add('pwa-mode');
|
|
document.body.classList.add('pwa-mode');
|
|
} else {
|
|
document.documentElement.classList.remove('pwa-mode');
|
|
document.body.classList.remove('pwa-mode');
|
|
}
|
|
};
|
|
|
|
checkPWA();
|
|
|
|
// Listen for changes
|
|
window.matchMedia('(display-mode: standalone)').addEventListener('change', checkPWA);
|
|
|
|
return () => {
|
|
window.matchMedia('(display-mode: standalone)').removeEventListener('change', checkPWA);
|
|
};
|
|
}, []);
|
|
|
|
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') {
|
|
|
|
// External Session Update Detection: Check if the changed file is the current session's JSONL
|
|
// If so, and the session is not active, trigger a message reload in ChatInterface
|
|
if (latestMessage.changedFile && selectedSession && selectedProject) {
|
|
// Extract session ID from changedFile (format: "project-name/session-id.jsonl")
|
|
const changedFileParts = latestMessage.changedFile.split('/');
|
|
if (changedFileParts.length >= 2) {
|
|
const filename = changedFileParts[changedFileParts.length - 1];
|
|
const changedSessionId = filename.replace('.jsonl', '');
|
|
|
|
// Check if this is the currently-selected session
|
|
if (changedSessionId === selectedSession.id) {
|
|
const isSessionActive = activeSessions.has(selectedSession.id);
|
|
|
|
if (!isSessionActive) {
|
|
// Session is not active - safe to reload messages
|
|
setExternalMessageUpdate(prev => prev + 1);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// 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) {
|
|
// Only update selected project if it actually changed - prevents flickering
|
|
if (JSON.stringify(updatedSelectedProject) !== JSON.stringify(selectedProject)) {
|
|
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 api.projects();
|
|
const data = await response.json();
|
|
|
|
// Always fetch Cursor sessions for each project so we can combine views
|
|
for (let project of data) {
|
|
try {
|
|
const url = `/api/cursor/sessions?projectPath=${encodeURIComponent(project.fullPath || project.path)}`;
|
|
const cursorResponse = await authenticatedFetch(url);
|
|
if (cursorResponse.ok) {
|
|
const cursorData = await cursorResponse.json();
|
|
if (cursorData.success && cursorData.sessions) {
|
|
project.cursorSessions = cursorData.sessions;
|
|
} else {
|
|
project.cursorSessions = [];
|
|
}
|
|
} else {
|
|
project.cursorSessions = [];
|
|
}
|
|
} catch (error) {
|
|
console.error(`Error fetching Cursor sessions for project ${project.name}:`, error);
|
|
project.cursorSessions = [];
|
|
}
|
|
}
|
|
|
|
// 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) ||
|
|
JSON.stringify(newProject.cursorSessions) !== JSON.stringify(prevProject.cursorSessions)
|
|
);
|
|
}) || 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;
|
|
|
|
// Expose openSettings function globally for component access
|
|
window.openSettings = useCallback((tab = 'tools') => {
|
|
setSettingsInitialTab(tab);
|
|
setShowSettings(true);
|
|
}, []);
|
|
|
|
// Handle URL-based session loading
|
|
useEffect(() => {
|
|
if (sessionId && projects.length > 0) {
|
|
// Only switch tabs on initial load, not on every project update
|
|
const shouldSwitchTab = !selectedSession || selectedSession.id !== sessionId;
|
|
// Find the session across all projects
|
|
for (const project of projects) {
|
|
let session = project.sessions?.find(s => s.id === sessionId);
|
|
if (session) {
|
|
setSelectedProject(project);
|
|
setSelectedSession({ ...session, __provider: 'claude' });
|
|
// Only switch to chat tab if we're loading a different session
|
|
if (shouldSwitchTab) {
|
|
setActiveTab('chat');
|
|
}
|
|
return;
|
|
}
|
|
// Also check Cursor sessions
|
|
const cSession = project.cursorSessions?.find(s => s.id === sessionId);
|
|
if (cSession) {
|
|
setSelectedProject(project);
|
|
setSelectedSession({ ...cSession, __provider: 'cursor' });
|
|
if (shouldSwitchTab) {
|
|
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);
|
|
// Only switch to chat tab when user explicitly selects a session
|
|
// This prevents tab switching during automatic updates
|
|
if (activeTab !== 'git' && activeTab !== 'preview') {
|
|
setActiveTab('chat');
|
|
}
|
|
|
|
// For Cursor sessions, we need to set the session ID differently
|
|
// since they're persistent and not created by Claude
|
|
const provider = localStorage.getItem('selected-provider') || 'claude';
|
|
if (provider === 'cursor') {
|
|
// Cursor sessions have persistent IDs
|
|
sessionStorage.setItem('cursorSessionId', session.id);
|
|
}
|
|
|
|
// Only close sidebar on mobile if switching to a different project
|
|
if (isMobile) {
|
|
const sessionProjectName = session.__projectName;
|
|
const currentProjectName = selectedProject?.name;
|
|
|
|
// Close sidebar if clicking a session from a different project
|
|
// Keep it open if clicking a session from the same project
|
|
if (sessionProjectName !== currentProjectName) {
|
|
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 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 = useCallback((sessionId) => {
|
|
if (sessionId) {
|
|
setActiveSessions(prev => new Set([...prev, sessionId]));
|
|
}
|
|
}, []);
|
|
|
|
// markSessionAsInactive: Called when conversation completes/aborts to re-enable project updates
|
|
const markSessionAsInactive = useCallback((sessionId) => {
|
|
if (sessionId) {
|
|
setActiveSessions(prev => {
|
|
const newSet = new Set(prev);
|
|
newSet.delete(sessionId);
|
|
return newSet;
|
|
});
|
|
}
|
|
}, []);
|
|
|
|
// Processing Session Functions: Track which sessions are currently thinking/processing
|
|
|
|
// markSessionAsProcessing: Called when Claude starts thinking/processing
|
|
const markSessionAsProcessing = useCallback((sessionId) => {
|
|
if (sessionId) {
|
|
setProcessingSessions(prev => new Set([...prev, sessionId]));
|
|
}
|
|
}, []);
|
|
|
|
// markSessionAsNotProcessing: Called when Claude finishes thinking/processing
|
|
const markSessionAsNotProcessing = useCallback((sessionId) => {
|
|
if (sessionId) {
|
|
setProcessingSessions(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 = useCallback((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;
|
|
});
|
|
}
|
|
}, []);
|
|
|
|
// Version Upgrade Modal Component
|
|
const VersionUpgradeModal = () => {
|
|
const [isUpdating, setIsUpdating] = useState(false);
|
|
const [updateOutput, setUpdateOutput] = useState('');
|
|
const [updateError, setUpdateError] = useState('');
|
|
|
|
if (!showVersionModal) return null;
|
|
|
|
// Clean up changelog by removing GitHub-specific metadata
|
|
const cleanChangelog = (body) => {
|
|
if (!body) return '';
|
|
|
|
return body
|
|
// Remove full commit hashes (40 character hex strings)
|
|
.replace(/\b[0-9a-f]{40}\b/gi, '')
|
|
// Remove short commit hashes (7-10 character hex strings at start of line or after dash/space)
|
|
.replace(/(?:^|\s|-)([0-9a-f]{7,10})\b/gi, '')
|
|
// Remove "Full Changelog" links
|
|
.replace(/\*\*Full Changelog\*\*:.*$/gim, '')
|
|
// Remove compare links (e.g., https://github.com/.../compare/v1.0.0...v1.0.1)
|
|
.replace(/https?:\/\/github\.com\/[^\/]+\/[^\/]+\/compare\/[^\s)]+/gi, '')
|
|
// Clean up multiple consecutive empty lines
|
|
.replace(/\n\s*\n\s*\n/g, '\n\n')
|
|
// Trim whitespace
|
|
.trim();
|
|
};
|
|
|
|
const handleUpdateNow = async () => {
|
|
setIsUpdating(true);
|
|
setUpdateOutput('Starting update...\n');
|
|
setUpdateError('');
|
|
|
|
try {
|
|
// Call the backend API to run the update command
|
|
const response = await authenticatedFetch('/api/system/update', {
|
|
method: 'POST',
|
|
});
|
|
|
|
const data = await response.json();
|
|
|
|
if (response.ok) {
|
|
setUpdateOutput(prev => prev + data.output + '\n');
|
|
setUpdateOutput(prev => prev + '\n✅ Update completed successfully!\n');
|
|
setUpdateOutput(prev => prev + 'Please restart the server to apply changes.\n');
|
|
} else {
|
|
setUpdateError(data.error || 'Update failed');
|
|
setUpdateOutput(prev => prev + '\n❌ Update failed: ' + (data.error || 'Unknown error') + '\n');
|
|
}
|
|
} catch (error) {
|
|
setUpdateError(error.message);
|
|
setUpdateOutput(prev => prev + '\n❌ Update failed: ' + error.message + '\n');
|
|
} finally {
|
|
setIsUpdating(false);
|
|
}
|
|
};
|
|
|
|
return (
|
|
<div className="fixed inset-0 z-50 flex items-center justify-center">
|
|
{/* Backdrop */}
|
|
<button
|
|
className="fixed inset-0 bg-black/50 backdrop-blur-sm"
|
|
onClick={() => setShowVersionModal(false)}
|
|
aria-label="Close version upgrade modal"
|
|
/>
|
|
|
|
{/* Modal */}
|
|
<div className="relative bg-white dark:bg-gray-800 rounded-lg shadow-xl border border-gray-200 dark:border-gray-700 w-full max-w-2xl mx-4 p-6 space-y-4 max-h-[90vh] overflow-y-auto">
|
|
{/* Header */}
|
|
<div className="flex items-center justify-between">
|
|
<div className="flex items-center gap-3">
|
|
<div className="w-10 h-10 bg-blue-100 dark:bg-blue-900/30 rounded-lg flex items-center justify-center">
|
|
<svg className="w-5 h-5 text-blue-600 dark:text-blue-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M7 16a4 4 0 01-.88-7.903A5 5 0 1115.9 6L16 6a5 5 0 011 9.9M9 19l3 3m0 0l3-3m-3 3V10" />
|
|
</svg>
|
|
</div>
|
|
<div>
|
|
<h2 className="text-lg font-semibold text-gray-900 dark:text-white">Update Available</h2>
|
|
<p className="text-sm text-gray-500 dark:text-gray-400">
|
|
{releaseInfo?.title || 'A new version is ready'}
|
|
</p>
|
|
</div>
|
|
</div>
|
|
<button
|
|
onClick={() => setShowVersionModal(false)}
|
|
className="p-2 text-gray-400 hover:text-gray-600 dark:hover:text-gray-300 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="M6 18L18 6M6 6l12 12" />
|
|
</svg>
|
|
</button>
|
|
</div>
|
|
|
|
{/* Version Info */}
|
|
<div className="space-y-3">
|
|
<div className="flex justify-between items-center p-3 bg-gray-50 dark:bg-gray-700/50 rounded-lg">
|
|
<span className="text-sm font-medium text-gray-700 dark:text-gray-300">Current Version</span>
|
|
<span className="text-sm text-gray-900 dark:text-white font-mono">{currentVersion}</span>
|
|
</div>
|
|
<div className="flex justify-between items-center p-3 bg-blue-50 dark:bg-blue-900/20 rounded-lg border border-blue-200 dark:border-blue-700">
|
|
<span className="text-sm font-medium text-blue-700 dark:text-blue-300">Latest Version</span>
|
|
<span className="text-sm text-blue-900 dark:text-blue-100 font-mono">{latestVersion}</span>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Changelog */}
|
|
{releaseInfo?.body && (
|
|
<div className="space-y-3">
|
|
<div className="flex items-center justify-between">
|
|
<h3 className="text-sm font-medium text-gray-900 dark:text-white">What's New:</h3>
|
|
{releaseInfo?.htmlUrl && (
|
|
<a
|
|
href={releaseInfo.htmlUrl}
|
|
target="_blank"
|
|
rel="noopener noreferrer"
|
|
className="text-xs text-blue-600 dark:text-blue-400 hover:text-blue-700 dark:hover:text-blue-300 hover:underline flex items-center gap-1"
|
|
>
|
|
View full release
|
|
<svg className="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14" />
|
|
</svg>
|
|
</a>
|
|
)}
|
|
</div>
|
|
<div className="bg-gray-50 dark:bg-gray-700/50 rounded-lg p-4 border border-gray-200 dark:border-gray-600 max-h-64 overflow-y-auto">
|
|
<div className="text-sm text-gray-700 dark:text-gray-300 whitespace-pre-wrap prose prose-sm dark:prose-invert max-w-none">
|
|
{cleanChangelog(releaseInfo.body)}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{/* Update Output */}
|
|
{updateOutput && (
|
|
<div className="space-y-2">
|
|
<h3 className="text-sm font-medium text-gray-900 dark:text-white">Update Progress:</h3>
|
|
<div className="bg-gray-900 dark:bg-gray-950 rounded-lg p-4 border border-gray-700 max-h-48 overflow-y-auto">
|
|
<pre className="text-xs text-green-400 font-mono whitespace-pre-wrap">{updateOutput}</pre>
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{/* Upgrade Instructions */}
|
|
{!isUpdating && !updateOutput && (
|
|
<div className="space-y-3">
|
|
<h3 className="text-sm font-medium text-gray-900 dark:text-white">Manual upgrade:</h3>
|
|
<div className="bg-gray-100 dark:bg-gray-800 rounded-lg p-3 border">
|
|
<code className="text-sm text-gray-800 dark:text-gray-200 font-mono">
|
|
git checkout main && git pull && npm install
|
|
</code>
|
|
</div>
|
|
<p className="text-xs text-gray-600 dark:text-gray-400">
|
|
Or click "Update Now" to run the update automatically.
|
|
</p>
|
|
</div>
|
|
)}
|
|
|
|
{/* Actions */}
|
|
<div className="flex gap-2 pt-2">
|
|
<button
|
|
onClick={() => setShowVersionModal(false)}
|
|
className="flex-1 px-4 py-2 text-sm font-medium text-gray-700 dark:text-gray-300 bg-gray-100 dark:bg-gray-700 hover:bg-gray-200 dark:hover:bg-gray-600 rounded-md transition-colors"
|
|
>
|
|
{updateOutput ? 'Close' : 'Later'}
|
|
</button>
|
|
{!updateOutput && (
|
|
<>
|
|
<button
|
|
onClick={() => {
|
|
navigator.clipboard.writeText('git checkout main && git pull && npm install');
|
|
}}
|
|
className="flex-1 px-4 py-2 text-sm font-medium text-gray-700 dark:text-gray-300 bg-gray-100 dark:bg-gray-700 hover:bg-gray-200 dark:hover:bg-gray-600 rounded-md transition-colors"
|
|
>
|
|
Copy Command
|
|
</button>
|
|
<button
|
|
onClick={handleUpdateNow}
|
|
disabled={isUpdating}
|
|
className="flex-1 px-4 py-2 text-sm font-medium text-white bg-blue-600 hover:bg-blue-700 disabled:bg-blue-400 disabled:cursor-not-allowed rounded-md transition-colors flex items-center justify-center gap-2"
|
|
>
|
|
{isUpdating ? (
|
|
<>
|
|
<div className="w-4 h-4 border-2 border-white border-t-transparent rounded-full animate-spin" />
|
|
Updating...
|
|
</>
|
|
) : (
|
|
'Update Now'
|
|
)}
|
|
</button>
|
|
</>
|
|
)}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
);
|
|
};
|
|
|
|
return (
|
|
<div className="fixed inset-0 flex bg-background">
|
|
{/* Fixed Desktop Sidebar */}
|
|
{!isMobile && (
|
|
<div
|
|
className={`flex-shrink-0 border-r border-border bg-card transition-all duration-300 ${
|
|
sidebarVisible ? 'w-80' : 'w-14'
|
|
}`}
|
|
>
|
|
<div className="h-full overflow-hidden">
|
|
{sidebarVisible ? (
|
|
<Sidebar
|
|
projects={projects}
|
|
selectedProject={selectedProject}
|
|
selectedSession={selectedSession}
|
|
onProjectSelect={handleProjectSelect}
|
|
onSessionSelect={handleSessionSelect}
|
|
onNewSession={handleNewSession}
|
|
onSessionDelete={handleSessionDelete}
|
|
onProjectDelete={handleProjectDelete}
|
|
isLoading={isLoadingProjects}
|
|
onRefresh={handleSidebarRefresh}
|
|
onShowSettings={() => setShowSettings(true)}
|
|
updateAvailable={updateAvailable}
|
|
latestVersion={latestVersion}
|
|
currentVersion={currentVersion}
|
|
releaseInfo={releaseInfo}
|
|
onShowVersionModal={() => setShowVersionModal(true)}
|
|
isPWA={isPWA}
|
|
isMobile={isMobile}
|
|
onToggleSidebar={() => setSidebarVisible(false)}
|
|
/>
|
|
) : (
|
|
/* Collapsed Sidebar */
|
|
<div className="h-full flex flex-col items-center py-4 gap-4">
|
|
{/* Expand Button */}
|
|
<button
|
|
onClick={() => setSidebarVisible(true)}
|
|
className="p-2 hover:bg-accent rounded-md transition-colors duration-200 group"
|
|
aria-label="Show sidebar"
|
|
title="Show sidebar"
|
|
>
|
|
<svg
|
|
className="w-5 h-5 text-foreground group-hover:scale-110 transition-transform"
|
|
fill="none"
|
|
stroke="currentColor"
|
|
viewBox="0 0 24 24"
|
|
>
|
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M13 5l7 7-7 7M5 5l7 7-7 7" />
|
|
</svg>
|
|
</button>
|
|
|
|
{/* Settings Icon */}
|
|
<button
|
|
onClick={() => setShowSettings(true)}
|
|
className="p-2 hover:bg-accent rounded-md transition-colors duration-200"
|
|
aria-label="Settings"
|
|
title="Settings"
|
|
>
|
|
<SettingsIcon className="w-5 h-5 text-muted-foreground hover:text-foreground transition-colors" />
|
|
</button>
|
|
|
|
{/* Update Indicator */}
|
|
{updateAvailable && (
|
|
<button
|
|
onClick={() => setShowVersionModal(true)}
|
|
className="relative p-2 hover:bg-accent rounded-md transition-colors duration-200"
|
|
aria-label="Update available"
|
|
title="Update available"
|
|
>
|
|
<Sparkles className="w-5 h-5 text-blue-500" />
|
|
<span className="absolute top-1 right-1 w-2 h-2 bg-blue-500 rounded-full animate-pulse" />
|
|
</button>
|
|
)}
|
|
</div>
|
|
)}
|
|
</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'
|
|
}`}>
|
|
<button
|
|
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);
|
|
}}
|
|
aria-label="Close sidebar"
|
|
/>
|
|
<div
|
|
className={`relative w-[85vw] max-w-sm sm:w-80 bg-card border-r border-border transform transition-transform duration-150 ease-out ${
|
|
sidebarOpen ? 'translate-x-0' : '-translate-x-full'
|
|
}`}
|
|
style={{ height: 'calc(100vh - 80px)' }}
|
|
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={() => setShowSettings(true)}
|
|
updateAvailable={updateAvailable}
|
|
latestVersion={latestVersion}
|
|
currentVersion={currentVersion}
|
|
releaseInfo={releaseInfo}
|
|
onShowVersionModal={() => setShowVersionModal(true)}
|
|
isPWA={isPWA}
|
|
isMobile={isMobile}
|
|
onToggleSidebar={() => setSidebarVisible(false)}
|
|
/>
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{/* Main Content Area - Flexible */}
|
|
<div className={`flex-1 flex flex-col min-w-0 ${isMobile && !isInputFocused ? 'pb-16' : ''}`}>
|
|
<MainContent
|
|
selectedProject={selectedProject}
|
|
selectedSession={selectedSession}
|
|
activeTab={activeTab}
|
|
setActiveTab={setActiveTab}
|
|
ws={ws}
|
|
sendMessage={sendMessage}
|
|
messages={messages}
|
|
isMobile={isMobile}
|
|
isPWA={isPWA}
|
|
onMenuClick={() => setSidebarOpen(true)}
|
|
isLoading={isLoadingProjects}
|
|
onInputFocusChange={setIsInputFocused}
|
|
onSessionActive={markSessionAsActive}
|
|
onSessionInactive={markSessionAsInactive}
|
|
onSessionProcessing={markSessionAsProcessing}
|
|
onSessionNotProcessing={markSessionAsNotProcessing}
|
|
processingSessions={processingSessions}
|
|
onReplaceTemporarySession={replaceTemporarySession}
|
|
onNavigateToSession={(sessionId) => navigate(`/session/${sessionId}`)}
|
|
onShowSettings={() => setShowSettings(true)}
|
|
autoExpandTools={autoExpandTools}
|
|
showRawParameters={showRawParameters}
|
|
showThinking={showThinking}
|
|
autoScrollToBottom={autoScrollToBottom}
|
|
sendByCtrlEnter={sendByCtrlEnter}
|
|
externalMessageUpdate={externalMessageUpdate}
|
|
/>
|
|
</div>
|
|
|
|
{/* Mobile Bottom Navigation */}
|
|
{isMobile && (
|
|
<MobileNav
|
|
activeTab={activeTab}
|
|
setActiveTab={setActiveTab}
|
|
isInputFocused={isInputFocused}
|
|
/>
|
|
)}
|
|
{/* Quick Settings Panel - Only show on chat tab */}
|
|
{activeTab === 'chat' && (
|
|
<QuickSettingsPanel
|
|
isOpen={showQuickSettings}
|
|
onToggle={setShowQuickSettings}
|
|
autoExpandTools={autoExpandTools}
|
|
onAutoExpandChange={setAutoExpandTools}
|
|
showRawParameters={showRawParameters}
|
|
onShowRawParametersChange={setShowRawParameters}
|
|
showThinking={showThinking}
|
|
onShowThinkingChange={setShowThinking}
|
|
autoScrollToBottom={autoScrollToBottom}
|
|
onAutoScrollChange={setAutoScrollToBottom}
|
|
sendByCtrlEnter={sendByCtrlEnter}
|
|
onSendByCtrlEnterChange={setSendByCtrlEnter}
|
|
isMobile={isMobile}
|
|
/>
|
|
)}
|
|
|
|
{/* Settings Modal */}
|
|
<Settings
|
|
isOpen={showSettings}
|
|
onClose={() => setShowSettings(false)}
|
|
projects={projects}
|
|
initialTab={settingsInitialTab}
|
|
/>
|
|
|
|
{/* Version Upgrade Modal */}
|
|
<VersionUpgradeModal />
|
|
</div>
|
|
);
|
|
}
|
|
|
|
// Root App component with router
|
|
function App() {
|
|
return (
|
|
<ThemeProvider>
|
|
<AuthProvider>
|
|
<WebSocketProvider>
|
|
<TasksSettingsProvider>
|
|
<TaskMasterProvider>
|
|
<ProtectedRoute>
|
|
<Router>
|
|
<Routes>
|
|
<Route path="/" element={<AppContent />} />
|
|
<Route path="/session/:sessionId" element={<AppContent />} />
|
|
</Routes>
|
|
</Router>
|
|
</ProtectedRoute>
|
|
</TaskMasterProvider>
|
|
</TasksSettingsProvider>
|
|
</WebSocketProvider>
|
|
</AuthProvider>
|
|
</ThemeProvider>
|
|
);
|
|
}
|
|
|
|
export default App; |