Files
claudecodeui/src/hooks/useProjectsState.ts
Haileyesus 2c5e534121 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.
2026-02-12 23:45:45 +03:00

528 lines
15 KiB
TypeScript

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,
};
}