mirror of
https://github.com/siteboon/claudecodeui.git
synced 2026-02-15 05:07:35 +00: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:
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,
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user