mirror of
https://github.com/siteboon/claudecodeui.git
synced 2026-02-16 13:47:34 +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:
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';
|
||||
};
|
||||
Reference in New Issue
Block a user