mirror of
https://github.com/siteboon/claudecodeui.git
synced 2026-06-02 18:45:34 +08:00
refactor(sidebar): extract typed app/sidebar architecture and split Sidebar into modular components
- Replace `src/App.jsx` with `src/App.tsx` and move route-level UI orchestration into `src/components/app/AppContent.tsx`. This separates provider/bootstrap concerns from runtime app layout logic, keeps route definitions minimal, and improves readability of the root app entry. - Introduce `src/hooks/useProjectsState.ts` to centralize project/session/sidebar state management previously embedded in `App.jsx`. This keeps the existing behavior for: project loading, Cursor session hydration, WebSocket `loading_progress` handling, additive-update protection for active sessions, URL-based session selection, sidebar refresh/delete/new-session flows. The hook now exposes a typed `sidebarSharedProps` contract and typed handlers used by `AppContent`. - Introduce `src/hooks/useSessionProtection.ts` for active/processing session lifecycle logic. This preserves session-protection behavior while isolating `activeSessions`, `processingSessions`, and temporary-session replacement into a dedicated reusable hook. - Replace monolithic `src/components/Sidebar.jsx` with typed `src/components/Sidebar.tsx` as a thin orchestrator. `Sidebar.tsx` now focuses on wiring controller state/actions, modal visibility, collapsed mode, and version modal behavior instead of rendering every UI branch inline. - Add `src/hooks/useSidebarController.ts` to encapsulate sidebar interaction/state logic. This includes expand/collapse state, inline project/session editing state, project starring/sorting/filtering, lazy session pagination, delete confirmations, rename/delete actions, refresh state, and mobile touch click handling. - Add strongly typed sidebar domain models in `src/components/sidebar/types.ts` and move sidebar-derived helpers into `src/components/sidebar/utils.ts`. Utility coverage now includes: session provider normalization, session view-model creation (name/time/activity/message count), project sorting/filtering, task indicator status derivation, starred-project persistence and readbacks. - Split sidebar rendering into focused components under `src/components/sidebar/`: `SidebarContent.tsx` for top-level sidebar layout composition. `SidebarProjectList.tsx` for project-state branching and project iteration. `SidebarProjectsState.tsx` for loading/empty/no-search-result placeholders. `SidebarProjectItem.tsx` for per-project desktop/mobile header rendering and actions. `SidebarProjectSessions.tsx` for expanded session area, skeletons, pagination, and new-session controls. `SidebarSessionItem.tsx` for per-session desktop/mobile item rendering and session actions. `SessionProviderIcon.tsx` for provider icon normalization. `SidebarHeader.tsx`, `SidebarFooter.tsx`, `SidebarCollapsed.tsx`, and `SidebarModals.tsx` as dedicated typed UI surfaces. This keeps rendering responsibilities local and significantly improves traceability. - Convert shared UI primitives from JSX to TSX: `src/components/ui/button.tsx`, `src/components/ui/input.tsx`, `src/components/ui/badge.tsx`, `src/components/ui/scroll-area.tsx`. These now provide typed props/variants (`forwardRef` where appropriate) while preserving existing class/behavior. - Add shared app typings in `src/types/app.ts` for projects/sessions/websocket/loading contracts used by new hooks/components. - Add global window declarations in `src/types/global.d.ts` for `__ROUTER_BASENAME__`, `refreshProjects`, and `openSettings`, removing implicit `any` usage for global integration points. - Update `src/main.jsx` to import `App.tsx` and keep app bootstrap consistent with the TS migration. - Update `src/components/QuickSettingsPanel.jsx` to self-resolve mobile state via `useDeviceSettings` (remove `isMobile` prop dependency), and update `src/components/ChatInterface.jsx` to render `QuickSettingsPanel` directly. This reduces prop drilling and keeps quick settings colocated with chat UI concerns.
This commit is contained in:
695
src/App.jsx
695
src/App.jsx
@@ -1,695 +0,0 @@
|
|||||||
/*
|
|
||||||
* 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, useRef, useMemo } 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 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, useWebSocket } from './contexts/WebSocketContext';
|
|
||||||
import ProtectedRoute from './components/ProtectedRoute';
|
|
||||||
import { useDeviceSettings } from './hooks/useDeviceSettings';
|
|
||||||
import { api, authenticatedFetch } from './utils/api';
|
|
||||||
import { I18nextProvider, useTranslation } from 'react-i18next';
|
|
||||||
import i18n from './i18n/config.js';
|
|
||||||
|
|
||||||
// TODO: Move to a separate file called AppContent.ts
|
|
||||||
// Main App component with routing
|
|
||||||
function AppContent() {
|
|
||||||
const navigate = useNavigate(); // used for navigation on project select
|
|
||||||
const { sessionId } = useParams();
|
|
||||||
const { t } = useTranslation('common');
|
|
||||||
// * This is a tracker for avoiding excessive re-renders during development
|
|
||||||
const renderCountRef = useRef(0);
|
|
||||||
// console.log(`AppContent render count: ${renderCountRef.current++}`);
|
|
||||||
|
|
||||||
// ! ESSENTIAL STATES
|
|
||||||
const [projects, setProjects] = useState([]);
|
|
||||||
// debugger;
|
|
||||||
// console.log('Projects state updated:', projects); // Debug log to track projects state changes
|
|
||||||
const [selectedProject, setSelectedProject] = useState(null);
|
|
||||||
const [selectedSession, setSelectedSession] = useState(null);
|
|
||||||
|
|
||||||
|
|
||||||
const [activeTab, setActiveTab] = useState('chat'); // 'chat' or 'files'
|
|
||||||
const { isMobile } = useDeviceSettings({ trackPWA: false });
|
|
||||||
const [sidebarOpen, setSidebarOpen] = useState(false);
|
|
||||||
const [isLoadingProjects, setIsLoadingProjects] = useState(true);
|
|
||||||
const [loadingProgress, setLoadingProgress] = useState(null); // { phase, current, total, currentProject }
|
|
||||||
const [isInputFocused, setIsInputFocused] = useState(false);
|
|
||||||
const [showSettings, setShowSettings] = useState(false);
|
|
||||||
const [settingsInitialTab, setSettingsInitialTab] = useState('agents');
|
|
||||||
// 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, latestMessage } = useWebSocket();
|
|
||||||
console.log('WebSocket latest message:', latestMessage); // Debug log to track WebSocket messages
|
|
||||||
// Ref to track loading progress timeout for cleanup
|
|
||||||
const loadingProgressTimeoutRef = useRef(null);
|
|
||||||
|
|
||||||
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 (latestMessage) {
|
|
||||||
// Handle loading progress updates
|
|
||||||
if (latestMessage.type === 'loading_progress') {
|
|
||||||
if (loadingProgressTimeoutRef.current) {
|
|
||||||
clearTimeout(loadingProgressTimeoutRef.current);
|
|
||||||
loadingProgressTimeoutRef.current = null;
|
|
||||||
}
|
|
||||||
setLoadingProgress(latestMessage);
|
|
||||||
if (latestMessage.phase === 'complete') {
|
|
||||||
loadingProgressTimeoutRef.current = setTimeout(() => {
|
|
||||||
setLoadingProgress(null);
|
|
||||||
loadingProgressTimeoutRef.current = null;
|
|
||||||
}, 500);
|
|
||||||
}
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
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 normalized = latestMessage.changedFile.replace(/\\/g, '/');
|
|
||||||
const changedFileParts = normalized.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;
|
|
||||||
console.log("====> latest message is: ", latestMessage);
|
|
||||||
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);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (selectedSession) {
|
|
||||||
const allSessions = [
|
|
||||||
...(updatedSelectedProject.sessions || []),
|
|
||||||
...(updatedSelectedProject.codexSessions || []),
|
|
||||||
...(updatedSelectedProject.cursorSessions || [])
|
|
||||||
];
|
|
||||||
const updatedSelectedSession = allSessions.find(s => s.id === selectedSession.id);
|
|
||||||
if (!updatedSelectedSession) {
|
|
||||||
setSelectedSession(null);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return () => {
|
|
||||||
if (loadingProgressTimeoutRef.current) {
|
|
||||||
clearTimeout(loadingProgressTimeoutRef.current);
|
|
||||||
loadingProgressTimeoutRef.current = null;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
}, [latestMessage, 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;
|
|
||||||
}
|
|
||||||
|
|
||||||
console.log("===> Prev projects: ", prevProjects);
|
|
||||||
|
|
||||||
// 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; // ! Exposing it globally is bad so we should use props for stuff like this.
|
|
||||||
|
|
||||||
// Expose openSettings function globally for component access
|
|
||||||
window.openSettings = useCallback((tab = 'tools') => {
|
|
||||||
setSettingsInitialTab(tab);
|
|
||||||
setShowSettings(true);
|
|
||||||
}, []); // ! Exposing it globally is bad so we should use props for stuff like this.
|
|
||||||
|
|
||||||
// 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]);
|
|
||||||
|
|
||||||
|
|
||||||
// TODO: All this functions should be in separate components
|
|
||||||
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;
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const handleShowSettings = useCallback(() => {
|
|
||||||
setShowSettings(true);
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const sidebarSharedProps = useMemo(() => ({
|
|
||||||
projects,
|
|
||||||
selectedProject,
|
|
||||||
selectedSession,
|
|
||||||
onProjectSelect: handleProjectSelect,
|
|
||||||
onSessionSelect: handleSessionSelect,
|
|
||||||
onNewSession: handleNewSession,
|
|
||||||
onSessionDelete: handleSessionDelete,
|
|
||||||
onProjectDelete: handleProjectDelete,
|
|
||||||
isLoading: isLoadingProjects,
|
|
||||||
loadingProgress,
|
|
||||||
onRefresh: handleSidebarRefresh,
|
|
||||||
onShowSettings: handleShowSettings,
|
|
||||||
isMobile
|
|
||||||
}), [
|
|
||||||
projects,
|
|
||||||
selectedProject,
|
|
||||||
selectedSession,
|
|
||||||
handleProjectSelect,
|
|
||||||
handleSessionSelect,
|
|
||||||
handleNewSession,
|
|
||||||
handleSessionDelete,
|
|
||||||
handleProjectDelete,
|
|
||||||
isLoadingProjects,
|
|
||||||
loadingProgress,
|
|
||||||
handleSidebarRefresh,
|
|
||||||
handleShowSettings,
|
|
||||||
isMobile
|
|
||||||
]);
|
|
||||||
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="fixed inset-0 flex bg-background">
|
|
||||||
{/* Fixed Desktop Sidebar */}
|
|
||||||
{!isMobile && (
|
|
||||||
<div className="h-full flex-shrink-0 border-r border-border bg-card">
|
|
||||||
<Sidebar
|
|
||||||
{...sidebarSharedProps}
|
|
||||||
/>
|
|
||||||
</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={t('versionUpdate.ariaLabels.closeSidebar')}
|
|
||||||
/>
|
|
||||||
<div
|
|
||||||
className={`relative w-[85vw] max-w-sm sm:w-80 h-full bg-card border-r border-border transform transition-transform duration-150 ease-out ${
|
|
||||||
sidebarOpen ? 'translate-x-0' : '-translate-x-full'
|
|
||||||
}`}
|
|
||||||
onClick={(e) => e.stopPropagation()}
|
|
||||||
onTouchStart={(e) => e.stopPropagation()}
|
|
||||||
>
|
|
||||||
<Sidebar
|
|
||||||
{...sidebarSharedProps}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Main Content Area - Flexible */}
|
|
||||||
<div className={`flex-1 flex flex-col min-w-0 ${isMobile && !isInputFocused ? 'pb-mobile-nav' : ''}`}>
|
|
||||||
<MainContent
|
|
||||||
selectedProject={selectedProject}
|
|
||||||
selectedSession={selectedSession}
|
|
||||||
activeTab={activeTab}
|
|
||||||
setActiveTab={setActiveTab}
|
|
||||||
ws={ws}
|
|
||||||
sendMessage={sendMessage}
|
|
||||||
latestMessage={latestMessage}
|
|
||||||
isMobile={isMobile}
|
|
||||||
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)}
|
|
||||||
|
|
||||||
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 isMobile={isMobile} />
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* // TODO: This should be in its own file. In modals/Settings.tsx */}
|
|
||||||
{/* // TODO: This should be in sidebar as well. */}
|
|
||||||
{/* Settings Modal */}
|
|
||||||
<Settings
|
|
||||||
isOpen={showSettings}
|
|
||||||
onClose={() => setShowSettings(false)}
|
|
||||||
projects={projects}
|
|
||||||
initialTab={settingsInitialTab}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Root App component with router
|
|
||||||
function App() {
|
|
||||||
return (
|
|
||||||
<I18nextProvider i18n={i18n}>
|
|
||||||
<ThemeProvider>
|
|
||||||
<AuthProvider>
|
|
||||||
<WebSocketProvider>
|
|
||||||
<TasksSettingsProvider>
|
|
||||||
<TaskMasterProvider>
|
|
||||||
<ProtectedRoute>
|
|
||||||
<Router basename={window.__ROUTER_BASENAME__ || ''}>
|
|
||||||
<Routes>
|
|
||||||
{/* // TODO: Can this be refactored to just have one route? */}
|
|
||||||
<Route path="/" element={<AppContent />} />
|
|
||||||
<Route path="/session/:sessionId" element={<AppContent />} />
|
|
||||||
</Routes>
|
|
||||||
</Router>
|
|
||||||
</ProtectedRoute>
|
|
||||||
</TaskMasterProvider>
|
|
||||||
</TasksSettingsProvider>
|
|
||||||
</WebSocketProvider>
|
|
||||||
</AuthProvider>
|
|
||||||
</ThemeProvider>
|
|
||||||
</I18nextProvider>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export default App;
|
|
||||||
35
src/App.tsx
Normal file
35
src/App.tsx
Normal file
@@ -0,0 +1,35 @@
|
|||||||
|
import { BrowserRouter as Router, Route, Routes } from 'react-router-dom';
|
||||||
|
import { I18nextProvider } from 'react-i18next';
|
||||||
|
import { ThemeProvider } from './contexts/ThemeContext';
|
||||||
|
import { AuthProvider } from './contexts/AuthContext';
|
||||||
|
import { TaskMasterProvider } from './contexts/TaskMasterContext';
|
||||||
|
import { TasksSettingsProvider } from './contexts/TasksSettingsContext';
|
||||||
|
import { WebSocketProvider } from './contexts/WebSocketContext';
|
||||||
|
import ProtectedRoute from './components/ProtectedRoute';
|
||||||
|
import AppContent from './components/app/AppContent';
|
||||||
|
import i18n from './i18n/config.js';
|
||||||
|
|
||||||
|
export default function App() {
|
||||||
|
return (
|
||||||
|
<I18nextProvider i18n={i18n}>
|
||||||
|
<ThemeProvider>
|
||||||
|
<AuthProvider>
|
||||||
|
<WebSocketProvider>
|
||||||
|
<TasksSettingsProvider>
|
||||||
|
<TaskMasterProvider>
|
||||||
|
<ProtectedRoute>
|
||||||
|
<Router basename={window.__ROUTER_BASENAME__ || ''}>
|
||||||
|
<Routes>
|
||||||
|
<Route path="/" element={<AppContent />} />
|
||||||
|
<Route path="/session/:sessionId" element={<AppContent />} />
|
||||||
|
</Routes>
|
||||||
|
</Router>
|
||||||
|
</ProtectedRoute>
|
||||||
|
</TaskMasterProvider>
|
||||||
|
</TasksSettingsProvider>
|
||||||
|
</WebSocketProvider>
|
||||||
|
</AuthProvider>
|
||||||
|
</ThemeProvider>
|
||||||
|
</I18nextProvider>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -29,6 +29,8 @@ import ClaudeLogo from './ClaudeLogo.jsx';
|
|||||||
import CursorLogo from './CursorLogo.jsx';
|
import CursorLogo from './CursorLogo.jsx';
|
||||||
import CodexLogo from './CodexLogo.jsx';
|
import CodexLogo from './CodexLogo.jsx';
|
||||||
import NextTaskBanner from './NextTaskBanner.jsx';
|
import NextTaskBanner from './NextTaskBanner.jsx';
|
||||||
|
import QuickSettingsPanel from './QuickSettingsPanel';
|
||||||
|
|
||||||
import { useTasksSettings } from '../contexts/TasksSettingsContext';
|
import { useTasksSettings } from '../contexts/TasksSettingsContext';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
|
|
||||||
@@ -5685,6 +5687,8 @@ function ChatInterface({ selectedProject, selectedSession, ws, sendMessage, late
|
|||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<QuickSettingsPanel />
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -22,8 +22,10 @@ import { useUiPreferences } from '../hooks/useUiPreferences';
|
|||||||
import { useTheme } from '../contexts/ThemeContext';
|
import { useTheme } from '../contexts/ThemeContext';
|
||||||
import LanguageSelector from './LanguageSelector';
|
import LanguageSelector from './LanguageSelector';
|
||||||
|
|
||||||
|
import { useDeviceSettings } from '../hooks/useDeviceSettings';
|
||||||
|
|
||||||
const QuickSettingsPanel = ({ isMobile }) => {
|
|
||||||
|
const QuickSettingsPanel = () => {
|
||||||
const { t } = useTranslation('settings');
|
const { t } = useTranslation('settings');
|
||||||
const [ isOpen, setIsOpen ] = useState(false);
|
const [ isOpen, setIsOpen ] = useState(false);
|
||||||
const [localIsOpen, setLocalIsOpen] = useState(false); // ! Is this necessary? Can we just use isOpen?
|
const [localIsOpen, setLocalIsOpen] = useState(false); // ! Is this necessary? Can we just use isOpen?
|
||||||
@@ -32,6 +34,8 @@ const QuickSettingsPanel = ({ isMobile }) => {
|
|||||||
});
|
});
|
||||||
const { isDarkMode } = useTheme();
|
const { isDarkMode } = useTheme();
|
||||||
|
|
||||||
|
const { isMobile } = useDeviceSettings({ trackPWA: false });
|
||||||
|
|
||||||
const { preferences, setPreference } = useUiPreferences();
|
const { preferences, setPreference } = useUiPreferences();
|
||||||
const { autoExpandTools, showRawParameters, showThinking, autoScrollToBottom, sendByCtrlEnter } = preferences;
|
const { autoExpandTools, showRawParameters, showThinking, autoScrollToBottom, sendByCtrlEnter } = preferences;
|
||||||
|
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
241
src/components/Sidebar.tsx
Normal file
241
src/components/Sidebar.tsx
Normal file
@@ -0,0 +1,241 @@
|
|||||||
|
import { useEffect } from 'react';
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
|
import VersionUpgradeModal from './modals/VersionUpgradeModal';
|
||||||
|
import { useDeviceSettings } from '../hooks/useDeviceSettings';
|
||||||
|
import { useVersionCheck } from '../hooks/useVersionCheck';
|
||||||
|
import { useUiPreferences } from '../hooks/useUiPreferences';
|
||||||
|
import { useSidebarController } from '../hooks/useSidebarController';
|
||||||
|
import { useTaskMaster } from '../contexts/TaskMasterContext';
|
||||||
|
import { useTasksSettings } from '../contexts/TasksSettingsContext';
|
||||||
|
import SidebarCollapsed from './sidebar/SidebarCollapsed';
|
||||||
|
import SidebarContent from './sidebar/SidebarContent';
|
||||||
|
import SidebarModals from './sidebar/SidebarModals';
|
||||||
|
import type { Project } from '../types/app';
|
||||||
|
import type { SidebarProjectListProps } from './sidebar/SidebarProjectList';
|
||||||
|
import type { MCPServerStatus, SidebarProps } from './sidebar/types';
|
||||||
|
|
||||||
|
type TaskMasterSidebarContext = {
|
||||||
|
setCurrentProject: (project: Project) => void;
|
||||||
|
mcpServerStatus: MCPServerStatus;
|
||||||
|
};
|
||||||
|
|
||||||
|
function Sidebar({
|
||||||
|
projects,
|
||||||
|
selectedProject,
|
||||||
|
selectedSession,
|
||||||
|
onProjectSelect,
|
||||||
|
onSessionSelect,
|
||||||
|
onNewSession,
|
||||||
|
onSessionDelete,
|
||||||
|
onProjectDelete,
|
||||||
|
isLoading,
|
||||||
|
loadingProgress,
|
||||||
|
onRefresh,
|
||||||
|
onShowSettings,
|
||||||
|
isMobile,
|
||||||
|
}: SidebarProps) {
|
||||||
|
const { t } = useTranslation(['sidebar', 'common']);
|
||||||
|
const { isPWA } = useDeviceSettings({ trackMobile: false });
|
||||||
|
const { updateAvailable, latestVersion, currentVersion, releaseInfo } = useVersionCheck(
|
||||||
|
'siteboon',
|
||||||
|
'claudecodeui',
|
||||||
|
);
|
||||||
|
const { preferences, setPreference } = useUiPreferences();
|
||||||
|
const { sidebarVisible } = preferences;
|
||||||
|
const { setCurrentProject, mcpServerStatus } = useTaskMaster() as TaskMasterSidebarContext;
|
||||||
|
const { tasksEnabled } = useTasksSettings();
|
||||||
|
|
||||||
|
const {
|
||||||
|
isSidebarCollapsed,
|
||||||
|
expandedProjects,
|
||||||
|
editingProject,
|
||||||
|
showNewProject,
|
||||||
|
editingName,
|
||||||
|
loadingSessions,
|
||||||
|
initialSessionsLoaded,
|
||||||
|
currentTime,
|
||||||
|
isRefreshing,
|
||||||
|
editingSession,
|
||||||
|
editingSessionName,
|
||||||
|
searchFilter,
|
||||||
|
deletingProjects,
|
||||||
|
deleteConfirmation,
|
||||||
|
sessionDeleteConfirmation,
|
||||||
|
showVersionModal,
|
||||||
|
filteredProjects,
|
||||||
|
handleTouchClick,
|
||||||
|
toggleProject,
|
||||||
|
handleSessionClick,
|
||||||
|
toggleStarProject,
|
||||||
|
isProjectStarred,
|
||||||
|
getProjectSessions,
|
||||||
|
startEditing,
|
||||||
|
cancelEditing,
|
||||||
|
saveProjectName,
|
||||||
|
showDeleteSessionConfirmation,
|
||||||
|
confirmDeleteSession,
|
||||||
|
requestProjectDelete,
|
||||||
|
confirmDeleteProject,
|
||||||
|
loadMoreSessions,
|
||||||
|
handleProjectSelect,
|
||||||
|
refreshProjects,
|
||||||
|
updateSessionSummary,
|
||||||
|
collapseSidebar: handleCollapseSidebar,
|
||||||
|
expandSidebar: handleExpandSidebar,
|
||||||
|
setShowNewProject,
|
||||||
|
setEditingName,
|
||||||
|
setEditingSession,
|
||||||
|
setEditingSessionName,
|
||||||
|
setSearchFilter,
|
||||||
|
setDeleteConfirmation,
|
||||||
|
setSessionDeleteConfirmation,
|
||||||
|
setShowVersionModal,
|
||||||
|
} = useSidebarController({
|
||||||
|
projects,
|
||||||
|
selectedProject,
|
||||||
|
selectedSession,
|
||||||
|
isLoading,
|
||||||
|
isMobile,
|
||||||
|
t,
|
||||||
|
onRefresh,
|
||||||
|
onProjectSelect,
|
||||||
|
onSessionSelect,
|
||||||
|
onSessionDelete,
|
||||||
|
onProjectDelete,
|
||||||
|
setCurrentProject,
|
||||||
|
setSidebarVisible: (visible) => setPreference('sidebarVisible', visible),
|
||||||
|
sidebarVisible,
|
||||||
|
});
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (typeof document === 'undefined') {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
document.documentElement.classList.toggle('pwa-mode', isPWA);
|
||||||
|
document.body.classList.toggle('pwa-mode', isPWA);
|
||||||
|
}, [isPWA]);
|
||||||
|
|
||||||
|
const handleProjectCreated = () => {
|
||||||
|
if (window.refreshProjects) {
|
||||||
|
void window.refreshProjects();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
window.location.reload();
|
||||||
|
};
|
||||||
|
|
||||||
|
const projectListProps: SidebarProjectListProps = {
|
||||||
|
projects,
|
||||||
|
filteredProjects,
|
||||||
|
selectedProject,
|
||||||
|
selectedSession,
|
||||||
|
isLoading,
|
||||||
|
loadingProgress,
|
||||||
|
expandedProjects,
|
||||||
|
editingProject,
|
||||||
|
editingName,
|
||||||
|
loadingSessions,
|
||||||
|
initialSessionsLoaded,
|
||||||
|
currentTime,
|
||||||
|
editingSession,
|
||||||
|
editingSessionName,
|
||||||
|
deletingProjects,
|
||||||
|
tasksEnabled,
|
||||||
|
mcpServerStatus,
|
||||||
|
getProjectSessions,
|
||||||
|
isProjectStarred,
|
||||||
|
onEditingNameChange: setEditingName,
|
||||||
|
onToggleProject: toggleProject,
|
||||||
|
onProjectSelect: handleProjectSelect,
|
||||||
|
onToggleStarProject: toggleStarProject,
|
||||||
|
onStartEditingProject: startEditing,
|
||||||
|
onCancelEditingProject: cancelEditing,
|
||||||
|
onSaveProjectName: (projectName) => {
|
||||||
|
void saveProjectName(projectName);
|
||||||
|
},
|
||||||
|
onDeleteProject: requestProjectDelete,
|
||||||
|
onSessionSelect: handleSessionClick,
|
||||||
|
onDeleteSession: showDeleteSessionConfirmation,
|
||||||
|
onLoadMoreSessions: (project) => {
|
||||||
|
void loadMoreSessions(project);
|
||||||
|
},
|
||||||
|
onNewSession,
|
||||||
|
onEditingSessionNameChange: setEditingSessionName,
|
||||||
|
onStartEditingSession: (sessionId, initialName) => {
|
||||||
|
setEditingSession(sessionId);
|
||||||
|
setEditingSessionName(initialName);
|
||||||
|
},
|
||||||
|
onCancelEditingSession: () => {
|
||||||
|
setEditingSession(null);
|
||||||
|
setEditingSessionName('');
|
||||||
|
},
|
||||||
|
onSaveEditingSession: (projectName, sessionId, summary) => {
|
||||||
|
void updateSessionSummary(projectName, sessionId, summary);
|
||||||
|
},
|
||||||
|
touchHandlerFactory: handleTouchClick,
|
||||||
|
t,
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{isSidebarCollapsed ? (
|
||||||
|
<SidebarCollapsed
|
||||||
|
onExpand={handleExpandSidebar}
|
||||||
|
onShowSettings={onShowSettings}
|
||||||
|
updateAvailable={updateAvailable}
|
||||||
|
onShowVersionModal={() => setShowVersionModal(true)}
|
||||||
|
t={t}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<SidebarModals
|
||||||
|
showNewProject={showNewProject}
|
||||||
|
onCloseNewProject={() => setShowNewProject(false)}
|
||||||
|
onProjectCreated={handleProjectCreated}
|
||||||
|
deleteConfirmation={deleteConfirmation}
|
||||||
|
onCancelDeleteProject={() => setDeleteConfirmation(null)}
|
||||||
|
onConfirmDeleteProject={confirmDeleteProject}
|
||||||
|
sessionDeleteConfirmation={sessionDeleteConfirmation}
|
||||||
|
onCancelDeleteSession={() => setSessionDeleteConfirmation(null)}
|
||||||
|
onConfirmDeleteSession={confirmDeleteSession}
|
||||||
|
t={t}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<SidebarContent
|
||||||
|
isPWA={isPWA}
|
||||||
|
isMobile={isMobile}
|
||||||
|
isLoading={isLoading}
|
||||||
|
projects={projects}
|
||||||
|
searchFilter={searchFilter}
|
||||||
|
onSearchFilterChange={setSearchFilter}
|
||||||
|
onClearSearchFilter={() => setSearchFilter('')}
|
||||||
|
onRefresh={() => {
|
||||||
|
void refreshProjects();
|
||||||
|
}}
|
||||||
|
isRefreshing={isRefreshing}
|
||||||
|
onCreateProject={() => setShowNewProject(true)}
|
||||||
|
onCollapseSidebar={handleCollapseSidebar}
|
||||||
|
updateAvailable={updateAvailable}
|
||||||
|
releaseInfo={releaseInfo}
|
||||||
|
latestVersion={latestVersion}
|
||||||
|
onShowVersionModal={() => setShowVersionModal(true)}
|
||||||
|
onShowSettings={onShowSettings}
|
||||||
|
projectListProps={projectListProps}
|
||||||
|
t={t}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<VersionUpgradeModal
|
||||||
|
isOpen={showVersionModal}
|
||||||
|
onClose={() => setShowVersionModal(false)}
|
||||||
|
releaseInfo={releaseInfo}
|
||||||
|
currentVersion={currentVersion}
|
||||||
|
latestVersion={latestVersion}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default Sidebar;
|
||||||
154
src/components/app/AppContent.tsx
Normal file
154
src/components/app/AppContent.tsx
Normal file
@@ -0,0 +1,154 @@
|
|||||||
|
import { useEffect } from 'react';
|
||||||
|
import { useNavigate, useParams } from 'react-router-dom';
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
|
|
||||||
|
import Sidebar from '../Sidebar';
|
||||||
|
import MainContent from '../MainContent';
|
||||||
|
import MobileNav from '../MobileNav';
|
||||||
|
import Settings from '../Settings';
|
||||||
|
|
||||||
|
import { useWebSocket } from '../../contexts/WebSocketContext';
|
||||||
|
import { useDeviceSettings } from '../../hooks/useDeviceSettings';
|
||||||
|
import { useSessionProtection } from '../../hooks/useSessionProtection';
|
||||||
|
import { useProjectsState } from '../../hooks/useProjectsState';
|
||||||
|
|
||||||
|
export default function AppContent() {
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const { sessionId } = useParams<{ sessionId?: string }>();
|
||||||
|
const { t } = useTranslation('common');
|
||||||
|
const { isMobile } = useDeviceSettings({ trackPWA: false });
|
||||||
|
const { ws, sendMessage, latestMessage } = useWebSocket();
|
||||||
|
|
||||||
|
const {
|
||||||
|
activeSessions,
|
||||||
|
processingSessions,
|
||||||
|
markSessionAsActive,
|
||||||
|
markSessionAsInactive,
|
||||||
|
markSessionAsProcessing,
|
||||||
|
markSessionAsNotProcessing,
|
||||||
|
replaceTemporarySession,
|
||||||
|
} = useSessionProtection();
|
||||||
|
|
||||||
|
const {
|
||||||
|
projects,
|
||||||
|
selectedProject,
|
||||||
|
selectedSession,
|
||||||
|
activeTab,
|
||||||
|
sidebarOpen,
|
||||||
|
isLoadingProjects,
|
||||||
|
isInputFocused,
|
||||||
|
showSettings,
|
||||||
|
settingsInitialTab,
|
||||||
|
externalMessageUpdate,
|
||||||
|
setActiveTab,
|
||||||
|
setSidebarOpen,
|
||||||
|
setIsInputFocused,
|
||||||
|
setShowSettings,
|
||||||
|
openSettings,
|
||||||
|
fetchProjects,
|
||||||
|
sidebarSharedProps,
|
||||||
|
} = useProjectsState({
|
||||||
|
sessionId,
|
||||||
|
navigate,
|
||||||
|
latestMessage,
|
||||||
|
isMobile,
|
||||||
|
activeSessions,
|
||||||
|
});
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
window.refreshProjects = fetchProjects;
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
if (window.refreshProjects === fetchProjects) {
|
||||||
|
delete window.refreshProjects;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}, [fetchProjects]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
window.openSettings = openSettings;
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
if (window.openSettings === openSettings) {
|
||||||
|
delete window.openSettings;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}, [openSettings]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="fixed inset-0 flex bg-background">
|
||||||
|
{!isMobile ? (
|
||||||
|
<div className="h-full flex-shrink-0 border-r border-border bg-card">
|
||||||
|
<Sidebar {...sidebarSharedProps} />
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<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={(event) => {
|
||||||
|
event.stopPropagation();
|
||||||
|
setSidebarOpen(false);
|
||||||
|
}}
|
||||||
|
onTouchStart={(event) => {
|
||||||
|
event.preventDefault();
|
||||||
|
event.stopPropagation();
|
||||||
|
setSidebarOpen(false);
|
||||||
|
}}
|
||||||
|
aria-label={t('versionUpdate.ariaLabels.closeSidebar')}
|
||||||
|
/>
|
||||||
|
<div
|
||||||
|
className={`relative w-[85vw] max-w-sm sm:w-80 h-full bg-card border-r border-border transform transition-transform duration-150 ease-out ${sidebarOpen ? 'translate-x-0' : '-translate-x-full'
|
||||||
|
}`}
|
||||||
|
onClick={(event) => event.stopPropagation()}
|
||||||
|
onTouchStart={(event) => event.stopPropagation()}
|
||||||
|
>
|
||||||
|
<Sidebar {...sidebarSharedProps} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className={`flex-1 flex flex-col min-w-0 ${isMobile && !isInputFocused ? 'pb-mobile-nav' : ''}`}>
|
||||||
|
<MainContent
|
||||||
|
selectedProject={selectedProject}
|
||||||
|
selectedSession={selectedSession}
|
||||||
|
activeTab={activeTab}
|
||||||
|
setActiveTab={setActiveTab}
|
||||||
|
ws={ws}
|
||||||
|
sendMessage={sendMessage}
|
||||||
|
latestMessage={latestMessage}
|
||||||
|
isMobile={isMobile}
|
||||||
|
onMenuClick={() => setSidebarOpen(true)}
|
||||||
|
isLoading={isLoadingProjects}
|
||||||
|
onInputFocusChange={setIsInputFocused}
|
||||||
|
onSessionActive={markSessionAsActive}
|
||||||
|
onSessionInactive={markSessionAsInactive}
|
||||||
|
onSessionProcessing={markSessionAsProcessing}
|
||||||
|
onSessionNotProcessing={markSessionAsNotProcessing}
|
||||||
|
processingSessions={processingSessions}
|
||||||
|
onReplaceTemporarySession={replaceTemporarySession}
|
||||||
|
onNavigateToSession={(targetSessionId: string) => navigate(`/session/${targetSessionId}`)}
|
||||||
|
onShowSettings={() => setShowSettings(true)}
|
||||||
|
externalMessageUpdate={externalMessageUpdate}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{isMobile && (
|
||||||
|
<MobileNav
|
||||||
|
activeTab={activeTab}
|
||||||
|
setActiveTab={setActiveTab}
|
||||||
|
isInputFocused={isInputFocused}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<Settings
|
||||||
|
isOpen={showSettings}
|
||||||
|
onClose={() => setShowSettings(false)}
|
||||||
|
projects={projects as any}
|
||||||
|
initialTab={settingsInitialTab}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
21
src/components/sidebar/SessionProviderIcon.tsx
Normal file
21
src/components/sidebar/SessionProviderIcon.tsx
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
import type { SessionProvider } from '../../types/app';
|
||||||
|
import ClaudeLogo from '../ClaudeLogo';
|
||||||
|
import CodexLogo from '../CodexLogo';
|
||||||
|
import CursorLogo from '../CursorLogo';
|
||||||
|
|
||||||
|
type SessionProviderIconProps = {
|
||||||
|
provider: SessionProvider;
|
||||||
|
className: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function SessionProviderIcon({ provider, className }: SessionProviderIconProps) {
|
||||||
|
if (provider === 'cursor') {
|
||||||
|
return <CursorLogo className={className} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (provider === 'codex') {
|
||||||
|
return <CodexLogo className={className} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
return <ClaudeLogo className={className} />;
|
||||||
|
}
|
||||||
59
src/components/sidebar/SidebarCollapsed.tsx
Normal file
59
src/components/sidebar/SidebarCollapsed.tsx
Normal file
@@ -0,0 +1,59 @@
|
|||||||
|
import { Settings, Sparkles } from 'lucide-react';
|
||||||
|
import type { TFunction } from 'i18next';
|
||||||
|
|
||||||
|
type SidebarCollapsedProps = {
|
||||||
|
onExpand: () => void;
|
||||||
|
onShowSettings: () => void;
|
||||||
|
updateAvailable: boolean;
|
||||||
|
onShowVersionModal: () => void;
|
||||||
|
t: TFunction;
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function SidebarCollapsed({
|
||||||
|
onExpand,
|
||||||
|
onShowSettings,
|
||||||
|
updateAvailable,
|
||||||
|
onShowVersionModal,
|
||||||
|
t,
|
||||||
|
}: SidebarCollapsedProps) {
|
||||||
|
return (
|
||||||
|
<div className="h-full flex flex-col items-center py-4 gap-4 bg-card">
|
||||||
|
<button
|
||||||
|
onClick={onExpand}
|
||||||
|
className="p-2 hover:bg-accent rounded-md transition-colors duration-200 group"
|
||||||
|
aria-label={t('common:versionUpdate.ariaLabels.showSidebar')}
|
||||||
|
title={t('common:versionUpdate.ariaLabels.showSidebar')}
|
||||||
|
>
|
||||||
|
<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>
|
||||||
|
|
||||||
|
<button
|
||||||
|
onClick={onShowSettings}
|
||||||
|
className="p-2 hover:bg-accent rounded-md transition-colors duration-200"
|
||||||
|
aria-label={t('actions.settings')}
|
||||||
|
title={t('actions.settings')}
|
||||||
|
>
|
||||||
|
<Settings className="w-5 h-5 text-muted-foreground hover:text-foreground transition-colors" />
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{updateAvailable && (
|
||||||
|
<button
|
||||||
|
onClick={onShowVersionModal}
|
||||||
|
className="relative p-2 hover:bg-accent rounded-md transition-colors duration-200"
|
||||||
|
aria-label={t('common:versionUpdate.ariaLabels.updateAvailable')}
|
||||||
|
title={t('common:versionUpdate.ariaLabels.updateAvailable')}
|
||||||
|
>
|
||||||
|
<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>
|
||||||
|
);
|
||||||
|
}
|
||||||
84
src/components/sidebar/SidebarContent.tsx
Normal file
84
src/components/sidebar/SidebarContent.tsx
Normal file
@@ -0,0 +1,84 @@
|
|||||||
|
import { ScrollArea } from '../ui/scroll-area';
|
||||||
|
import type { TFunction } from 'i18next';
|
||||||
|
import type { Project } from '../../types/app';
|
||||||
|
import type { ReleaseInfo } from '../../types/sharedTypes';
|
||||||
|
import SidebarFooter from './SidebarFooter';
|
||||||
|
import SidebarHeader from './SidebarHeader';
|
||||||
|
import SidebarProjectList, { type SidebarProjectListProps } from './SidebarProjectList';
|
||||||
|
|
||||||
|
type SidebarContentProps = {
|
||||||
|
isPWA: boolean;
|
||||||
|
isMobile: boolean;
|
||||||
|
isLoading: boolean;
|
||||||
|
projects: Project[];
|
||||||
|
searchFilter: string;
|
||||||
|
onSearchFilterChange: (value: string) => void;
|
||||||
|
onClearSearchFilter: () => void;
|
||||||
|
onRefresh: () => void;
|
||||||
|
isRefreshing: boolean;
|
||||||
|
onCreateProject: () => void;
|
||||||
|
onCollapseSidebar: () => void;
|
||||||
|
updateAvailable: boolean;
|
||||||
|
releaseInfo: ReleaseInfo | null;
|
||||||
|
latestVersion: string | null;
|
||||||
|
onShowVersionModal: () => void;
|
||||||
|
onShowSettings: () => void;
|
||||||
|
projectListProps: SidebarProjectListProps;
|
||||||
|
t: TFunction;
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function SidebarContent({
|
||||||
|
isPWA,
|
||||||
|
isMobile,
|
||||||
|
isLoading,
|
||||||
|
projects,
|
||||||
|
searchFilter,
|
||||||
|
onSearchFilterChange,
|
||||||
|
onClearSearchFilter,
|
||||||
|
onRefresh,
|
||||||
|
isRefreshing,
|
||||||
|
onCreateProject,
|
||||||
|
onCollapseSidebar,
|
||||||
|
updateAvailable,
|
||||||
|
releaseInfo,
|
||||||
|
latestVersion,
|
||||||
|
onShowVersionModal,
|
||||||
|
onShowSettings,
|
||||||
|
projectListProps,
|
||||||
|
t,
|
||||||
|
}: SidebarContentProps) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className="h-full flex flex-col bg-card md:select-none md:w-80"
|
||||||
|
style={isPWA && isMobile ? { paddingTop: '44px' } : {}}
|
||||||
|
>
|
||||||
|
<SidebarHeader
|
||||||
|
isPWA={isPWA}
|
||||||
|
isMobile={isMobile}
|
||||||
|
isLoading={isLoading}
|
||||||
|
projectsCount={projects.length}
|
||||||
|
searchFilter={searchFilter}
|
||||||
|
onSearchFilterChange={onSearchFilterChange}
|
||||||
|
onClearSearchFilter={onClearSearchFilter}
|
||||||
|
onRefresh={onRefresh}
|
||||||
|
isRefreshing={isRefreshing}
|
||||||
|
onCreateProject={onCreateProject}
|
||||||
|
onCollapseSidebar={onCollapseSidebar}
|
||||||
|
t={t}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<ScrollArea className="flex-1 md:px-2 md:py-3 overflow-y-auto overscroll-contain">
|
||||||
|
<SidebarProjectList {...projectListProps} />
|
||||||
|
</ScrollArea>
|
||||||
|
|
||||||
|
<SidebarFooter
|
||||||
|
updateAvailable={updateAvailable}
|
||||||
|
releaseInfo={releaseInfo}
|
||||||
|
latestVersion={latestVersion}
|
||||||
|
onShowVersionModal={onShowVersionModal}
|
||||||
|
onShowSettings={onShowSettings}
|
||||||
|
t={t}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
94
src/components/sidebar/SidebarFooter.tsx
Normal file
94
src/components/sidebar/SidebarFooter.tsx
Normal file
@@ -0,0 +1,94 @@
|
|||||||
|
import { Settings } from 'lucide-react';
|
||||||
|
import type { TFunction } from 'i18next';
|
||||||
|
import type { ReleaseInfo } from '../../types/sharedTypes';
|
||||||
|
import { Button } from '../ui/button';
|
||||||
|
|
||||||
|
type SidebarFooterProps = {
|
||||||
|
updateAvailable: boolean;
|
||||||
|
releaseInfo: ReleaseInfo | null;
|
||||||
|
latestVersion: string | null;
|
||||||
|
onShowVersionModal: () => void;
|
||||||
|
onShowSettings: () => void;
|
||||||
|
t: TFunction;
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function SidebarFooter({
|
||||||
|
updateAvailable,
|
||||||
|
releaseInfo,
|
||||||
|
latestVersion,
|
||||||
|
onShowVersionModal,
|
||||||
|
onShowSettings,
|
||||||
|
t,
|
||||||
|
}: SidebarFooterProps) {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{updateAvailable && (
|
||||||
|
<div className="md:p-2 border-t border-border/50 flex-shrink-0">
|
||||||
|
<div className="hidden md:block">
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
className="w-full justify-start gap-3 p-3 h-auto font-normal text-left hover:bg-blue-50 dark:hover:bg-blue-900/20 transition-colors duration-200 border border-blue-200 dark:border-blue-700 rounded-lg mb-2"
|
||||||
|
onClick={onShowVersionModal}
|
||||||
|
>
|
||||||
|
<div className="relative">
|
||||||
|
<svg className="w-4 h-4 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 className="absolute -top-1 -right-1 w-2 h-2 bg-blue-500 rounded-full animate-pulse" />
|
||||||
|
</div>
|
||||||
|
<div className="min-w-0 flex-1">
|
||||||
|
<div className="text-sm font-medium text-blue-700 dark:text-blue-300">
|
||||||
|
{releaseInfo?.title || `Version ${latestVersion}`}
|
||||||
|
</div>
|
||||||
|
<div className="text-xs text-blue-600 dark:text-blue-400">{t('version.updateAvailable')}</div>
|
||||||
|
</div>
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="md:hidden p-3 pb-2">
|
||||||
|
<button
|
||||||
|
className="w-full h-12 bg-blue-50 dark:bg-blue-900/20 border border-blue-200 dark:border-blue-700 rounded-xl flex items-center justify-start gap-3 px-4 active:scale-[0.98] transition-all duration-150"
|
||||||
|
onClick={onShowVersionModal}
|
||||||
|
>
|
||||||
|
<div className="relative">
|
||||||
|
<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 className="absolute -top-1 -right-1 w-2 h-2 bg-blue-500 rounded-full animate-pulse" />
|
||||||
|
</div>
|
||||||
|
<div className="min-w-0 flex-1 text-left">
|
||||||
|
<div className="text-sm font-medium text-blue-700 dark:text-blue-300">
|
||||||
|
{releaseInfo?.title || `Version ${latestVersion}`}
|
||||||
|
</div>
|
||||||
|
<div className="text-xs text-blue-600 dark:text-blue-400">{t('version.updateAvailable')}</div>
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="md:p-2 md:border-t md:border-border flex-shrink-0">
|
||||||
|
<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">{t('actions.settings')}</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<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">{t('actions.settings')}</span>
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
187
src/components/sidebar/SidebarHeader.tsx
Normal file
187
src/components/sidebar/SidebarHeader.tsx
Normal file
@@ -0,0 +1,187 @@
|
|||||||
|
import { FolderPlus, MessageSquare, RefreshCw, Search, X } from 'lucide-react';
|
||||||
|
import type { TFunction } from 'i18next';
|
||||||
|
import { Button } from '../ui/button';
|
||||||
|
import { Input } from '../ui/input';
|
||||||
|
import { IS_PLATFORM } from '../../constants/config';
|
||||||
|
|
||||||
|
type SidebarHeaderProps = {
|
||||||
|
isPWA: boolean;
|
||||||
|
isMobile: boolean;
|
||||||
|
isLoading: boolean;
|
||||||
|
projectsCount: number;
|
||||||
|
searchFilter: string;
|
||||||
|
onSearchFilterChange: (value: string) => void;
|
||||||
|
onClearSearchFilter: () => void;
|
||||||
|
onRefresh: () => void;
|
||||||
|
isRefreshing: boolean;
|
||||||
|
onCreateProject: () => void;
|
||||||
|
onCollapseSidebar: () => void;
|
||||||
|
t: TFunction;
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function SidebarHeader({
|
||||||
|
isPWA,
|
||||||
|
isMobile,
|
||||||
|
isLoading,
|
||||||
|
projectsCount,
|
||||||
|
searchFilter,
|
||||||
|
onSearchFilterChange,
|
||||||
|
onClearSearchFilter,
|
||||||
|
onRefresh,
|
||||||
|
isRefreshing,
|
||||||
|
onCreateProject,
|
||||||
|
onCollapseSidebar,
|
||||||
|
t,
|
||||||
|
}: SidebarHeaderProps) {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<div
|
||||||
|
className="md:p-4 md:border-b md:border-border"
|
||||||
|
style={isPWA && isMobile ? { paddingTop: '44px' } : {}}
|
||||||
|
>
|
||||||
|
<div className="hidden md:flex items-center justify-between">
|
||||||
|
{IS_PLATFORM ? (
|
||||||
|
<a
|
||||||
|
href="https://cloudcli.ai/dashboard"
|
||||||
|
className="flex items-center gap-3 hover:opacity-80 transition-opacity group"
|
||||||
|
title={t('tooltips.viewEnvironments')}
|
||||||
|
>
|
||||||
|
<div className="w-8 h-8 bg-primary rounded-lg flex items-center justify-center shadow-sm group-hover:shadow-md transition-shadow">
|
||||||
|
<MessageSquare className="w-4 h-4 text-primary-foreground" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h1 className="text-lg font-bold text-foreground">{t('app.title')}</h1>
|
||||||
|
<p className="text-sm text-muted-foreground">{t('app.subtitle')}</p>
|
||||||
|
</div>
|
||||||
|
</a>
|
||||||
|
) : (
|
||||||
|
<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">{t('app.title')}</h1>
|
||||||
|
<p className="text-sm text-muted-foreground">{t('app.subtitle')}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
className="h-8 w-8 px-0 hover:bg-accent transition-colors duration-200"
|
||||||
|
onClick={onCollapseSidebar}
|
||||||
|
title={t('tooltips.hideSidebar')}
|
||||||
|
>
|
||||||
|
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 19l-7-7 7-7" />
|
||||||
|
</svg>
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div
|
||||||
|
className="md:hidden p-3 border-b border-border"
|
||||||
|
style={isPWA && isMobile ? { paddingTop: '16px' } : {}}
|
||||||
|
>
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
{IS_PLATFORM ? (
|
||||||
|
<a
|
||||||
|
href="https://cloudcli.ai/dashboard"
|
||||||
|
className="flex items-center gap-3 active:opacity-70 transition-opacity"
|
||||||
|
title={t('tooltips.viewEnvironments')}
|
||||||
|
>
|
||||||
|
<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">{t('app.title')}</h1>
|
||||||
|
<p className="text-sm text-muted-foreground">{t('projects.title')}</p>
|
||||||
|
</div>
|
||||||
|
</a>
|
||||||
|
) : (
|
||||||
|
<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">{t('app.title')}</h1>
|
||||||
|
<p className="text-sm text-muted-foreground">{t('projects.title')}</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={onRefresh}
|
||||||
|
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={onCreateProject}
|
||||||
|
>
|
||||||
|
<FolderPlus className="w-4 h-4" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{!isLoading && !isMobile && (
|
||||||
|
<div className="px-3 md:px-4 py-2 border-b border-border">
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<Button
|
||||||
|
variant="default"
|
||||||
|
size="sm"
|
||||||
|
className="flex-1 h-8 text-xs bg-primary hover:bg-primary/90 transition-all duration-200"
|
||||||
|
onClick={onCreateProject}
|
||||||
|
title={t('tooltips.createProject')}
|
||||||
|
>
|
||||||
|
<FolderPlus className="w-3.5 h-3.5 mr-1.5" />
|
||||||
|
{t('projects.newProject')}
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
className="h-8 w-8 px-0 hover:bg-accent transition-colors duration-200 group"
|
||||||
|
onClick={onRefresh}
|
||||||
|
disabled={isRefreshing}
|
||||||
|
title={t('tooltips.refresh')}
|
||||||
|
>
|
||||||
|
<RefreshCw
|
||||||
|
className={`w-3.5 h-3.5 ${
|
||||||
|
isRefreshing ? 'animate-spin' : 'group-hover:rotate-180 transition-transform duration-300'
|
||||||
|
}`}
|
||||||
|
/>
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{projectsCount > 0 && !isLoading && (
|
||||||
|
<div className="px-3 md:px-4 py-2 border-b border-border">
|
||||||
|
<div className="relative">
|
||||||
|
<Search className="absolute left-3 top-1/2 transform -translate-y-1/2 w-4 h-4 text-muted-foreground" />
|
||||||
|
<Input
|
||||||
|
type="text"
|
||||||
|
placeholder={t('projects.searchPlaceholder')}
|
||||||
|
value={searchFilter}
|
||||||
|
onChange={(event) => onSearchFilterChange(event.target.value)}
|
||||||
|
className="pl-9 h-9 text-sm bg-muted/50 border-0 focus:bg-background focus:ring-1 focus:ring-primary/20"
|
||||||
|
/>
|
||||||
|
{searchFilter && (
|
||||||
|
<button
|
||||||
|
onClick={onClearSearchFilter}
|
||||||
|
className="absolute right-2 top-1/2 transform -translate-y-1/2 p-1 hover:bg-accent rounded"
|
||||||
|
>
|
||||||
|
<X className="w-3 h-3 text-muted-foreground" />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
143
src/components/sidebar/SidebarModals.tsx
Normal file
143
src/components/sidebar/SidebarModals.tsx
Normal file
@@ -0,0 +1,143 @@
|
|||||||
|
import ReactDOM from 'react-dom';
|
||||||
|
import { AlertTriangle, Trash2 } from 'lucide-react';
|
||||||
|
import type { TFunction } from 'i18next';
|
||||||
|
import { Button } from '../ui/button';
|
||||||
|
import ProjectCreationWizard from '../ProjectCreationWizard';
|
||||||
|
import type { DeleteProjectConfirmation, SessionDeleteConfirmation } from './types';
|
||||||
|
|
||||||
|
type SidebarModalsProps = {
|
||||||
|
showNewProject: boolean;
|
||||||
|
onCloseNewProject: () => void;
|
||||||
|
onProjectCreated: () => void;
|
||||||
|
deleteConfirmation: DeleteProjectConfirmation | null;
|
||||||
|
onCancelDeleteProject: () => void;
|
||||||
|
onConfirmDeleteProject: () => void;
|
||||||
|
sessionDeleteConfirmation: SessionDeleteConfirmation | null;
|
||||||
|
onCancelDeleteSession: () => void;
|
||||||
|
onConfirmDeleteSession: () => void;
|
||||||
|
t: TFunction;
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function SidebarModals({
|
||||||
|
showNewProject,
|
||||||
|
onCloseNewProject,
|
||||||
|
onProjectCreated,
|
||||||
|
deleteConfirmation,
|
||||||
|
onCancelDeleteProject,
|
||||||
|
onConfirmDeleteProject,
|
||||||
|
sessionDeleteConfirmation,
|
||||||
|
onCancelDeleteSession,
|
||||||
|
onConfirmDeleteSession,
|
||||||
|
t,
|
||||||
|
}: SidebarModalsProps) {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{showNewProject &&
|
||||||
|
ReactDOM.createPortal(
|
||||||
|
<ProjectCreationWizard
|
||||||
|
onClose={onCloseNewProject}
|
||||||
|
onProjectCreated={onProjectCreated}
|
||||||
|
/>,
|
||||||
|
document.body,
|
||||||
|
)}
|
||||||
|
|
||||||
|
{deleteConfirmation &&
|
||||||
|
ReactDOM.createPortal(
|
||||||
|
<div className="fixed inset-0 bg-black/60 backdrop-blur-sm flex items-center justify-center z-50 p-4">
|
||||||
|
<div className="bg-card border border-border rounded-xl shadow-2xl max-w-md w-full overflow-hidden">
|
||||||
|
<div className="p-6">
|
||||||
|
<div className="flex items-start gap-4">
|
||||||
|
<div className="w-12 h-12 rounded-full bg-red-100 dark:bg-red-900/30 flex items-center justify-center flex-shrink-0">
|
||||||
|
<AlertTriangle className="w-6 h-6 text-red-600 dark:text-red-400" />
|
||||||
|
</div>
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<h3 className="text-lg font-semibold text-foreground mb-2">
|
||||||
|
{t('deleteConfirmation.deleteProject')}
|
||||||
|
</h3>
|
||||||
|
<p className="text-sm text-muted-foreground mb-1">
|
||||||
|
{t('deleteConfirmation.confirmDelete')}{' '}
|
||||||
|
<span className="font-medium text-foreground">
|
||||||
|
{deleteConfirmation.project.displayName || deleteConfirmation.project.name}
|
||||||
|
</span>
|
||||||
|
?
|
||||||
|
</p>
|
||||||
|
{deleteConfirmation.sessionCount > 0 && (
|
||||||
|
<div className="mt-3 p-3 bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800 rounded-lg">
|
||||||
|
<p className="text-sm text-red-700 dark:text-red-300 font-medium">
|
||||||
|
{t('deleteConfirmation.sessionCount', { count: deleteConfirmation.sessionCount })}
|
||||||
|
</p>
|
||||||
|
<p className="text-xs text-red-600 dark:text-red-400 mt-1">
|
||||||
|
{t('deleteConfirmation.allConversationsDeleted')}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<p className="text-xs text-muted-foreground mt-3">
|
||||||
|
{t('deleteConfirmation.cannotUndo')}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex gap-3 p-4 bg-muted/30 border-t border-border">
|
||||||
|
<Button variant="outline" className="flex-1" onClick={onCancelDeleteProject}>
|
||||||
|
{t('actions.cancel')}
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="destructive"
|
||||||
|
className="flex-1 bg-red-600 hover:bg-red-700 text-white"
|
||||||
|
onClick={onConfirmDeleteProject}
|
||||||
|
>
|
||||||
|
<Trash2 className="w-4 h-4 mr-2" />
|
||||||
|
{t('actions.delete')}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>,
|
||||||
|
document.body,
|
||||||
|
)}
|
||||||
|
|
||||||
|
{sessionDeleteConfirmation &&
|
||||||
|
ReactDOM.createPortal(
|
||||||
|
<div className="fixed inset-0 bg-black/60 backdrop-blur-sm flex items-center justify-center z-50 p-4">
|
||||||
|
<div className="bg-card border border-border rounded-xl shadow-2xl max-w-md w-full overflow-hidden">
|
||||||
|
<div className="p-6">
|
||||||
|
<div className="flex items-start gap-4">
|
||||||
|
<div className="w-12 h-12 rounded-full bg-red-100 dark:bg-red-900/30 flex items-center justify-center flex-shrink-0">
|
||||||
|
<AlertTriangle className="w-6 h-6 text-red-600 dark:text-red-400" />
|
||||||
|
</div>
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<h3 className="text-lg font-semibold text-foreground mb-2">
|
||||||
|
{t('deleteConfirmation.deleteSession')}
|
||||||
|
</h3>
|
||||||
|
<p className="text-sm text-muted-foreground mb-1">
|
||||||
|
{t('deleteConfirmation.confirmDelete')}{' '}
|
||||||
|
<span className="font-medium text-foreground">
|
||||||
|
{sessionDeleteConfirmation.sessionTitle || t('sessions.unnamed')}
|
||||||
|
</span>
|
||||||
|
?
|
||||||
|
</p>
|
||||||
|
<p className="text-xs text-muted-foreground mt-3">
|
||||||
|
{t('deleteConfirmation.cannotUndo')}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex gap-3 p-4 bg-muted/30 border-t border-border">
|
||||||
|
<Button variant="outline" className="flex-1" onClick={onCancelDeleteSession}>
|
||||||
|
{t('actions.cancel')}
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="destructive"
|
||||||
|
className="flex-1 bg-red-600 hover:bg-red-700 text-white"
|
||||||
|
onClick={onConfirmDeleteSession}
|
||||||
|
>
|
||||||
|
<Trash2 className="w-4 h-4 mr-2" />
|
||||||
|
{t('actions.delete')}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>,
|
||||||
|
document.body,
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
437
src/components/sidebar/SidebarProjectItem.tsx
Normal file
437
src/components/sidebar/SidebarProjectItem.tsx
Normal file
@@ -0,0 +1,437 @@
|
|||||||
|
import { Button } from '../ui/button';
|
||||||
|
import { Check, ChevronDown, ChevronRight, Edit3, Folder, FolderOpen, Star, Trash2, X } from 'lucide-react';
|
||||||
|
import type { TFunction } from 'i18next';
|
||||||
|
import { cn } from '../../lib/utils';
|
||||||
|
import TaskIndicator from '../TaskIndicator';
|
||||||
|
import type { Project, ProjectSession, SessionProvider } from '../../types/app';
|
||||||
|
import type { MCPServerStatus, SessionWithProvider, TouchHandlerFactory } from './types';
|
||||||
|
import { getTaskIndicatorStatus } from './utils';
|
||||||
|
import SidebarProjectSessions from './SidebarProjectSessions';
|
||||||
|
|
||||||
|
type SidebarProjectItemProps = {
|
||||||
|
project: Project;
|
||||||
|
selectedProject: Project | null;
|
||||||
|
selectedSession: ProjectSession | null;
|
||||||
|
isExpanded: boolean;
|
||||||
|
isDeleting: boolean;
|
||||||
|
isStarred: boolean;
|
||||||
|
editingProject: string | null;
|
||||||
|
editingName: string;
|
||||||
|
sessions: SessionWithProvider[];
|
||||||
|
initialSessionsLoaded: boolean;
|
||||||
|
isLoadingSessions: boolean;
|
||||||
|
currentTime: Date;
|
||||||
|
editingSession: string | null;
|
||||||
|
editingSessionName: string;
|
||||||
|
tasksEnabled: boolean;
|
||||||
|
mcpServerStatus: MCPServerStatus;
|
||||||
|
onEditingNameChange: (name: string) => void;
|
||||||
|
onToggleProject: (projectName: string) => void;
|
||||||
|
onProjectSelect: (project: Project) => void;
|
||||||
|
onToggleStarProject: (projectName: string) => void;
|
||||||
|
onStartEditingProject: (project: Project) => void;
|
||||||
|
onCancelEditingProject: () => void;
|
||||||
|
onSaveProjectName: (projectName: string) => void;
|
||||||
|
onDeleteProject: (project: Project) => void;
|
||||||
|
onSessionSelect: (session: SessionWithProvider, projectName: string) => void;
|
||||||
|
onDeleteSession: (
|
||||||
|
projectName: string,
|
||||||
|
sessionId: string,
|
||||||
|
sessionTitle: string,
|
||||||
|
provider: SessionProvider,
|
||||||
|
) => void;
|
||||||
|
onLoadMoreSessions: (project: Project) => void;
|
||||||
|
onNewSession: (project: Project) => void;
|
||||||
|
onEditingSessionNameChange: (value: string) => void;
|
||||||
|
onStartEditingSession: (sessionId: string, initialName: string) => void;
|
||||||
|
onCancelEditingSession: () => void;
|
||||||
|
onSaveEditingSession: (projectName: string, sessionId: string, summary: string) => void;
|
||||||
|
touchHandlerFactory: TouchHandlerFactory;
|
||||||
|
t: TFunction;
|
||||||
|
};
|
||||||
|
|
||||||
|
const getSessionCountDisplay = (sessions: SessionWithProvider[], hasMoreSessions: boolean): string => {
|
||||||
|
const sessionCount = sessions.length;
|
||||||
|
if (hasMoreSessions && sessionCount >= 5) {
|
||||||
|
return `${sessionCount}+`;
|
||||||
|
}
|
||||||
|
|
||||||
|
return `${sessionCount}`;
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function SidebarProjectItem({
|
||||||
|
project,
|
||||||
|
selectedProject,
|
||||||
|
selectedSession,
|
||||||
|
isExpanded,
|
||||||
|
isDeleting,
|
||||||
|
isStarred,
|
||||||
|
editingProject,
|
||||||
|
editingName,
|
||||||
|
sessions,
|
||||||
|
initialSessionsLoaded,
|
||||||
|
isLoadingSessions,
|
||||||
|
currentTime,
|
||||||
|
editingSession,
|
||||||
|
editingSessionName,
|
||||||
|
tasksEnabled,
|
||||||
|
mcpServerStatus,
|
||||||
|
onEditingNameChange,
|
||||||
|
onToggleProject,
|
||||||
|
onProjectSelect,
|
||||||
|
onToggleStarProject,
|
||||||
|
onStartEditingProject,
|
||||||
|
onCancelEditingProject,
|
||||||
|
onSaveProjectName,
|
||||||
|
onDeleteProject,
|
||||||
|
onSessionSelect,
|
||||||
|
onDeleteSession,
|
||||||
|
onLoadMoreSessions,
|
||||||
|
onNewSession,
|
||||||
|
onEditingSessionNameChange,
|
||||||
|
onStartEditingSession,
|
||||||
|
onCancelEditingSession,
|
||||||
|
onSaveEditingSession,
|
||||||
|
touchHandlerFactory,
|
||||||
|
t,
|
||||||
|
}: SidebarProjectItemProps) {
|
||||||
|
const isSelected = selectedProject?.name === project.name;
|
||||||
|
const isEditing = editingProject === project.name;
|
||||||
|
const hasMoreSessions = project.sessionMeta?.hasMore !== false;
|
||||||
|
const sessionCountDisplay = getSessionCountDisplay(sessions, hasMoreSessions);
|
||||||
|
const sessionCountLabel = `${sessionCountDisplay} session${sessions.length === 1 ? '' : 's'}`;
|
||||||
|
const taskStatus = getTaskIndicatorStatus(project, mcpServerStatus);
|
||||||
|
|
||||||
|
const toggleProject = () => onToggleProject(project.name);
|
||||||
|
const toggleStarProject = () => onToggleStarProject(project.name);
|
||||||
|
|
||||||
|
const saveProjectName = () => {
|
||||||
|
onSaveProjectName(project.name);
|
||||||
|
};
|
||||||
|
|
||||||
|
const selectAndToggleProject = () => {
|
||||||
|
if (selectedProject?.name !== project.name) {
|
||||||
|
onProjectSelect(project);
|
||||||
|
}
|
||||||
|
|
||||||
|
toggleProject();
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={cn('md:space-y-1', isDeleting && 'opacity-50 pointer-events-none')}>
|
||||||
|
<div className="group md:group">
|
||||||
|
<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',
|
||||||
|
isStarred &&
|
||||||
|
!isSelected &&
|
||||||
|
'bg-yellow-50/50 dark:bg-yellow-900/5 border-yellow-200/30 dark:border-yellow-800/30',
|
||||||
|
)}
|
||||||
|
onClick={toggleProject}
|
||||||
|
onTouchEnd={touchHandlerFactory(toggleProject)}
|
||||||
|
>
|
||||||
|
<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">
|
||||||
|
{isEditing ? (
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={editingName}
|
||||||
|
onChange={(event) => onEditingNameChange(event.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={t('projects.projectNamePlaceholder')}
|
||||||
|
autoFocus
|
||||||
|
autoComplete="off"
|
||||||
|
onClick={(event) => event.stopPropagation()}
|
||||||
|
onKeyDown={(event) => {
|
||||||
|
if (event.key === 'Enter') {
|
||||||
|
saveProjectName();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (event.key === 'Escape') {
|
||||||
|
onCancelEditingProject();
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
style={{
|
||||||
|
fontSize: '16px',
|
||||||
|
WebkitAppearance: 'none',
|
||||||
|
borderRadius: '8px',
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<div className="flex items-center justify-between min-w-0 flex-1">
|
||||||
|
<h3 className="text-sm font-medium text-foreground truncate">{project.displayName}</h3>
|
||||||
|
{tasksEnabled && (
|
||||||
|
<TaskIndicator
|
||||||
|
status={taskStatus}
|
||||||
|
size="xs"
|
||||||
|
className="hidden md:inline-flex flex-shrink-0 ml-2"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<p className="text-xs text-muted-foreground">{sessionCountLabel}</p>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center gap-1">
|
||||||
|
{isEditing ? (
|
||||||
|
<>
|
||||||
|
<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={(event) => {
|
||||||
|
event.stopPropagation();
|
||||||
|
saveProjectName();
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<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={(event) => {
|
||||||
|
event.stopPropagation();
|
||||||
|
onCancelEditingProject();
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<X className="w-4 h-4 text-white" />
|
||||||
|
</button>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<button
|
||||||
|
className={cn(
|
||||||
|
'w-8 h-8 rounded-lg flex items-center justify-center active:scale-90 transition-all duration-150 border',
|
||||||
|
isStarred
|
||||||
|
? 'bg-yellow-500/10 dark:bg-yellow-900/30 border-yellow-200 dark:border-yellow-800'
|
||||||
|
: 'bg-gray-500/10 dark:bg-gray-900/30 border-gray-200 dark:border-gray-800',
|
||||||
|
)}
|
||||||
|
onClick={(event) => {
|
||||||
|
event.stopPropagation();
|
||||||
|
toggleStarProject();
|
||||||
|
}}
|
||||||
|
onTouchEnd={touchHandlerFactory(toggleStarProject)}
|
||||||
|
title={isStarred ? t('tooltips.removeFromFavorites') : t('tooltips.addToFavorites')}
|
||||||
|
>
|
||||||
|
<Star
|
||||||
|
className={cn(
|
||||||
|
'w-4 h-4 transition-colors',
|
||||||
|
isStarred
|
||||||
|
? 'text-yellow-600 dark:text-yellow-400 fill-current'
|
||||||
|
: 'text-gray-600 dark:text-gray-400',
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<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 border border-red-200 dark:border-red-800"
|
||||||
|
onClick={(event) => {
|
||||||
|
event.stopPropagation();
|
||||||
|
onDeleteProject(project);
|
||||||
|
}}
|
||||||
|
onTouchEnd={touchHandlerFactory(() => onDeleteProject(project))}
|
||||||
|
>
|
||||||
|
<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 border border-primary/20 dark:border-primary/30"
|
||||||
|
onClick={(event) => {
|
||||||
|
event.stopPropagation();
|
||||||
|
onStartEditingProject(project);
|
||||||
|
}}
|
||||||
|
onTouchEnd={touchHandlerFactory(() => onStartEditingProject(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>
|
||||||
|
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
className={cn(
|
||||||
|
'hidden md:flex w-full justify-between p-2 h-auto font-normal hover:bg-accent/50',
|
||||||
|
isSelected && 'bg-accent text-accent-foreground',
|
||||||
|
isStarred &&
|
||||||
|
!isSelected &&
|
||||||
|
'bg-yellow-50/50 dark:bg-yellow-900/10 hover:bg-yellow-100/50 dark:hover:bg-yellow-900/20',
|
||||||
|
)}
|
||||||
|
onClick={selectAndToggleProject}
|
||||||
|
onTouchEnd={touchHandlerFactory(selectAndToggleProject)}
|
||||||
|
>
|
||||||
|
<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">
|
||||||
|
{isEditing ? (
|
||||||
|
<div className="space-y-1">
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={editingName}
|
||||||
|
onChange={(event) => onEditingNameChange(event.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={t('projects.projectNamePlaceholder')}
|
||||||
|
autoFocus
|
||||||
|
onKeyDown={(event) => {
|
||||||
|
if (event.key === 'Enter') {
|
||||||
|
saveProjectName();
|
||||||
|
}
|
||||||
|
if (event.key === 'Escape') {
|
||||||
|
onCancelEditingProject();
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<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">
|
||||||
|
{sessionCountDisplay}
|
||||||
|
{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">
|
||||||
|
{isEditing ? (
|
||||||
|
<>
|
||||||
|
<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={(event) => {
|
||||||
|
event.stopPropagation();
|
||||||
|
saveProjectName();
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<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={(event) => {
|
||||||
|
event.stopPropagation();
|
||||||
|
onCancelEditingProject();
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<X className="w-3 h-3" />
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
'w-6 h-6 opacity-0 group-hover:opacity-100 transition-all duration-200 flex items-center justify-center rounded cursor-pointer touch:opacity-100',
|
||||||
|
isStarred ? 'hover:bg-yellow-50 dark:hover:bg-yellow-900/20 opacity-100' : 'hover:bg-accent',
|
||||||
|
)}
|
||||||
|
onClick={(event) => {
|
||||||
|
event.stopPropagation();
|
||||||
|
toggleStarProject();
|
||||||
|
}}
|
||||||
|
title={isStarred ? t('tooltips.removeFromFavorites') : t('tooltips.addToFavorites')}
|
||||||
|
>
|
||||||
|
<Star
|
||||||
|
className={cn(
|
||||||
|
'w-3 h-3 transition-colors',
|
||||||
|
isStarred
|
||||||
|
? 'text-yellow-600 dark:text-yellow-400 fill-current'
|
||||||
|
: 'text-muted-foreground',
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</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={(event) => {
|
||||||
|
event.stopPropagation();
|
||||||
|
onStartEditingProject(project);
|
||||||
|
}}
|
||||||
|
title={t('tooltips.renameProject')}
|
||||||
|
>
|
||||||
|
<Edit3 className="w-3 h-3" />
|
||||||
|
</div>
|
||||||
|
<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={(event) => {
|
||||||
|
event.stopPropagation();
|
||||||
|
onDeleteProject(project);
|
||||||
|
}}
|
||||||
|
title={t('tooltips.deleteProject')}
|
||||||
|
>
|
||||||
|
<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>
|
||||||
|
|
||||||
|
<SidebarProjectSessions
|
||||||
|
project={project}
|
||||||
|
isExpanded={isExpanded}
|
||||||
|
sessions={sessions}
|
||||||
|
selectedSession={selectedSession}
|
||||||
|
initialSessionsLoaded={initialSessionsLoaded}
|
||||||
|
isLoadingSessions={isLoadingSessions}
|
||||||
|
currentTime={currentTime}
|
||||||
|
editingSession={editingSession}
|
||||||
|
editingSessionName={editingSessionName}
|
||||||
|
onEditingSessionNameChange={onEditingSessionNameChange}
|
||||||
|
onStartEditingSession={onStartEditingSession}
|
||||||
|
onCancelEditingSession={onCancelEditingSession}
|
||||||
|
onSaveEditingSession={onSaveEditingSession}
|
||||||
|
onProjectSelect={onProjectSelect}
|
||||||
|
onSessionSelect={onSessionSelect}
|
||||||
|
onDeleteSession={onDeleteSession}
|
||||||
|
onLoadMoreSessions={onLoadMoreSessions}
|
||||||
|
onNewSession={onNewSession}
|
||||||
|
touchHandlerFactory={touchHandlerFactory}
|
||||||
|
t={t}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
153
src/components/sidebar/SidebarProjectList.tsx
Normal file
153
src/components/sidebar/SidebarProjectList.tsx
Normal file
@@ -0,0 +1,153 @@
|
|||||||
|
import type { TFunction } from 'i18next';
|
||||||
|
import type { LoadingProgress, Project, ProjectSession, SessionProvider } from '../../types/app';
|
||||||
|
import type {
|
||||||
|
LoadingSessionsByProject,
|
||||||
|
MCPServerStatus,
|
||||||
|
SessionWithProvider,
|
||||||
|
TouchHandlerFactory,
|
||||||
|
} from './types';
|
||||||
|
import SidebarProjectItem from './SidebarProjectItem';
|
||||||
|
import SidebarProjectsState from './SidebarProjectsState';
|
||||||
|
|
||||||
|
export type SidebarProjectListProps = {
|
||||||
|
projects: Project[];
|
||||||
|
filteredProjects: Project[];
|
||||||
|
selectedProject: Project | null;
|
||||||
|
selectedSession: ProjectSession | null;
|
||||||
|
isLoading: boolean;
|
||||||
|
loadingProgress: LoadingProgress | null;
|
||||||
|
expandedProjects: Set<string>;
|
||||||
|
editingProject: string | null;
|
||||||
|
editingName: string;
|
||||||
|
loadingSessions: LoadingSessionsByProject;
|
||||||
|
initialSessionsLoaded: Set<string>;
|
||||||
|
currentTime: Date;
|
||||||
|
editingSession: string | null;
|
||||||
|
editingSessionName: string;
|
||||||
|
deletingProjects: Set<string>;
|
||||||
|
tasksEnabled: boolean;
|
||||||
|
mcpServerStatus: MCPServerStatus;
|
||||||
|
getProjectSessions: (project: Project) => SessionWithProvider[];
|
||||||
|
isProjectStarred: (projectName: string) => boolean;
|
||||||
|
onEditingNameChange: (value: string) => void;
|
||||||
|
onToggleProject: (projectName: string) => void;
|
||||||
|
onProjectSelect: (project: Project) => void;
|
||||||
|
onToggleStarProject: (projectName: string) => void;
|
||||||
|
onStartEditingProject: (project: Project) => void;
|
||||||
|
onCancelEditingProject: () => void;
|
||||||
|
onSaveProjectName: (projectName: string) => void;
|
||||||
|
onDeleteProject: (project: Project) => void;
|
||||||
|
onSessionSelect: (session: SessionWithProvider, projectName: string) => void;
|
||||||
|
onDeleteSession: (
|
||||||
|
projectName: string,
|
||||||
|
sessionId: string,
|
||||||
|
sessionTitle: string,
|
||||||
|
provider: SessionProvider,
|
||||||
|
) => void;
|
||||||
|
onLoadMoreSessions: (project: Project) => void;
|
||||||
|
onNewSession: (project: Project) => void;
|
||||||
|
onEditingSessionNameChange: (value: string) => void;
|
||||||
|
onStartEditingSession: (sessionId: string, initialName: string) => void;
|
||||||
|
onCancelEditingSession: () => void;
|
||||||
|
onSaveEditingSession: (projectName: string, sessionId: string, summary: string) => void;
|
||||||
|
touchHandlerFactory: TouchHandlerFactory;
|
||||||
|
t: TFunction;
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function SidebarProjectList({
|
||||||
|
projects,
|
||||||
|
filteredProjects,
|
||||||
|
selectedProject,
|
||||||
|
selectedSession,
|
||||||
|
isLoading,
|
||||||
|
loadingProgress,
|
||||||
|
expandedProjects,
|
||||||
|
editingProject,
|
||||||
|
editingName,
|
||||||
|
loadingSessions,
|
||||||
|
initialSessionsLoaded,
|
||||||
|
currentTime,
|
||||||
|
editingSession,
|
||||||
|
editingSessionName,
|
||||||
|
deletingProjects,
|
||||||
|
tasksEnabled,
|
||||||
|
mcpServerStatus,
|
||||||
|
getProjectSessions,
|
||||||
|
isProjectStarred,
|
||||||
|
onEditingNameChange,
|
||||||
|
onToggleProject,
|
||||||
|
onProjectSelect,
|
||||||
|
onToggleStarProject,
|
||||||
|
onStartEditingProject,
|
||||||
|
onCancelEditingProject,
|
||||||
|
onSaveProjectName,
|
||||||
|
onDeleteProject,
|
||||||
|
onSessionSelect,
|
||||||
|
onDeleteSession,
|
||||||
|
onLoadMoreSessions,
|
||||||
|
onNewSession,
|
||||||
|
onEditingSessionNameChange,
|
||||||
|
onStartEditingSession,
|
||||||
|
onCancelEditingSession,
|
||||||
|
onSaveEditingSession,
|
||||||
|
touchHandlerFactory,
|
||||||
|
t,
|
||||||
|
}: SidebarProjectListProps) {
|
||||||
|
const state = (
|
||||||
|
<SidebarProjectsState
|
||||||
|
isLoading={isLoading}
|
||||||
|
loadingProgress={loadingProgress}
|
||||||
|
projectsCount={projects.length}
|
||||||
|
filteredProjectsCount={filteredProjects.length}
|
||||||
|
t={t}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
|
||||||
|
const showProjects = !isLoading && projects.length > 0 && filteredProjects.length > 0;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="md:space-y-1 pb-safe-area-inset-bottom">
|
||||||
|
{!showProjects
|
||||||
|
? state
|
||||||
|
: filteredProjects.map((project) => (
|
||||||
|
<SidebarProjectItem
|
||||||
|
key={project.name}
|
||||||
|
project={project}
|
||||||
|
selectedProject={selectedProject}
|
||||||
|
selectedSession={selectedSession}
|
||||||
|
isExpanded={expandedProjects.has(project.name)}
|
||||||
|
isDeleting={deletingProjects.has(project.name)}
|
||||||
|
isStarred={isProjectStarred(project.name)}
|
||||||
|
editingProject={editingProject}
|
||||||
|
editingName={editingName}
|
||||||
|
sessions={getProjectSessions(project)}
|
||||||
|
initialSessionsLoaded={initialSessionsLoaded.has(project.name)}
|
||||||
|
isLoadingSessions={Boolean(loadingSessions[project.name])}
|
||||||
|
currentTime={currentTime}
|
||||||
|
editingSession={editingSession}
|
||||||
|
editingSessionName={editingSessionName}
|
||||||
|
tasksEnabled={tasksEnabled}
|
||||||
|
mcpServerStatus={mcpServerStatus}
|
||||||
|
onEditingNameChange={onEditingNameChange}
|
||||||
|
onToggleProject={onToggleProject}
|
||||||
|
onProjectSelect={onProjectSelect}
|
||||||
|
onToggleStarProject={onToggleStarProject}
|
||||||
|
onStartEditingProject={onStartEditingProject}
|
||||||
|
onCancelEditingProject={onCancelEditingProject}
|
||||||
|
onSaveProjectName={onSaveProjectName}
|
||||||
|
onDeleteProject={onDeleteProject}
|
||||||
|
onSessionSelect={onSessionSelect}
|
||||||
|
onDeleteSession={onDeleteSession}
|
||||||
|
onLoadMoreSessions={onLoadMoreSessions}
|
||||||
|
onNewSession={onNewSession}
|
||||||
|
onEditingSessionNameChange={onEditingSessionNameChange}
|
||||||
|
onStartEditingSession={onStartEditingSession}
|
||||||
|
onCancelEditingSession={onCancelEditingSession}
|
||||||
|
onSaveEditingSession={onSaveEditingSession}
|
||||||
|
touchHandlerFactory={touchHandlerFactory}
|
||||||
|
t={t}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
160
src/components/sidebar/SidebarProjectSessions.tsx
Normal file
160
src/components/sidebar/SidebarProjectSessions.tsx
Normal file
@@ -0,0 +1,160 @@
|
|||||||
|
import { ChevronDown, Plus } from 'lucide-react';
|
||||||
|
import type { TFunction } from 'i18next';
|
||||||
|
import { Button } from '../ui/button';
|
||||||
|
import type { Project, ProjectSession, SessionProvider } from '../../types/app';
|
||||||
|
import type { SessionWithProvider, TouchHandlerFactory } from './types';
|
||||||
|
import SidebarSessionItem from './SidebarSessionItem';
|
||||||
|
|
||||||
|
type SidebarProjectSessionsProps = {
|
||||||
|
project: Project;
|
||||||
|
isExpanded: boolean;
|
||||||
|
sessions: SessionWithProvider[];
|
||||||
|
selectedSession: ProjectSession | null;
|
||||||
|
initialSessionsLoaded: boolean;
|
||||||
|
isLoadingSessions: boolean;
|
||||||
|
currentTime: Date;
|
||||||
|
editingSession: string | null;
|
||||||
|
editingSessionName: string;
|
||||||
|
onEditingSessionNameChange: (value: string) => void;
|
||||||
|
onStartEditingSession: (sessionId: string, initialName: string) => void;
|
||||||
|
onCancelEditingSession: () => void;
|
||||||
|
onSaveEditingSession: (projectName: string, sessionId: string, summary: string) => void;
|
||||||
|
onProjectSelect: (project: Project) => void;
|
||||||
|
onSessionSelect: (session: SessionWithProvider, projectName: string) => void;
|
||||||
|
onDeleteSession: (
|
||||||
|
projectName: string,
|
||||||
|
sessionId: string,
|
||||||
|
sessionTitle: string,
|
||||||
|
provider: SessionProvider,
|
||||||
|
) => void;
|
||||||
|
onLoadMoreSessions: (project: Project) => void;
|
||||||
|
onNewSession: (project: Project) => void;
|
||||||
|
touchHandlerFactory: TouchHandlerFactory;
|
||||||
|
t: TFunction;
|
||||||
|
};
|
||||||
|
|
||||||
|
function SessionListSkeleton() {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{Array.from({ length: 3 }).map((_, index) => (
|
||||||
|
<div key={index} 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 + index * 15}%` }} />
|
||||||
|
<div className="h-2 bg-muted rounded animate-pulse w-1/2" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function SidebarProjectSessions({
|
||||||
|
project,
|
||||||
|
isExpanded,
|
||||||
|
sessions,
|
||||||
|
selectedSession,
|
||||||
|
initialSessionsLoaded,
|
||||||
|
isLoadingSessions,
|
||||||
|
currentTime,
|
||||||
|
editingSession,
|
||||||
|
editingSessionName,
|
||||||
|
onEditingSessionNameChange,
|
||||||
|
onStartEditingSession,
|
||||||
|
onCancelEditingSession,
|
||||||
|
onSaveEditingSession,
|
||||||
|
onProjectSelect,
|
||||||
|
onSessionSelect,
|
||||||
|
onDeleteSession,
|
||||||
|
onLoadMoreSessions,
|
||||||
|
onNewSession,
|
||||||
|
touchHandlerFactory,
|
||||||
|
t,
|
||||||
|
}: SidebarProjectSessionsProps) {
|
||||||
|
if (!isExpanded) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const hasSessions = sessions.length > 0;
|
||||||
|
const hasMoreSessions = project.sessionMeta?.hasMore !== false;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="ml-3 space-y-1 border-l border-border pl-3">
|
||||||
|
{!initialSessionsLoaded ? (
|
||||||
|
<SessionListSkeleton />
|
||||||
|
) : !hasSessions && !isLoadingSessions ? (
|
||||||
|
<div className="py-2 px-3 text-left">
|
||||||
|
<p className="text-xs text-muted-foreground">{t('sessions.noSessions')}</p>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
sessions.map((session) => (
|
||||||
|
<SidebarSessionItem
|
||||||
|
key={session.id}
|
||||||
|
project={project}
|
||||||
|
session={session}
|
||||||
|
selectedSession={selectedSession}
|
||||||
|
currentTime={currentTime}
|
||||||
|
editingSession={editingSession}
|
||||||
|
editingSessionName={editingSessionName}
|
||||||
|
onEditingSessionNameChange={onEditingSessionNameChange}
|
||||||
|
onStartEditingSession={onStartEditingSession}
|
||||||
|
onCancelEditingSession={onCancelEditingSession}
|
||||||
|
onSaveEditingSession={onSaveEditingSession}
|
||||||
|
onProjectSelect={onProjectSelect}
|
||||||
|
onSessionSelect={onSessionSelect}
|
||||||
|
onDeleteSession={onDeleteSession}
|
||||||
|
touchHandlerFactory={touchHandlerFactory}
|
||||||
|
t={t}
|
||||||
|
/>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
|
||||||
|
{hasSessions && hasMoreSessions && (
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
className="w-full justify-center gap-2 mt-2 text-muted-foreground"
|
||||||
|
onClick={() => onLoadMoreSessions(project)}
|
||||||
|
disabled={isLoadingSessions}
|
||||||
|
>
|
||||||
|
{isLoadingSessions ? (
|
||||||
|
<>
|
||||||
|
<div className="w-3 h-3 animate-spin rounded-full border border-muted-foreground border-t-transparent" />
|
||||||
|
{t('sessions.loading')}
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<ChevronDown className="w-3 h-3" />
|
||||||
|
{t('sessions.showMore')}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</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" />
|
||||||
|
{t('sessions.newSession')}
|
||||||
|
</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" />
|
||||||
|
{t('sessions.newSession')}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
81
src/components/sidebar/SidebarProjectsState.tsx
Normal file
81
src/components/sidebar/SidebarProjectsState.tsx
Normal file
@@ -0,0 +1,81 @@
|
|||||||
|
import { Folder, Search } from 'lucide-react';
|
||||||
|
import type { TFunction } from 'i18next';
|
||||||
|
import type { LoadingProgress } from '../../types/app';
|
||||||
|
|
||||||
|
type SidebarProjectsStateProps = {
|
||||||
|
isLoading: boolean;
|
||||||
|
loadingProgress: LoadingProgress | null;
|
||||||
|
projectsCount: number;
|
||||||
|
filteredProjectsCount: number;
|
||||||
|
t: TFunction;
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function SidebarProjectsState({
|
||||||
|
isLoading,
|
||||||
|
loadingProgress,
|
||||||
|
projectsCount,
|
||||||
|
filteredProjectsCount,
|
||||||
|
t,
|
||||||
|
}: SidebarProjectsStateProps) {
|
||||||
|
if (isLoading) {
|
||||||
|
return (
|
||||||
|
<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">{t('projects.loadingProjects')}</h3>
|
||||||
|
<p className="text-sm text-muted-foreground">{t('projects.fetchingProjects')}</p>
|
||||||
|
<h3 className="text-base font-medium text-foreground mb-2 md:mb-1">{t('projects.loadingProjects')}</h3>
|
||||||
|
{loadingProgress && loadingProgress.total > 0 ? (
|
||||||
|
<div className="space-y-2">
|
||||||
|
<div className="w-full bg-muted rounded-full h-2 overflow-hidden">
|
||||||
|
<div
|
||||||
|
className="bg-primary h-full transition-all duration-300 ease-out"
|
||||||
|
style={{ width: `${(loadingProgress.current / loadingProgress.total) * 100}%` }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
{loadingProgress.current}/{loadingProgress.total} {t('projects.projects')}
|
||||||
|
</p>
|
||||||
|
{loadingProgress.currentProject && (
|
||||||
|
<p
|
||||||
|
className="text-xs text-muted-foreground/70 truncate max-w-[200px] mx-auto"
|
||||||
|
title={loadingProgress.currentProject}
|
||||||
|
>
|
||||||
|
{loadingProgress.currentProject.split('-').slice(-2).join('/')}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<p className="text-sm text-muted-foreground">{t('projects.fetchingProjects')}</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (projectsCount === 0) {
|
||||||
|
return (
|
||||||
|
<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">{t('projects.noProjects')}</h3>
|
||||||
|
<p className="text-sm text-muted-foreground">{t('projects.runClaudeCli')}</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (filteredProjectsCount === 0) {
|
||||||
|
return (
|
||||||
|
<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">
|
||||||
|
<Search className="w-6 h-6 text-muted-foreground" />
|
||||||
|
</div>
|
||||||
|
<h3 className="text-base font-medium text-foreground mb-2 md:mb-1">{t('projects.noMatchingProjects')}</h3>
|
||||||
|
<p className="text-sm text-muted-foreground">{t('projects.tryDifferentSearch')}</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
239
src/components/sidebar/SidebarSessionItem.tsx
Normal file
239
src/components/sidebar/SidebarSessionItem.tsx
Normal file
@@ -0,0 +1,239 @@
|
|||||||
|
import { Badge } from '../ui/badge';
|
||||||
|
import { Button } from '../ui/button';
|
||||||
|
import { Check, Clock, Edit2, Trash2, X } from 'lucide-react';
|
||||||
|
import type { TFunction } from 'i18next';
|
||||||
|
import { cn } from '../../lib/utils';
|
||||||
|
import { formatTimeAgo } from '../../utils/dateUtils';
|
||||||
|
import type { Project, ProjectSession, SessionProvider } from '../../types/app';
|
||||||
|
import type { SessionWithProvider, TouchHandlerFactory } from './types';
|
||||||
|
import { createSessionViewModel } from './utils';
|
||||||
|
import SessionProviderIcon from './SessionProviderIcon';
|
||||||
|
|
||||||
|
type SidebarSessionItemProps = {
|
||||||
|
project: Project;
|
||||||
|
session: SessionWithProvider;
|
||||||
|
selectedSession: ProjectSession | null;
|
||||||
|
currentTime: Date;
|
||||||
|
editingSession: string | null;
|
||||||
|
editingSessionName: string;
|
||||||
|
onEditingSessionNameChange: (value: string) => void;
|
||||||
|
onStartEditingSession: (sessionId: string, initialName: string) => void;
|
||||||
|
onCancelEditingSession: () => void;
|
||||||
|
onSaveEditingSession: (projectName: string, sessionId: string, summary: string) => void;
|
||||||
|
onProjectSelect: (project: Project) => void;
|
||||||
|
onSessionSelect: (session: SessionWithProvider, projectName: string) => void;
|
||||||
|
onDeleteSession: (
|
||||||
|
projectName: string,
|
||||||
|
sessionId: string,
|
||||||
|
sessionTitle: string,
|
||||||
|
provider: SessionProvider,
|
||||||
|
) => void;
|
||||||
|
touchHandlerFactory: TouchHandlerFactory;
|
||||||
|
t: TFunction;
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function SidebarSessionItem({
|
||||||
|
project,
|
||||||
|
session,
|
||||||
|
selectedSession,
|
||||||
|
currentTime,
|
||||||
|
editingSession,
|
||||||
|
editingSessionName,
|
||||||
|
onEditingSessionNameChange,
|
||||||
|
onStartEditingSession,
|
||||||
|
onCancelEditingSession,
|
||||||
|
onSaveEditingSession,
|
||||||
|
onProjectSelect,
|
||||||
|
onSessionSelect,
|
||||||
|
onDeleteSession,
|
||||||
|
touchHandlerFactory,
|
||||||
|
t,
|
||||||
|
}: SidebarSessionItemProps) {
|
||||||
|
const sessionView = createSessionViewModel(session, currentTime, t);
|
||||||
|
const isSelected = selectedSession?.id === session.id;
|
||||||
|
|
||||||
|
const selectMobileSession = () => {
|
||||||
|
onProjectSelect(project);
|
||||||
|
onSessionSelect(session, project.name);
|
||||||
|
};
|
||||||
|
|
||||||
|
const saveEditedSession = () => {
|
||||||
|
onSaveEditingSession(project.name, session.id, editingSessionName);
|
||||||
|
};
|
||||||
|
|
||||||
|
const requestDeleteSession = () => {
|
||||||
|
onDeleteSession(project.name, session.id, sessionView.sessionName, session.__provider);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="group relative">
|
||||||
|
{sessionView.isActive && (
|
||||||
|
<div className="absolute left-0 top-1/2 transform -translate-y-1/2 -translate-x-1">
|
||||||
|
<div className="w-2 h-2 bg-green-500 rounded-full animate-pulse" />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="md:hidden">
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
'p-2 mx-3 my-0.5 rounded-md bg-card border active:scale-[0.98] transition-all duration-150 relative',
|
||||||
|
isSelected ? 'bg-primary/5 border-primary/20' : '',
|
||||||
|
!isSelected && sessionView.isActive
|
||||||
|
? 'border-green-500/30 bg-green-50/5 dark:bg-green-900/5'
|
||||||
|
: 'border-border/30',
|
||||||
|
)}
|
||||||
|
onClick={selectMobileSession}
|
||||||
|
onTouchEnd={touchHandlerFactory(selectMobileSession)}
|
||||||
|
>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
'w-5 h-5 rounded-md flex items-center justify-center flex-shrink-0',
|
||||||
|
isSelected ? 'bg-primary/10' : 'bg-muted/50',
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<SessionProviderIcon provider={session.__provider} className="w-3 h-3" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="min-w-0 flex-1">
|
||||||
|
<div className="text-xs font-medium truncate text-foreground">{sessionView.sessionName}</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(sessionView.sessionTime, currentTime, t)}
|
||||||
|
</span>
|
||||||
|
{sessionView.messageCount > 0 && (
|
||||||
|
<Badge variant="secondary" className="text-xs px-1 py-0 ml-auto">
|
||||||
|
{sessionView.messageCount}
|
||||||
|
</Badge>
|
||||||
|
)}
|
||||||
|
<span className="ml-1 opacity-70">
|
||||||
|
<SessionProviderIcon provider={session.__provider} className="w-3 h-3" />
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{!sessionView.isCursorSession && (
|
||||||
|
<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={(event) => {
|
||||||
|
event.stopPropagation();
|
||||||
|
requestDeleteSession();
|
||||||
|
}}
|
||||||
|
onTouchEnd={touchHandlerFactory(requestDeleteSession)}
|
||||||
|
>
|
||||||
|
<Trash2 className="w-2.5 h-2.5 text-red-600 dark:text-red-400" />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<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',
|
||||||
|
isSelected && 'bg-accent text-accent-foreground',
|
||||||
|
)}
|
||||||
|
onClick={() => onSessionSelect(session, project.name)}
|
||||||
|
onTouchEnd={touchHandlerFactory(() => onSessionSelect(session, project.name))}
|
||||||
|
>
|
||||||
|
<div className="flex items-start gap-2 min-w-0 w-full">
|
||||||
|
<SessionProviderIcon provider={session.__provider} className="w-3 h-3 mt-0.5 flex-shrink-0" />
|
||||||
|
<div className="min-w-0 flex-1">
|
||||||
|
<div className="text-xs font-medium truncate text-foreground">{sessionView.sessionName}</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(sessionView.sessionTime, currentTime, t)}
|
||||||
|
</span>
|
||||||
|
{sessionView.messageCount > 0 && (
|
||||||
|
<Badge
|
||||||
|
variant="secondary"
|
||||||
|
className="text-xs px-1 py-0 ml-auto group-hover:opacity-0 transition-opacity"
|
||||||
|
>
|
||||||
|
{sessionView.messageCount}
|
||||||
|
</Badge>
|
||||||
|
)}
|
||||||
|
<span className="ml-1 opacity-70 group-hover:opacity-0 transition-opacity">
|
||||||
|
<SessionProviderIcon provider={session.__provider} className="w-3 h-3" />
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
{!sessionView.isCursorSession && (
|
||||||
|
<div className="absolute right-2 top-1/2 transform -translate-y-1/2 flex items-center gap-1 opacity-0 group-hover:opacity-100 transition-all duration-200">
|
||||||
|
{editingSession === session.id && !sessionView.isCodexSession ? (
|
||||||
|
<>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={editingSessionName}
|
||||||
|
onChange={(event) => onEditingSessionNameChange(event.target.value)}
|
||||||
|
onKeyDown={(event) => {
|
||||||
|
event.stopPropagation();
|
||||||
|
if (event.key === 'Enter') {
|
||||||
|
saveEditedSession();
|
||||||
|
} else if (event.key === 'Escape') {
|
||||||
|
onCancelEditingSession();
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
onClick={(event) => event.stopPropagation()}
|
||||||
|
className="w-32 px-2 py-1 text-xs border border-border rounded bg-background focus:outline-none focus:ring-1 focus:ring-primary"
|
||||||
|
autoFocus
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
className="w-6 h-6 bg-green-50 hover:bg-green-100 dark:bg-green-900/20 dark:hover:bg-green-900/40 rounded flex items-center justify-center"
|
||||||
|
onClick={(event) => {
|
||||||
|
event.stopPropagation();
|
||||||
|
saveEditedSession();
|
||||||
|
}}
|
||||||
|
title={t('tooltips.save')}
|
||||||
|
>
|
||||||
|
<Check className="w-3 h-3 text-green-600 dark:text-green-400" />
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
className="w-6 h-6 bg-gray-50 hover:bg-gray-100 dark:bg-gray-900/20 dark:hover:bg-gray-900/40 rounded flex items-center justify-center"
|
||||||
|
onClick={(event) => {
|
||||||
|
event.stopPropagation();
|
||||||
|
onCancelEditingSession();
|
||||||
|
}}
|
||||||
|
title={t('tooltips.cancel')}
|
||||||
|
>
|
||||||
|
<X className="w-3 h-3 text-gray-600 dark:text-gray-400" />
|
||||||
|
</button>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
{!sessionView.isCodexSession && (
|
||||||
|
<button
|
||||||
|
className="w-6 h-6 bg-gray-50 hover:bg-gray-100 dark:bg-gray-900/20 dark:hover:bg-gray-900/40 rounded flex items-center justify-center"
|
||||||
|
onClick={(event) => {
|
||||||
|
event.stopPropagation();
|
||||||
|
onStartEditingSession(session.id, session.summary || t('projects.newSession'));
|
||||||
|
}}
|
||||||
|
title={t('tooltips.editSessionName')}
|
||||||
|
>
|
||||||
|
<Edit2 className="w-3 h-3 text-gray-600 dark:text-gray-400" />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
<button
|
||||||
|
className="w-6 h-6 bg-red-50 hover:bg-red-100 dark:bg-red-900/20 dark:hover:bg-red-900/40 rounded flex items-center justify-center"
|
||||||
|
onClick={(event) => {
|
||||||
|
event.stopPropagation();
|
||||||
|
requestDeleteSession();
|
||||||
|
}}
|
||||||
|
title={t('tooltips.deleteSession')}
|
||||||
|
>
|
||||||
|
<Trash2 className="w-3 h-3 text-red-600 dark:text-red-400" />
|
||||||
|
</button>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
57
src/components/sidebar/types.ts
Normal file
57
src/components/sidebar/types.ts
Normal file
@@ -0,0 +1,57 @@
|
|||||||
|
import type React from 'react';
|
||||||
|
import type { LoadingProgress, Project, ProjectSession, SessionProvider } from '../../types/app';
|
||||||
|
|
||||||
|
export type ProjectSortOrder = 'name' | 'date';
|
||||||
|
|
||||||
|
export type SessionWithProvider = ProjectSession & {
|
||||||
|
__provider: SessionProvider;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type AdditionalSessionsByProject = Record<string, ProjectSession[]>;
|
||||||
|
export type LoadingSessionsByProject = Record<string, boolean>;
|
||||||
|
|
||||||
|
export type DeleteProjectConfirmation = {
|
||||||
|
project: Project;
|
||||||
|
sessionCount: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type SessionDeleteConfirmation = {
|
||||||
|
projectName: string;
|
||||||
|
sessionId: string;
|
||||||
|
sessionTitle: string;
|
||||||
|
provider: SessionProvider;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type SidebarProps = {
|
||||||
|
projects: Project[];
|
||||||
|
selectedProject: Project | null;
|
||||||
|
selectedSession: ProjectSession | null;
|
||||||
|
onProjectSelect: (project: Project) => void;
|
||||||
|
onSessionSelect: (session: ProjectSession) => void;
|
||||||
|
onNewSession: (project: Project) => void;
|
||||||
|
onSessionDelete?: (sessionId: string) => void;
|
||||||
|
onProjectDelete?: (projectName: string) => void;
|
||||||
|
isLoading: boolean;
|
||||||
|
loadingProgress: LoadingProgress | null;
|
||||||
|
onRefresh: () => Promise<void> | void;
|
||||||
|
onShowSettings: () => void;
|
||||||
|
isMobile: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type SessionViewModel = {
|
||||||
|
isCursorSession: boolean;
|
||||||
|
isCodexSession: boolean;
|
||||||
|
isActive: boolean;
|
||||||
|
sessionName: string;
|
||||||
|
sessionTime: string;
|
||||||
|
messageCount: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type MCPServerStatus = {
|
||||||
|
hasMCPServer?: boolean;
|
||||||
|
isConfigured?: boolean;
|
||||||
|
} | null;
|
||||||
|
|
||||||
|
export type TouchHandlerFactory = (
|
||||||
|
callback: () => void,
|
||||||
|
) => (event: React.TouchEvent<HTMLElement>) => void;
|
||||||
200
src/components/sidebar/utils.ts
Normal file
200
src/components/sidebar/utils.ts
Normal file
@@ -0,0 +1,200 @@
|
|||||||
|
import type { TFunction } from 'i18next';
|
||||||
|
import type { Project } from '../../types/app';
|
||||||
|
import type {
|
||||||
|
AdditionalSessionsByProject,
|
||||||
|
ProjectSortOrder,
|
||||||
|
SessionViewModel,
|
||||||
|
SessionWithProvider,
|
||||||
|
} from './types';
|
||||||
|
|
||||||
|
export const readProjectSortOrder = (): ProjectSortOrder => {
|
||||||
|
try {
|
||||||
|
const rawSettings = localStorage.getItem('claude-settings');
|
||||||
|
if (!rawSettings) {
|
||||||
|
return 'name';
|
||||||
|
}
|
||||||
|
|
||||||
|
const settings = JSON.parse(rawSettings) as { projectSortOrder?: ProjectSortOrder };
|
||||||
|
return settings.projectSortOrder === 'date' ? 'date' : 'name';
|
||||||
|
} catch {
|
||||||
|
return 'name';
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export const loadStarredProjects = (): Set<string> => {
|
||||||
|
try {
|
||||||
|
const saved = localStorage.getItem('starredProjects');
|
||||||
|
return saved ? new Set<string>(JSON.parse(saved)) : new Set<string>();
|
||||||
|
} catch {
|
||||||
|
return new Set<string>();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export const persistStarredProjects = (starredProjects: Set<string>) => {
|
||||||
|
try {
|
||||||
|
localStorage.setItem('starredProjects', JSON.stringify([...starredProjects]));
|
||||||
|
} catch {
|
||||||
|
// Keep UI responsive even if storage fails.
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export const getSessionDate = (session: SessionWithProvider): Date => {
|
||||||
|
if (session.__provider === 'cursor') {
|
||||||
|
return new Date(session.createdAt || 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (session.__provider === 'codex') {
|
||||||
|
return new Date(session.createdAt || session.lastActivity || 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
return new Date(session.lastActivity || 0);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const getSessionName = (session: SessionWithProvider, t: TFunction): string => {
|
||||||
|
if (session.__provider === 'cursor') {
|
||||||
|
return session.name || t('projects.untitledSession');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (session.__provider === 'codex') {
|
||||||
|
return session.summary || session.name || t('projects.codexSession');
|
||||||
|
}
|
||||||
|
|
||||||
|
return session.summary || t('projects.newSession');
|
||||||
|
};
|
||||||
|
|
||||||
|
export const getSessionTime = (session: SessionWithProvider): string => {
|
||||||
|
if (session.__provider === 'cursor') {
|
||||||
|
return String(session.createdAt || '');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (session.__provider === 'codex') {
|
||||||
|
return String(session.createdAt || session.lastActivity || '');
|
||||||
|
}
|
||||||
|
|
||||||
|
return String(session.lastActivity || '');
|
||||||
|
};
|
||||||
|
|
||||||
|
export const createSessionViewModel = (
|
||||||
|
session: SessionWithProvider,
|
||||||
|
currentTime: Date,
|
||||||
|
t: TFunction,
|
||||||
|
): SessionViewModel => {
|
||||||
|
const sessionDate = getSessionDate(session);
|
||||||
|
const diffInMinutes = Math.floor((currentTime.getTime() - sessionDate.getTime()) / (1000 * 60));
|
||||||
|
|
||||||
|
return {
|
||||||
|
isCursorSession: session.__provider === 'cursor',
|
||||||
|
isCodexSession: session.__provider === 'codex',
|
||||||
|
isActive: diffInMinutes < 10,
|
||||||
|
sessionName: getSessionName(session, t),
|
||||||
|
sessionTime: getSessionTime(session),
|
||||||
|
messageCount: Number(session.messageCount || 0),
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
export const getAllSessions = (
|
||||||
|
project: Project,
|
||||||
|
additionalSessions: AdditionalSessionsByProject,
|
||||||
|
): SessionWithProvider[] => {
|
||||||
|
const claudeSessions = [
|
||||||
|
...(project.sessions || []),
|
||||||
|
...(additionalSessions[project.name] || []),
|
||||||
|
].map((session) => ({ ...session, __provider: 'claude' as const }));
|
||||||
|
|
||||||
|
const cursorSessions = (project.cursorSessions || []).map((session) => ({
|
||||||
|
...session,
|
||||||
|
__provider: 'cursor' as const,
|
||||||
|
}));
|
||||||
|
|
||||||
|
const codexSessions = (project.codexSessions || []).map((session) => ({
|
||||||
|
...session,
|
||||||
|
__provider: 'codex' as const,
|
||||||
|
}));
|
||||||
|
|
||||||
|
return [...claudeSessions, ...cursorSessions, ...codexSessions].sort(
|
||||||
|
(a, b) => getSessionDate(b).getTime() - getSessionDate(a).getTime(),
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const getProjectLastActivity = (
|
||||||
|
project: Project,
|
||||||
|
additionalSessions: AdditionalSessionsByProject,
|
||||||
|
): Date => {
|
||||||
|
const sessions = getAllSessions(project, additionalSessions);
|
||||||
|
if (sessions.length === 0) {
|
||||||
|
return new Date(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
return sessions.reduce((latest, session) => {
|
||||||
|
const sessionDate = getSessionDate(session);
|
||||||
|
return sessionDate > latest ? sessionDate : latest;
|
||||||
|
}, new Date(0));
|
||||||
|
};
|
||||||
|
|
||||||
|
export const sortProjects = (
|
||||||
|
projects: Project[],
|
||||||
|
projectSortOrder: ProjectSortOrder,
|
||||||
|
starredProjects: Set<string>,
|
||||||
|
additionalSessions: AdditionalSessionsByProject,
|
||||||
|
): Project[] => {
|
||||||
|
const byName = [...projects];
|
||||||
|
|
||||||
|
byName.sort((projectA, projectB) => {
|
||||||
|
const aStarred = starredProjects.has(projectA.name);
|
||||||
|
const bStarred = starredProjects.has(projectB.name);
|
||||||
|
|
||||||
|
if (aStarred && !bStarred) {
|
||||||
|
return -1;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!aStarred && bStarred) {
|
||||||
|
return 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (projectSortOrder === 'date') {
|
||||||
|
return (
|
||||||
|
getProjectLastActivity(projectB, additionalSessions).getTime() -
|
||||||
|
getProjectLastActivity(projectA, additionalSessions).getTime()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (projectA.displayName || projectA.name).localeCompare(projectB.displayName || projectB.name);
|
||||||
|
});
|
||||||
|
|
||||||
|
return byName;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const filterProjects = (projects: Project[], searchFilter: string): Project[] => {
|
||||||
|
const normalizedSearch = searchFilter.trim().toLowerCase();
|
||||||
|
if (!normalizedSearch) {
|
||||||
|
return projects;
|
||||||
|
}
|
||||||
|
|
||||||
|
return projects.filter((project) => {
|
||||||
|
const displayName = (project.displayName || project.name).toLowerCase();
|
||||||
|
const projectName = project.name.toLowerCase();
|
||||||
|
return displayName.includes(normalizedSearch) || projectName.includes(normalizedSearch);
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
export const getTaskIndicatorStatus = (
|
||||||
|
project: Project,
|
||||||
|
mcpServerStatus: { hasMCPServer?: boolean; isConfigured?: boolean } | null,
|
||||||
|
) => {
|
||||||
|
const projectConfigured = Boolean(project.taskmaster?.hasTaskmaster);
|
||||||
|
const mcpConfigured = Boolean(mcpServerStatus?.hasMCPServer && mcpServerStatus?.isConfigured);
|
||||||
|
|
||||||
|
if (projectConfigured && mcpConfigured) {
|
||||||
|
return 'fully-configured';
|
||||||
|
}
|
||||||
|
|
||||||
|
if (projectConfigured) {
|
||||||
|
return 'taskmaster-only';
|
||||||
|
}
|
||||||
|
|
||||||
|
if (mcpConfigured) {
|
||||||
|
return 'mcp-only';
|
||||||
|
}
|
||||||
|
|
||||||
|
return 'not-configured';
|
||||||
|
};
|
||||||
@@ -1,31 +0,0 @@
|
|||||||
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 }
|
|
||||||
31
src/components/ui/badge.tsx
Normal file
31
src/components/ui/badge.tsx
Normal file
@@ -0,0 +1,31 @@
|
|||||||
|
import * as React from 'react';
|
||||||
|
import { cva, type VariantProps } 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',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
export interface BadgeProps
|
||||||
|
extends React.HTMLAttributes<HTMLDivElement>,
|
||||||
|
VariantProps<typeof badgeVariants> {}
|
||||||
|
|
||||||
|
function Badge({ className, variant, ...props }: BadgeProps) {
|
||||||
|
return <div className={cn(badgeVariants({ variant }), className)} {...props} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
export { Badge, badgeVariants };
|
||||||
@@ -1,46 +0,0 @@
|
|||||||
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 }
|
|
||||||
50
src/components/ui/button.tsx
Normal file
50
src/components/ui/button.tsx
Normal file
@@ -0,0 +1,50 @@
|
|||||||
|
import * as React from 'react';
|
||||||
|
import { cva, type VariantProps } 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',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
export interface ButtonProps
|
||||||
|
extends React.ButtonHTMLAttributes<HTMLButtonElement>,
|
||||||
|
VariantProps<typeof buttonVariants> {}
|
||||||
|
|
||||||
|
const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
|
||||||
|
({ className, variant, size, ...props }, ref) => {
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
className={cn(buttonVariants({ variant, size, className }))}
|
||||||
|
ref={ref}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
Button.displayName = 'Button';
|
||||||
|
|
||||||
|
export { Button, buttonVariants };
|
||||||
@@ -1,19 +0,0 @@
|
|||||||
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 }
|
|
||||||
24
src/components/ui/input.tsx
Normal file
24
src/components/ui/input.tsx
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
import * as React from 'react';
|
||||||
|
import { cn } from '../../lib/utils';
|
||||||
|
|
||||||
|
export interface InputProps
|
||||||
|
extends React.InputHTMLAttributes<HTMLInputElement> {}
|
||||||
|
|
||||||
|
const Input = React.forwardRef<HTMLInputElement, InputProps>(
|
||||||
|
({ 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 };
|
||||||
@@ -1,23 +0,0 @@
|
|||||||
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"
|
|
||||||
style={{
|
|
||||||
WebkitOverflowScrolling: 'touch',
|
|
||||||
touchAction: 'pan-y'
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{children}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
))
|
|
||||||
ScrollArea.displayName = "ScrollArea"
|
|
||||||
|
|
||||||
export { ScrollArea }
|
|
||||||
27
src/components/ui/scroll-area.tsx
Normal file
27
src/components/ui/scroll-area.tsx
Normal file
@@ -0,0 +1,27 @@
|
|||||||
|
import * as React from 'react';
|
||||||
|
import { cn } from '../../lib/utils';
|
||||||
|
|
||||||
|
export interface ScrollAreaProps extends React.HTMLAttributes<HTMLDivElement> {}
|
||||||
|
|
||||||
|
const ScrollArea = React.forwardRef<HTMLDivElement, ScrollAreaProps>(
|
||||||
|
({ className, children, ...props }, ref) => (
|
||||||
|
<div
|
||||||
|
ref={ref}
|
||||||
|
className={cn('relative overflow-hidden', className)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className="h-full w-full rounded-[inherit] overflow-auto"
|
||||||
|
style={{
|
||||||
|
WebkitOverflowScrolling: 'touch',
|
||||||
|
touchAction: 'pan-y',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
);
|
||||||
|
ScrollArea.displayName = 'ScrollArea';
|
||||||
|
|
||||||
|
export { ScrollArea };
|
||||||
527
src/hooks/useProjectsState.ts
Normal file
527
src/hooks/useProjectsState.ts
Normal file
@@ -0,0 +1,527 @@
|
|||||||
|
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||||
|
import type { NavigateFunction } from 'react-router-dom';
|
||||||
|
import { api, authenticatedFetch } from '../utils/api';
|
||||||
|
import type {
|
||||||
|
AppSocketMessage,
|
||||||
|
AppTab,
|
||||||
|
LoadingProgress,
|
||||||
|
Project,
|
||||||
|
ProjectSession,
|
||||||
|
ProjectsUpdatedMessage,
|
||||||
|
} from '../types/app';
|
||||||
|
|
||||||
|
type UseProjectsStateArgs = {
|
||||||
|
sessionId?: string;
|
||||||
|
navigate: NavigateFunction;
|
||||||
|
latestMessage: AppSocketMessage | null;
|
||||||
|
isMobile: boolean;
|
||||||
|
activeSessions: Set<string>;
|
||||||
|
};
|
||||||
|
|
||||||
|
const serialize = (value: unknown) => JSON.stringify(value ?? null);
|
||||||
|
|
||||||
|
const projectsHaveChanges = (
|
||||||
|
prevProjects: Project[],
|
||||||
|
nextProjects: Project[],
|
||||||
|
includeCursorSessions: boolean,
|
||||||
|
): boolean => {
|
||||||
|
if (prevProjects.length !== nextProjects.length) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
return nextProjects.some((nextProject, index) => {
|
||||||
|
const prevProject = prevProjects[index];
|
||||||
|
if (!prevProject) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
const baseChanged =
|
||||||
|
nextProject.name !== prevProject.name ||
|
||||||
|
nextProject.displayName !== prevProject.displayName ||
|
||||||
|
nextProject.fullPath !== prevProject.fullPath ||
|
||||||
|
serialize(nextProject.sessionMeta) !== serialize(prevProject.sessionMeta) ||
|
||||||
|
serialize(nextProject.sessions) !== serialize(prevProject.sessions);
|
||||||
|
|
||||||
|
if (baseChanged) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!includeCursorSessions) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return serialize(nextProject.cursorSessions) !== serialize(prevProject.cursorSessions);
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const getProjectSessions = (project: Project): ProjectSession[] => {
|
||||||
|
return [
|
||||||
|
...(project.sessions ?? []),
|
||||||
|
...(project.codexSessions ?? []),
|
||||||
|
...(project.cursorSessions ?? []),
|
||||||
|
];
|
||||||
|
};
|
||||||
|
|
||||||
|
const isUpdateAdditive = (
|
||||||
|
currentProjects: Project[],
|
||||||
|
updatedProjects: Project[],
|
||||||
|
selectedProject: Project | null,
|
||||||
|
selectedSession: ProjectSession | null,
|
||||||
|
): boolean => {
|
||||||
|
if (!selectedProject || !selectedSession) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
const currentSelectedProject = currentProjects.find((project) => project.name === selectedProject.name);
|
||||||
|
const updatedSelectedProject = updatedProjects.find((project) => project.name === selectedProject.name);
|
||||||
|
|
||||||
|
if (!currentSelectedProject || !updatedSelectedProject) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
const currentSelectedSession = getProjectSessions(currentSelectedProject).find(
|
||||||
|
(session) => session.id === selectedSession.id,
|
||||||
|
);
|
||||||
|
const updatedSelectedSession = getProjectSessions(updatedSelectedProject).find(
|
||||||
|
(session) => session.id === selectedSession.id,
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!currentSelectedSession || !updatedSelectedSession) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
currentSelectedSession.id === updatedSelectedSession.id &&
|
||||||
|
currentSelectedSession.title === updatedSelectedSession.title &&
|
||||||
|
currentSelectedSession.created_at === updatedSelectedSession.created_at &&
|
||||||
|
currentSelectedSession.updated_at === updatedSelectedSession.updated_at
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const loadCursorSessionsForProjects = async (projects: Project[]): Promise<Project[]> => {
|
||||||
|
const projectsWithCursor = [...projects];
|
||||||
|
|
||||||
|
for (const project of projectsWithCursor) {
|
||||||
|
try {
|
||||||
|
const projectPath = project.fullPath || project.path;
|
||||||
|
const url = `/api/cursor/sessions?projectPath=${encodeURIComponent(projectPath ?? '')}`;
|
||||||
|
const response = await authenticatedFetch(url);
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
project.cursorSessions = [];
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = await response.json();
|
||||||
|
project.cursorSessions = data.success && Array.isArray(data.sessions) ? data.sessions : [];
|
||||||
|
} catch (error) {
|
||||||
|
console.error(`Error fetching Cursor sessions for project ${project.name}:`, error);
|
||||||
|
project.cursorSessions = [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return projectsWithCursor;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function useProjectsState({
|
||||||
|
sessionId,
|
||||||
|
navigate,
|
||||||
|
latestMessage,
|
||||||
|
isMobile,
|
||||||
|
activeSessions,
|
||||||
|
}: UseProjectsStateArgs) {
|
||||||
|
const [projects, setProjects] = useState<Project[]>([]);
|
||||||
|
const [selectedProject, setSelectedProject] = useState<Project | null>(null);
|
||||||
|
const [selectedSession, setSelectedSession] = useState<ProjectSession | null>(null);
|
||||||
|
const [activeTab, setActiveTab] = useState<AppTab>('chat');
|
||||||
|
const [sidebarOpen, setSidebarOpen] = useState(false);
|
||||||
|
const [isLoadingProjects, setIsLoadingProjects] = useState(true);
|
||||||
|
const [loadingProgress, setLoadingProgress] = useState<LoadingProgress | null>(null);
|
||||||
|
const [isInputFocused, setIsInputFocused] = useState(false);
|
||||||
|
const [showSettings, setShowSettings] = useState(false);
|
||||||
|
const [settingsInitialTab, setSettingsInitialTab] = useState('agents');
|
||||||
|
const [externalMessageUpdate, setExternalMessageUpdate] = useState(0);
|
||||||
|
|
||||||
|
const loadingProgressTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||||
|
|
||||||
|
const fetchProjects = useCallback(async () => {
|
||||||
|
try {
|
||||||
|
setIsLoadingProjects(true);
|
||||||
|
const response = await api.projects();
|
||||||
|
const projectData = (await response.json()) as Project[];
|
||||||
|
const projectsWithCursor = await loadCursorSessionsForProjects(projectData);
|
||||||
|
|
||||||
|
setProjects((prevProjects) => {
|
||||||
|
if (prevProjects.length === 0) {
|
||||||
|
return projectsWithCursor;
|
||||||
|
}
|
||||||
|
|
||||||
|
return projectsHaveChanges(prevProjects, projectsWithCursor, true)
|
||||||
|
? projectsWithCursor
|
||||||
|
: prevProjects;
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error fetching projects:', error);
|
||||||
|
} finally {
|
||||||
|
setIsLoadingProjects(false);
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const openSettings = useCallback((tab = 'tools') => {
|
||||||
|
setSettingsInitialTab(tab);
|
||||||
|
setShowSettings(true);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
void fetchProjects();
|
||||||
|
}, [fetchProjects]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!latestMessage) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (latestMessage.type === 'loading_progress') {
|
||||||
|
if (loadingProgressTimeoutRef.current) {
|
||||||
|
clearTimeout(loadingProgressTimeoutRef.current);
|
||||||
|
loadingProgressTimeoutRef.current = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
setLoadingProgress(latestMessage as LoadingProgress);
|
||||||
|
|
||||||
|
if (latestMessage.phase === 'complete') {
|
||||||
|
loadingProgressTimeoutRef.current = setTimeout(() => {
|
||||||
|
setLoadingProgress(null);
|
||||||
|
loadingProgressTimeoutRef.current = null;
|
||||||
|
}, 500);
|
||||||
|
}
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (latestMessage.type !== 'projects_updated') {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const projectsMessage = latestMessage as ProjectsUpdatedMessage;
|
||||||
|
|
||||||
|
if (projectsMessage.changedFile && selectedSession && selectedProject) {
|
||||||
|
const normalized = projectsMessage.changedFile.replace(/\\/g, '/');
|
||||||
|
const changedFileParts = normalized.split('/');
|
||||||
|
|
||||||
|
if (changedFileParts.length >= 2) {
|
||||||
|
const filename = changedFileParts[changedFileParts.length - 1];
|
||||||
|
const changedSessionId = filename.replace('.jsonl', '');
|
||||||
|
|
||||||
|
if (changedSessionId === selectedSession.id) {
|
||||||
|
const isSessionActive = activeSessions.has(selectedSession.id);
|
||||||
|
|
||||||
|
if (!isSessionActive) {
|
||||||
|
setExternalMessageUpdate((prev) => prev + 1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const hasActiveSession =
|
||||||
|
(selectedSession && activeSessions.has(selectedSession.id)) ||
|
||||||
|
(activeSessions.size > 0 && Array.from(activeSessions).some((id) => id.startsWith('new-session-')));
|
||||||
|
|
||||||
|
const updatedProjects = projectsMessage.projects;
|
||||||
|
|
||||||
|
if (
|
||||||
|
hasActiveSession &&
|
||||||
|
!isUpdateAdditive(projects, updatedProjects, selectedProject, selectedSession)
|
||||||
|
) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setProjects(updatedProjects);
|
||||||
|
|
||||||
|
if (!selectedProject) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const updatedSelectedProject = updatedProjects.find(
|
||||||
|
(project) => project.name === selectedProject.name,
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!updatedSelectedProject) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (serialize(updatedSelectedProject) !== serialize(selectedProject)) {
|
||||||
|
setSelectedProject(updatedSelectedProject);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!selectedSession) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const updatedSelectedSession = getProjectSessions(updatedSelectedProject).find(
|
||||||
|
(session) => session.id === selectedSession.id,
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!updatedSelectedSession) {
|
||||||
|
setSelectedSession(null);
|
||||||
|
}
|
||||||
|
}, [latestMessage, selectedProject, selectedSession, activeSessions, projects]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
return () => {
|
||||||
|
if (loadingProgressTimeoutRef.current) {
|
||||||
|
clearTimeout(loadingProgressTimeoutRef.current);
|
||||||
|
loadingProgressTimeoutRef.current = null;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!sessionId || projects.length === 0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const shouldSwitchTab = !selectedSession || selectedSession.id !== sessionId;
|
||||||
|
|
||||||
|
for (const project of projects) {
|
||||||
|
const claudeSession = project.sessions?.find((session) => session.id === sessionId);
|
||||||
|
if (claudeSession) {
|
||||||
|
const shouldUpdateProject = selectedProject?.name !== project.name;
|
||||||
|
const shouldUpdateSession =
|
||||||
|
selectedSession?.id !== sessionId || selectedSession.__provider !== 'claude';
|
||||||
|
|
||||||
|
if (shouldUpdateProject) {
|
||||||
|
setSelectedProject(project);
|
||||||
|
}
|
||||||
|
if (shouldUpdateSession) {
|
||||||
|
setSelectedSession({ ...claudeSession, __provider: 'claude' });
|
||||||
|
}
|
||||||
|
if (shouldSwitchTab) {
|
||||||
|
setActiveTab('chat');
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const cursorSession = project.cursorSessions?.find((session) => session.id === sessionId);
|
||||||
|
if (cursorSession) {
|
||||||
|
const shouldUpdateProject = selectedProject?.name !== project.name;
|
||||||
|
const shouldUpdateSession =
|
||||||
|
selectedSession?.id !== sessionId || selectedSession.__provider !== 'cursor';
|
||||||
|
|
||||||
|
if (shouldUpdateProject) {
|
||||||
|
setSelectedProject(project);
|
||||||
|
}
|
||||||
|
if (shouldUpdateSession) {
|
||||||
|
setSelectedSession({ ...cursorSession, __provider: 'cursor' });
|
||||||
|
}
|
||||||
|
if (shouldSwitchTab) {
|
||||||
|
setActiveTab('chat');
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const codexSession = project.codexSessions?.find((session) => session.id === sessionId);
|
||||||
|
if (codexSession) {
|
||||||
|
const shouldUpdateProject = selectedProject?.name !== project.name;
|
||||||
|
const shouldUpdateSession =
|
||||||
|
selectedSession?.id !== sessionId || selectedSession.__provider !== 'codex';
|
||||||
|
|
||||||
|
if (shouldUpdateProject) {
|
||||||
|
setSelectedProject(project);
|
||||||
|
}
|
||||||
|
if (shouldUpdateSession) {
|
||||||
|
setSelectedSession({ ...codexSession, __provider: 'codex' });
|
||||||
|
}
|
||||||
|
if (shouldSwitchTab) {
|
||||||
|
setActiveTab('chat');
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, [sessionId, projects, selectedProject?.name, selectedSession?.id, selectedSession?.__provider]);
|
||||||
|
|
||||||
|
const handleProjectSelect = useCallback(
|
||||||
|
(project: Project) => {
|
||||||
|
setSelectedProject(project);
|
||||||
|
setSelectedSession(null);
|
||||||
|
navigate('/');
|
||||||
|
|
||||||
|
if (isMobile) {
|
||||||
|
setSidebarOpen(false);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[isMobile, navigate],
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleSessionSelect = useCallback(
|
||||||
|
(session: ProjectSession) => {
|
||||||
|
setSelectedSession(session);
|
||||||
|
|
||||||
|
if (activeTab !== 'git' && activeTab !== 'preview') {
|
||||||
|
setActiveTab('chat');
|
||||||
|
}
|
||||||
|
|
||||||
|
const provider = localStorage.getItem('selected-provider') || 'claude';
|
||||||
|
if (provider === 'cursor') {
|
||||||
|
sessionStorage.setItem('cursorSessionId', session.id);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isMobile) {
|
||||||
|
const sessionProjectName = session.__projectName;
|
||||||
|
const currentProjectName = selectedProject?.name;
|
||||||
|
|
||||||
|
if (sessionProjectName !== currentProjectName) {
|
||||||
|
setSidebarOpen(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
navigate(`/session/${session.id}`);
|
||||||
|
},
|
||||||
|
[activeTab, isMobile, navigate, selectedProject?.name],
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleNewSession = useCallback(
|
||||||
|
(project: Project) => {
|
||||||
|
setSelectedProject(project);
|
||||||
|
setSelectedSession(null);
|
||||||
|
setActiveTab('chat');
|
||||||
|
navigate('/');
|
||||||
|
|
||||||
|
if (isMobile) {
|
||||||
|
setSidebarOpen(false);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[isMobile, navigate],
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleSessionDelete = useCallback(
|
||||||
|
(sessionIdToDelete: string) => {
|
||||||
|
if (selectedSession?.id === sessionIdToDelete) {
|
||||||
|
setSelectedSession(null);
|
||||||
|
navigate('/');
|
||||||
|
}
|
||||||
|
|
||||||
|
setProjects((prevProjects) =>
|
||||||
|
prevProjects.map((project) => ({
|
||||||
|
...project,
|
||||||
|
sessions: project.sessions?.filter((session) => session.id !== sessionIdToDelete) ?? [],
|
||||||
|
sessionMeta: {
|
||||||
|
...project.sessionMeta,
|
||||||
|
total: Math.max(0, (project.sessionMeta?.total as number | undefined ?? 0) - 1),
|
||||||
|
},
|
||||||
|
})),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
[navigate, selectedSession?.id],
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleSidebarRefresh = useCallback(async () => {
|
||||||
|
try {
|
||||||
|
const response = await api.projects();
|
||||||
|
const freshProjects = (await response.json()) as Project[];
|
||||||
|
|
||||||
|
setProjects((prevProjects) =>
|
||||||
|
projectsHaveChanges(prevProjects, freshProjects, false) ? freshProjects : prevProjects,
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!selectedProject) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const refreshedProject = freshProjects.find((project) => project.name === selectedProject.name);
|
||||||
|
if (!refreshedProject) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (serialize(refreshedProject) !== serialize(selectedProject)) {
|
||||||
|
setSelectedProject(refreshedProject);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!selectedSession) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const refreshedSession = refreshedProject.sessions?.find(
|
||||||
|
(session) => session.id === selectedSession.id,
|
||||||
|
);
|
||||||
|
|
||||||
|
if (refreshedSession && serialize(refreshedSession) !== serialize(selectedSession)) {
|
||||||
|
setSelectedSession(refreshedSession);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error refreshing sidebar:', error);
|
||||||
|
}
|
||||||
|
}, [selectedProject, selectedSession]);
|
||||||
|
|
||||||
|
const handleProjectDelete = useCallback(
|
||||||
|
(projectName: string) => {
|
||||||
|
if (selectedProject?.name === projectName) {
|
||||||
|
setSelectedProject(null);
|
||||||
|
setSelectedSession(null);
|
||||||
|
navigate('/');
|
||||||
|
}
|
||||||
|
|
||||||
|
setProjects((prevProjects) => prevProjects.filter((project) => project.name !== projectName));
|
||||||
|
},
|
||||||
|
[navigate, selectedProject?.name],
|
||||||
|
);
|
||||||
|
|
||||||
|
const sidebarSharedProps = useMemo(
|
||||||
|
() => ({
|
||||||
|
projects,
|
||||||
|
selectedProject,
|
||||||
|
selectedSession,
|
||||||
|
onProjectSelect: handleProjectSelect,
|
||||||
|
onSessionSelect: handleSessionSelect,
|
||||||
|
onNewSession: handleNewSession,
|
||||||
|
onSessionDelete: handleSessionDelete,
|
||||||
|
onProjectDelete: handleProjectDelete,
|
||||||
|
isLoading: isLoadingProjects,
|
||||||
|
loadingProgress,
|
||||||
|
onRefresh: handleSidebarRefresh,
|
||||||
|
onShowSettings: () => setShowSettings(true),
|
||||||
|
isMobile,
|
||||||
|
}),
|
||||||
|
[
|
||||||
|
handleNewSession,
|
||||||
|
handleProjectDelete,
|
||||||
|
handleProjectSelect,
|
||||||
|
handleSessionDelete,
|
||||||
|
handleSessionSelect,
|
||||||
|
handleSidebarRefresh,
|
||||||
|
isLoadingProjects,
|
||||||
|
isMobile,
|
||||||
|
loadingProgress,
|
||||||
|
projects,
|
||||||
|
selectedProject,
|
||||||
|
selectedSession,
|
||||||
|
],
|
||||||
|
);
|
||||||
|
|
||||||
|
return {
|
||||||
|
projects,
|
||||||
|
selectedProject,
|
||||||
|
selectedSession,
|
||||||
|
activeTab,
|
||||||
|
sidebarOpen,
|
||||||
|
isLoadingProjects,
|
||||||
|
loadingProgress,
|
||||||
|
isInputFocused,
|
||||||
|
showSettings,
|
||||||
|
settingsInitialTab,
|
||||||
|
externalMessageUpdate,
|
||||||
|
setActiveTab,
|
||||||
|
setSidebarOpen,
|
||||||
|
setIsInputFocused,
|
||||||
|
setShowSettings,
|
||||||
|
openSettings,
|
||||||
|
fetchProjects,
|
||||||
|
sidebarSharedProps,
|
||||||
|
handleProjectSelect,
|
||||||
|
handleSessionSelect,
|
||||||
|
handleNewSession,
|
||||||
|
handleSessionDelete,
|
||||||
|
handleProjectDelete,
|
||||||
|
handleSidebarRefresh,
|
||||||
|
};
|
||||||
|
}
|
||||||
73
src/hooks/useSessionProtection.ts
Normal file
73
src/hooks/useSessionProtection.ts
Normal file
@@ -0,0 +1,73 @@
|
|||||||
|
import { useCallback, useState } from 'react';
|
||||||
|
|
||||||
|
export function useSessionProtection() {
|
||||||
|
const [activeSessions, setActiveSessions] = useState<Set<string>>(new Set());
|
||||||
|
const [processingSessions, setProcessingSessions] = useState<Set<string>>(new Set());
|
||||||
|
|
||||||
|
const markSessionAsActive = useCallback((sessionId?: string | null) => {
|
||||||
|
if (!sessionId) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setActiveSessions((prev) => new Set([...prev, sessionId]));
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const markSessionAsInactive = useCallback((sessionId?: string | null) => {
|
||||||
|
if (!sessionId) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setActiveSessions((prev) => {
|
||||||
|
const next = new Set(prev);
|
||||||
|
next.delete(sessionId);
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const markSessionAsProcessing = useCallback((sessionId?: string | null) => {
|
||||||
|
if (!sessionId) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setProcessingSessions((prev) => new Set([...prev, sessionId]));
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const markSessionAsNotProcessing = useCallback((sessionId?: string | null) => {
|
||||||
|
if (!sessionId) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setProcessingSessions((prev) => {
|
||||||
|
const next = new Set(prev);
|
||||||
|
next.delete(sessionId);
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const replaceTemporarySession = useCallback((realSessionId?: string | null) => {
|
||||||
|
if (!realSessionId) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setActiveSessions((prev) => {
|
||||||
|
const next = new Set<string>();
|
||||||
|
for (const sessionId of prev) {
|
||||||
|
if (!sessionId.startsWith('new-session-')) {
|
||||||
|
next.add(sessionId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
next.add(realSessionId);
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return {
|
||||||
|
activeSessions,
|
||||||
|
processingSessions,
|
||||||
|
markSessionAsActive,
|
||||||
|
markSessionAsInactive,
|
||||||
|
markSessionAsProcessing,
|
||||||
|
markSessionAsNotProcessing,
|
||||||
|
replaceTemporarySession,
|
||||||
|
};
|
||||||
|
}
|
||||||
448
src/hooks/useSidebarController.ts
Normal file
448
src/hooks/useSidebarController.ts
Normal file
@@ -0,0 +1,448 @@
|
|||||||
|
import { useCallback, useEffect, useMemo, useState } from 'react';
|
||||||
|
import type React from 'react';
|
||||||
|
import type { TFunction } from 'i18next';
|
||||||
|
import { api } from '../utils/api';
|
||||||
|
import type { Project, ProjectSession } from '../types/app';
|
||||||
|
import type {
|
||||||
|
AdditionalSessionsByProject,
|
||||||
|
DeleteProjectConfirmation,
|
||||||
|
LoadingSessionsByProject,
|
||||||
|
ProjectSortOrder,
|
||||||
|
SessionDeleteConfirmation,
|
||||||
|
SessionWithProvider,
|
||||||
|
} from '../components/sidebar/types';
|
||||||
|
import {
|
||||||
|
filterProjects,
|
||||||
|
getAllSessions,
|
||||||
|
loadStarredProjects,
|
||||||
|
persistStarredProjects,
|
||||||
|
readProjectSortOrder,
|
||||||
|
sortProjects,
|
||||||
|
} from '../components/sidebar/utils';
|
||||||
|
|
||||||
|
type UseSidebarControllerArgs = {
|
||||||
|
projects: Project[];
|
||||||
|
selectedProject: Project | null;
|
||||||
|
selectedSession: ProjectSession | null;
|
||||||
|
isLoading: boolean;
|
||||||
|
isMobile: boolean;
|
||||||
|
t: TFunction;
|
||||||
|
onRefresh: () => Promise<void> | void;
|
||||||
|
onProjectSelect: (project: Project) => void;
|
||||||
|
onSessionSelect: (session: ProjectSession) => void;
|
||||||
|
onSessionDelete?: (sessionId: string) => void;
|
||||||
|
onProjectDelete?: (projectName: string) => void;
|
||||||
|
setCurrentProject: (project: Project) => void;
|
||||||
|
setSidebarVisible: (visible: boolean) => void;
|
||||||
|
sidebarVisible: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function useSidebarController({
|
||||||
|
projects,
|
||||||
|
selectedProject,
|
||||||
|
selectedSession,
|
||||||
|
isLoading,
|
||||||
|
isMobile,
|
||||||
|
t,
|
||||||
|
onRefresh,
|
||||||
|
onProjectSelect,
|
||||||
|
onSessionSelect,
|
||||||
|
onSessionDelete,
|
||||||
|
onProjectDelete,
|
||||||
|
setCurrentProject,
|
||||||
|
setSidebarVisible,
|
||||||
|
sidebarVisible,
|
||||||
|
}: UseSidebarControllerArgs) {
|
||||||
|
const [expandedProjects, setExpandedProjects] = useState<Set<string>>(new Set());
|
||||||
|
const [editingProject, setEditingProject] = useState<string | null>(null);
|
||||||
|
const [showNewProject, setShowNewProject] = useState(false);
|
||||||
|
const [editingName, setEditingName] = useState('');
|
||||||
|
const [loadingSessions, setLoadingSessions] = useState<LoadingSessionsByProject>({});
|
||||||
|
const [additionalSessions, setAdditionalSessions] = useState<AdditionalSessionsByProject>({});
|
||||||
|
const [initialSessionsLoaded, setInitialSessionsLoaded] = useState<Set<string>>(new Set());
|
||||||
|
const [currentTime, setCurrentTime] = useState(new Date());
|
||||||
|
const [projectSortOrder, setProjectSortOrder] = useState<ProjectSortOrder>('name');
|
||||||
|
const [isRefreshing, setIsRefreshing] = useState(false);
|
||||||
|
const [editingSession, setEditingSession] = useState<string | null>(null);
|
||||||
|
const [editingSessionName, setEditingSessionName] = useState('');
|
||||||
|
const [searchFilter, setSearchFilter] = useState('');
|
||||||
|
const [deletingProjects, setDeletingProjects] = useState<Set<string>>(new Set());
|
||||||
|
const [deleteConfirmation, setDeleteConfirmation] = useState<DeleteProjectConfirmation | null>(null);
|
||||||
|
const [sessionDeleteConfirmation, setSessionDeleteConfirmation] = useState<SessionDeleteConfirmation | null>(null);
|
||||||
|
const [showVersionModal, setShowVersionModal] = useState(false);
|
||||||
|
const [starredProjects, setStarredProjects] = useState<Set<string>>(() => loadStarredProjects());
|
||||||
|
|
||||||
|
const isSidebarCollapsed = !isMobile && !sidebarVisible;
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const timer = setInterval(() => {
|
||||||
|
setCurrentTime(new Date());
|
||||||
|
}, 60000);
|
||||||
|
|
||||||
|
return () => clearInterval(timer);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setAdditionalSessions({});
|
||||||
|
setInitialSessionsLoaded(new Set());
|
||||||
|
}, [projects]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (selectedSession && selectedProject) {
|
||||||
|
setExpandedProjects((prev) => {
|
||||||
|
if (prev.has(selectedProject.name)) {
|
||||||
|
return prev;
|
||||||
|
}
|
||||||
|
const next = new Set(prev);
|
||||||
|
next.add(selectedProject.name);
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}, [selectedSession, selectedProject]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (projects.length > 0 && !isLoading) {
|
||||||
|
const loadedProjects = new Set<string>();
|
||||||
|
projects.forEach((project) => {
|
||||||
|
if (project.sessions && project.sessions.length >= 0) {
|
||||||
|
loadedProjects.add(project.name);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
setInitialSessionsLoaded(loadedProjects);
|
||||||
|
}
|
||||||
|
}, [projects, isLoading]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const loadSortOrder = () => {
|
||||||
|
setProjectSortOrder(readProjectSortOrder());
|
||||||
|
};
|
||||||
|
|
||||||
|
loadSortOrder();
|
||||||
|
|
||||||
|
const handleStorageChange = (event: StorageEvent) => {
|
||||||
|
if (event.key === 'claude-settings') {
|
||||||
|
loadSortOrder();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
window.addEventListener('storage', handleStorageChange);
|
||||||
|
|
||||||
|
const interval = setInterval(() => {
|
||||||
|
if (document.hasFocus()) {
|
||||||
|
loadSortOrder();
|
||||||
|
}
|
||||||
|
}, 1000);
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
window.removeEventListener('storage', handleStorageChange);
|
||||||
|
clearInterval(interval);
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleTouchClick = useCallback(
|
||||||
|
(callback: () => void) =>
|
||||||
|
(event: React.TouchEvent<HTMLElement>) => {
|
||||||
|
const target = event.target as HTMLElement;
|
||||||
|
if (target.closest('.overflow-y-auto') || target.closest('[data-scroll-container]')) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
event.preventDefault();
|
||||||
|
event.stopPropagation();
|
||||||
|
callback();
|
||||||
|
},
|
||||||
|
[],
|
||||||
|
);
|
||||||
|
|
||||||
|
const toggleProject = useCallback((projectName: string) => {
|
||||||
|
setExpandedProjects((prev) => {
|
||||||
|
const next = new Set<string>();
|
||||||
|
if (!prev.has(projectName)) {
|
||||||
|
next.add(projectName);
|
||||||
|
}
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleSessionClick = useCallback(
|
||||||
|
(session: SessionWithProvider, projectName: string) => {
|
||||||
|
onSessionSelect({ ...session, __projectName: projectName });
|
||||||
|
},
|
||||||
|
[onSessionSelect],
|
||||||
|
);
|
||||||
|
|
||||||
|
const toggleStarProject = useCallback((projectName: string) => {
|
||||||
|
setStarredProjects((prev) => {
|
||||||
|
const next = new Set(prev);
|
||||||
|
if (next.has(projectName)) {
|
||||||
|
next.delete(projectName);
|
||||||
|
} else {
|
||||||
|
next.add(projectName);
|
||||||
|
}
|
||||||
|
|
||||||
|
persistStarredProjects(next);
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const isProjectStarred = useCallback(
|
||||||
|
(projectName: string) => starredProjects.has(projectName),
|
||||||
|
[starredProjects],
|
||||||
|
);
|
||||||
|
|
||||||
|
const getProjectSessions = useCallback(
|
||||||
|
(project: Project) => getAllSessions(project, additionalSessions),
|
||||||
|
[additionalSessions],
|
||||||
|
);
|
||||||
|
|
||||||
|
const sortedProjects = useMemo(
|
||||||
|
() => sortProjects(projects, projectSortOrder, starredProjects, additionalSessions),
|
||||||
|
[additionalSessions, projectSortOrder, projects, starredProjects],
|
||||||
|
);
|
||||||
|
|
||||||
|
const filteredProjects = useMemo(
|
||||||
|
() => filterProjects(sortedProjects, searchFilter),
|
||||||
|
[searchFilter, sortedProjects],
|
||||||
|
);
|
||||||
|
|
||||||
|
const startEditing = useCallback((project: Project) => {
|
||||||
|
setEditingProject(project.name);
|
||||||
|
setEditingName(project.displayName);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const cancelEditing = useCallback(() => {
|
||||||
|
setEditingProject(null);
|
||||||
|
setEditingName('');
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const saveProjectName = useCallback(
|
||||||
|
async (projectName: string) => {
|
||||||
|
try {
|
||||||
|
const response = await api.renameProject(projectName, editingName);
|
||||||
|
if (response.ok) {
|
||||||
|
if (window.refreshProjects) {
|
||||||
|
await window.refreshProjects();
|
||||||
|
} else {
|
||||||
|
window.location.reload();
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
console.error('Failed to rename project');
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error renaming project:', error);
|
||||||
|
} finally {
|
||||||
|
setEditingProject(null);
|
||||||
|
setEditingName('');
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[editingName],
|
||||||
|
);
|
||||||
|
|
||||||
|
const showDeleteSessionConfirmation = useCallback(
|
||||||
|
(
|
||||||
|
projectName: string,
|
||||||
|
sessionId: string,
|
||||||
|
sessionTitle: string,
|
||||||
|
provider: SessionDeleteConfirmation['provider'] = 'claude',
|
||||||
|
) => {
|
||||||
|
setSessionDeleteConfirmation({ projectName, sessionId, sessionTitle, provider });
|
||||||
|
},
|
||||||
|
[],
|
||||||
|
);
|
||||||
|
|
||||||
|
const confirmDeleteSession = useCallback(async () => {
|
||||||
|
if (!sessionDeleteConfirmation) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const { projectName, sessionId, provider } = sessionDeleteConfirmation;
|
||||||
|
setSessionDeleteConfirmation(null);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response =
|
||||||
|
provider === 'codex'
|
||||||
|
? await api.deleteCodexSession(sessionId)
|
||||||
|
: await api.deleteSession(projectName, sessionId);
|
||||||
|
|
||||||
|
if (response.ok) {
|
||||||
|
onSessionDelete?.(sessionId);
|
||||||
|
} else {
|
||||||
|
const errorText = await response.text();
|
||||||
|
console.error('[Sidebar] Failed to delete session:', {
|
||||||
|
status: response.status,
|
||||||
|
error: errorText,
|
||||||
|
});
|
||||||
|
alert(t('messages.deleteSessionFailed'));
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('[Sidebar] Error deleting session:', error);
|
||||||
|
alert(t('messages.deleteSessionError'));
|
||||||
|
}
|
||||||
|
}, [onSessionDelete, sessionDeleteConfirmation, t]);
|
||||||
|
|
||||||
|
const requestProjectDelete = useCallback(
|
||||||
|
(project: Project) => {
|
||||||
|
setDeleteConfirmation({
|
||||||
|
project,
|
||||||
|
sessionCount: getProjectSessions(project).length,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
[getProjectSessions],
|
||||||
|
);
|
||||||
|
|
||||||
|
const confirmDeleteProject = useCallback(async () => {
|
||||||
|
if (!deleteConfirmation) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const { project, sessionCount } = deleteConfirmation;
|
||||||
|
const isEmpty = sessionCount === 0;
|
||||||
|
|
||||||
|
setDeleteConfirmation(null);
|
||||||
|
setDeletingProjects((prev) => new Set([...prev, project.name]));
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await api.deleteProject(project.name, !isEmpty);
|
||||||
|
|
||||||
|
if (response.ok) {
|
||||||
|
onProjectDelete?.(project.name);
|
||||||
|
} else {
|
||||||
|
const error = (await response.json()) as { error?: string };
|
||||||
|
alert(error.error || t('messages.deleteProjectFailed'));
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error deleting project:', error);
|
||||||
|
alert(t('messages.deleteProjectError'));
|
||||||
|
} finally {
|
||||||
|
setDeletingProjects((prev) => {
|
||||||
|
const next = new Set(prev);
|
||||||
|
next.delete(project.name);
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}, [deleteConfirmation, onProjectDelete, t]);
|
||||||
|
|
||||||
|
const loadMoreSessions = useCallback(
|
||||||
|
async (project: Project) => {
|
||||||
|
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 api.sessions(project.name, 5, currentSessionCount);
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = (await response.json()) as {
|
||||||
|
sessions?: ProjectSession[];
|
||||||
|
hasMore?: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
setAdditionalSessions((prev) => ({
|
||||||
|
...prev,
|
||||||
|
[project.name]: [...(prev[project.name] || []), ...(result.sessions || [])],
|
||||||
|
}));
|
||||||
|
|
||||||
|
if (result.hasMore === false) {
|
||||||
|
project.sessionMeta = { ...project.sessionMeta, hasMore: false };
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error loading more sessions:', error);
|
||||||
|
} finally {
|
||||||
|
setLoadingSessions((prev) => ({ ...prev, [project.name]: false }));
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[additionalSessions, loadingSessions],
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleProjectSelect = useCallback(
|
||||||
|
(project: Project) => {
|
||||||
|
onProjectSelect(project);
|
||||||
|
setCurrentProject(project);
|
||||||
|
},
|
||||||
|
[onProjectSelect, setCurrentProject],
|
||||||
|
);
|
||||||
|
|
||||||
|
const refreshProjects = useCallback(async () => {
|
||||||
|
setIsRefreshing(true);
|
||||||
|
try {
|
||||||
|
await onRefresh();
|
||||||
|
} finally {
|
||||||
|
setIsRefreshing(false);
|
||||||
|
}
|
||||||
|
}, [onRefresh]);
|
||||||
|
|
||||||
|
const updateSessionSummary = useCallback(
|
||||||
|
async (_projectName: string, _sessionId: string, _summary: string) => {
|
||||||
|
// Session rename endpoint is not currently exposed on the API.
|
||||||
|
setEditingSession(null);
|
||||||
|
setEditingSessionName('');
|
||||||
|
},
|
||||||
|
[],
|
||||||
|
);
|
||||||
|
|
||||||
|
const collapseSidebar = useCallback(() => {
|
||||||
|
setSidebarVisible(false);
|
||||||
|
}, [setSidebarVisible]);
|
||||||
|
|
||||||
|
const expandSidebar = useCallback(() => {
|
||||||
|
setSidebarVisible(true);
|
||||||
|
}, [setSidebarVisible]);
|
||||||
|
|
||||||
|
return {
|
||||||
|
isSidebarCollapsed,
|
||||||
|
expandedProjects,
|
||||||
|
editingProject,
|
||||||
|
showNewProject,
|
||||||
|
editingName,
|
||||||
|
loadingSessions,
|
||||||
|
additionalSessions,
|
||||||
|
initialSessionsLoaded,
|
||||||
|
currentTime,
|
||||||
|
projectSortOrder,
|
||||||
|
isRefreshing,
|
||||||
|
editingSession,
|
||||||
|
editingSessionName,
|
||||||
|
searchFilter,
|
||||||
|
deletingProjects,
|
||||||
|
deleteConfirmation,
|
||||||
|
sessionDeleteConfirmation,
|
||||||
|
showVersionModal,
|
||||||
|
starredProjects,
|
||||||
|
filteredProjects,
|
||||||
|
handleTouchClick,
|
||||||
|
toggleProject,
|
||||||
|
handleSessionClick,
|
||||||
|
toggleStarProject,
|
||||||
|
isProjectStarred,
|
||||||
|
getProjectSessions,
|
||||||
|
startEditing,
|
||||||
|
cancelEditing,
|
||||||
|
saveProjectName,
|
||||||
|
showDeleteSessionConfirmation,
|
||||||
|
confirmDeleteSession,
|
||||||
|
requestProjectDelete,
|
||||||
|
confirmDeleteProject,
|
||||||
|
loadMoreSessions,
|
||||||
|
handleProjectSelect,
|
||||||
|
refreshProjects,
|
||||||
|
updateSessionSummary,
|
||||||
|
collapseSidebar,
|
||||||
|
expandSidebar,
|
||||||
|
setShowNewProject,
|
||||||
|
setEditingName,
|
||||||
|
setEditingSession,
|
||||||
|
setEditingSessionName,
|
||||||
|
setSearchFilter,
|
||||||
|
setDeleteConfirmation,
|
||||||
|
setSessionDeleteConfirmation,
|
||||||
|
setShowVersionModal,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
import React from 'react'
|
import React from 'react'
|
||||||
import ReactDOM from 'react-dom/client'
|
import ReactDOM from 'react-dom/client'
|
||||||
import App from './App.jsx'
|
import App from './App.tsx'
|
||||||
import './index.css'
|
import './index.css'
|
||||||
import 'katex/dist/katex.min.css'
|
import 'katex/dist/katex.min.css'
|
||||||
|
|
||||||
|
|||||||
69
src/types/app.ts
Normal file
69
src/types/app.ts
Normal file
@@ -0,0 +1,69 @@
|
|||||||
|
export type SessionProvider = 'claude' | 'cursor' | 'codex';
|
||||||
|
|
||||||
|
export type AppTab = 'chat' | 'files' | 'shell' | 'git' | 'tasks' | 'preview';
|
||||||
|
|
||||||
|
export interface ProjectSession {
|
||||||
|
id: string;
|
||||||
|
title?: string;
|
||||||
|
summary?: string;
|
||||||
|
name?: string;
|
||||||
|
createdAt?: string;
|
||||||
|
created_at?: string;
|
||||||
|
updated_at?: string;
|
||||||
|
lastActivity?: string;
|
||||||
|
messageCount?: number;
|
||||||
|
__provider?: SessionProvider;
|
||||||
|
__projectName?: string;
|
||||||
|
[key: string]: unknown;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ProjectSessionMeta {
|
||||||
|
total?: number;
|
||||||
|
hasMore?: boolean;
|
||||||
|
[key: string]: unknown;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ProjectTaskmasterInfo {
|
||||||
|
hasTaskmaster?: boolean;
|
||||||
|
status?: string;
|
||||||
|
metadata?: Record<string, unknown>;
|
||||||
|
[key: string]: unknown;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Project {
|
||||||
|
name: string;
|
||||||
|
displayName: string;
|
||||||
|
fullPath: string;
|
||||||
|
path?: string;
|
||||||
|
sessions?: ProjectSession[];
|
||||||
|
cursorSessions?: ProjectSession[];
|
||||||
|
codexSessions?: ProjectSession[];
|
||||||
|
sessionMeta?: ProjectSessionMeta;
|
||||||
|
taskmaster?: ProjectTaskmasterInfo;
|
||||||
|
[key: string]: unknown;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface LoadingProgress {
|
||||||
|
type?: 'loading_progress';
|
||||||
|
phase?: string;
|
||||||
|
current: number;
|
||||||
|
total: number;
|
||||||
|
currentProject?: string;
|
||||||
|
[key: string]: unknown;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ProjectsUpdatedMessage {
|
||||||
|
type: 'projects_updated';
|
||||||
|
projects: Project[];
|
||||||
|
changedFile?: string;
|
||||||
|
[key: string]: unknown;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface LoadingProgressMessage extends LoadingProgress {
|
||||||
|
type: 'loading_progress';
|
||||||
|
}
|
||||||
|
|
||||||
|
export type AppSocketMessage =
|
||||||
|
| LoadingProgressMessage
|
||||||
|
| ProjectsUpdatedMessage
|
||||||
|
| { type?: string; [key: string]: unknown };
|
||||||
9
src/types/global.d.ts
vendored
Normal file
9
src/types/global.d.ts
vendored
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
export {};
|
||||||
|
|
||||||
|
declare global {
|
||||||
|
interface Window {
|
||||||
|
__ROUTER_BASENAME__?: string;
|
||||||
|
refreshProjects?: () => void | Promise<void>;
|
||||||
|
openSettings?: (tab?: string) => void;
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user