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:
Haileyesus
2026-02-07 16:34:26 +03:00
parent 806597d149
commit 2c5e534121
34 changed files with 3613 additions and 2370 deletions

View 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';
};