mirror of
https://github.com/siteboon/claudecodeui.git
synced 2026-02-14 20:57:32 +00:00
- 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.
528 lines
15 KiB
TypeScript
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,
|
|
};
|
|
}
|