diff --git a/server/src/modules/providers/codex/codex.session-processor.ts b/server/src/modules/providers/codex/codex.session-processor.ts index bbf73886..9e1d1d4a 100644 --- a/server/src/modules/providers/codex/codex.session-processor.ts +++ b/server/src/modules/providers/codex/codex.session-processor.ts @@ -51,6 +51,17 @@ export async function processCodexSessions() { } } +function getPathNumberVariants(value: number): string[] { + const unpadded = String(value); + const padded = unpadded.padStart(2, '0'); + + if (unpadded === padded) { + return [unpadded]; + } + + return [unpadded, padded]; +} + function buildCodexDatePathParts(createdAt: string): Array<{ year: string; month: string; day: string }> { const parsedDate = new Date(createdAt); if (Number.isNaN(parsedDate.getTime())) { @@ -59,25 +70,40 @@ function buildCodexDatePathParts(createdAt: string): Array<{ year: string; month const localDate = { year: String(parsedDate.getFullYear()), - month: String(parsedDate.getMonth() + 1), - day: String(parsedDate.getDate()), + month: parsedDate.getMonth() + 1, + day: parsedDate.getDate(), }; const utcDate = { year: String(parsedDate.getUTCFullYear()), - month: String(parsedDate.getUTCMonth() + 1), - day: String(parsedDate.getUTCDate()), + month: parsedDate.getUTCMonth() + 1, + day: parsedDate.getUTCDate(), }; - if ( + const rawDateParts = localDate.year === utcDate.year && - localDate.month === utcDate.month && - localDate.day === utcDate.day - ) { - return [localDate]; + localDate.month === utcDate.month && + localDate.day === utcDate.day + ? [localDate] + : [localDate, utcDate]; + + const uniqueDateParts = new Map(); + for (const datePart of rawDateParts) { + const monthVariants = getPathNumberVariants(datePart.month); + const dayVariants = getPathNumberVariants(datePart.day); + + for (const month of monthVariants) { + for (const day of dayVariants) { + uniqueDateParts.set(`${datePart.year}-${month}-${day}`, { + year: datePart.year, + month, + day, + }); + } + } } - return [localDate, utcDate]; + return [...uniqueDateParts.values()]; } async function removeFileIfExists(filePath: string): Promise { diff --git a/server/src/modules/sidebar/sidebar.routes.ts b/server/src/modules/sidebar/sidebar.routes.ts new file mode 100644 index 00000000..e326a870 --- /dev/null +++ b/server/src/modules/sidebar/sidebar.routes.ts @@ -0,0 +1,171 @@ +import express, { type Request, type Response } from 'express'; + +import { authenticateToken } from '@/modules/auth/auth.middleware.js'; +import { + deleteSessionById, + deleteWorkspaceByPath, + getWorkspaceSessionsCollection, + updateSessionNameById, + updateWorkspaceNameByPath, + updateWorkspaceStarByPath, +} from '@/modules/sidebar/sidebar.service.js'; + +const router = express.Router(); + +const getTrimmedString = (value: unknown): string => { + if (typeof value !== 'string') { + return ''; + } + + return value.trim(); +}; + +const getWorkspacePathFromBody = (req: Request): string => getTrimmedString(req.body?.workspacePath); + +router.get( + '/api/sidebar/get-workspaces-sessions', + authenticateToken, + async (_req: Request, res: Response): Promise => { + try { + const workspaces = getWorkspaceSessionsCollection(); + res.json({ workspaces }); + } catch (error) { + const message = error instanceof Error ? error.message : 'Failed to fetch workspaces'; + res.status(500).json({ error: message }); + } + }, +); + +router.put( + '/api/sidebar/update-workspace-star', + authenticateToken, + async (req: Request, res: Response): Promise => { + try { + const workspacePath = getWorkspacePathFromBody(req); + if (!workspacePath) { + res.status(400).json({ error: 'workspacePath is required' }); + return; + } + + const isStarred = updateWorkspaceStarByPath(workspacePath); + res.json({ success: true, workspacePath, isStarred }); + } catch (error) { + const message = error instanceof Error ? error.message : 'Failed to update workspace star'; + const statusCode = message.toLowerCase().includes('not found') ? 404 : 500; + res.status(statusCode).json({ error: message }); + } + }, +); + +router.put( + '/api/sidebar/update-workspace-custom-name', + authenticateToken, + async (req: Request, res: Response): Promise => { + try { + const workspacePath = getWorkspacePathFromBody(req); + if (!workspacePath) { + res.status(400).json({ error: 'workspacePath is required' }); + return; + } + + const customWorkspaceName = getTrimmedString(req.body?.workspaceCustomName); + updateWorkspaceNameByPath(workspacePath, customWorkspaceName || null); + + res.json({ success: true, workspacePath, workspaceCustomName: customWorkspaceName || null }); + } catch (error) { + const message = error instanceof Error ? error.message : 'Failed to update workspace name'; + res.status(500).json({ error: message }); + } + }, +); + +router.put( + '/api/sidebar/update-session-custom-name', + authenticateToken, + async (req: Request, res: Response): Promise => { + try { + const sessionId = getTrimmedString(req.body?.sessionId); + const sessionCustomName = getTrimmedString(req.body?.sessionCustomName); + + if (!sessionId) { + res.status(400).json({ error: 'sessionId is required' }); + return; + } + + if (!sessionCustomName) { + res.status(400).json({ error: 'sessionCustomName is required' }); + return; + } + + if (sessionCustomName.length > 500) { + res + .status(400) + .json({ error: 'sessionCustomName must not exceed 500 characters' }); + return; + } + + updateSessionNameById(sessionId, sessionCustomName); + res.json({ success: true, sessionId, sessionCustomName }); + } catch (error) { + const message = + error instanceof Error ? error.message : 'Failed to update session name'; + const statusCode = message.toLowerCase().includes('not found') ? 404 : 500; + res.status(statusCode).json({ error: message }); + } + }, +); + +router.delete( + '/api/sidebar/delete-workspace', + authenticateToken, + async (req: Request, res: Response): Promise => { + try { + const workspacePath = getWorkspacePathFromBody(req); + if (!workspacePath) { + res.status(400).json({ error: 'workspacePath is required' }); + return; + } + + const result = await deleteWorkspaceByPath(workspacePath); + res.json({ + success: true, + workspacePath, + ...result, + }); + } catch (error) { + const message = error instanceof Error ? error.message : 'Failed to delete workspace'; + res.status(500).json({ error: message }); + } + }, +); + +router.delete( + '/api/sidebar/delete-session', + authenticateToken, + async (req: Request, res: Response): Promise => { + try { + const sessionId = getTrimmedString(req.body?.sessionId); + if (!sessionId) { + res.status(400).json({ error: 'sessionId is required' }); + return; + } + + const result = await deleteSessionById(sessionId); + if (!result.deleted) { + res.status(404).json({ error: 'Session not found' }); + return; + } + + res.json({ + success: true, + sessionId, + ...result, + }); + } catch (error) { + const message = error instanceof Error ? error.message : 'Failed to delete session'; + res.status(500).json({ error: message }); + } + }, +); + +export default router; diff --git a/server/src/modules/sidebar/sidebar.service.ts b/server/src/modules/sidebar/sidebar.service.ts new file mode 100644 index 00000000..ac2d2be9 --- /dev/null +++ b/server/src/modules/sidebar/sidebar.service.ts @@ -0,0 +1,247 @@ +import path from 'node:path'; + +import { deleteClaudeSession } from '@/modules/providers/claude/claude.session-processor.js'; +import { deleteCodexSession } from '@/modules/providers/codex/codex.session-processor.js'; +import { deleteCursorSession } from '@/modules/providers/cursor/cursor.session-processor.js'; +import { deleteGeminiSession } from '@/modules/providers/gemini/gemini.session-processor.js'; +import { sessionsDb } from '@/shared/database/repositories/sessions.db.js'; +import { workspaceOriginalPathsDb } from '@/shared/database/repositories/workspace-original-paths.db.js'; +import type { SessionsRow } from '@/shared/database/types.js'; + +export type SidebarSessionRecord = { + sessionId: string; + id: string; + provider: SessionsRow['provider']; + customName: string | null; + summary: string; + workspacePath: string; + createdAt: string | null; + updatedAt: string | null; + lastActivity: string | null; +}; + +export type SidebarWorkspaceRecord = { + workspaceOriginalPath: string; + workspaceCustomName: string | null; + workspaceDisplayName: string; + isStarred: boolean; + lastActivity: string | null; + sessions: SidebarSessionRecord[]; +}; + +export type DeleteSessionResult = { + deleted: boolean; + jsonlDeleted: boolean; +}; + +export type DeleteWorkspaceResult = { + deletedWorkspace: boolean; + deletedSessionCount: number; + jsonlDeletedCount: number; + failedSessionFileDeletes: string[]; +}; + +type SessionDeletionTarget = Pick; + +const parseTimestamp = (timestamp: string | null | undefined): number => { + if (!timestamp) { + return 0; + } + + // SQLite CURRENT_TIMESTAMP is UTC but stored without timezone ("YYYY-MM-DD HH:MM:SS"). + // Normalize this format so parsing is always timezone-correct. + const sqliteUtcPattern = /^\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}$/; + const normalizedTimestamp = sqliteUtcPattern.test(timestamp) + ? `${timestamp.replace(' ', 'T')}Z` + : timestamp; + + const parsed = new Date(normalizedTimestamp).getTime(); + return Number.isFinite(parsed) ? parsed : 0; +}; + +const toSidebarSessionRecord = (session: SessionsRow): SidebarSessionRecord => { + const lastActivity = session.updated_at || session.created_at || null; + + return { + sessionId: session.session_id, + id: session.session_id, + provider: session.provider, + customName: session.custom_name, + summary: session.custom_name || 'Untitled Session', + workspacePath: session.workspace_path, + createdAt: session.created_at || null, + updatedAt: session.updated_at || null, + lastActivity, + }; +}; + +const sortSessionsByLastActivity = (sessions: SidebarSessionRecord[]): SidebarSessionRecord[] => + [...sessions].sort((left, right) => { + const timestampDifference = + parseTimestamp(right.lastActivity) - parseTimestamp(left.lastActivity); + + if (timestampDifference !== 0) { + return timestampDifference; + } + + return right.sessionId.localeCompare(left.sessionId); + }); + +const sortWorkspacesByLastActivity = ( + workspaces: SidebarWorkspaceRecord[], +): SidebarWorkspaceRecord[] => + [...workspaces].sort((left, right) => { + const timestampDifference = + parseTimestamp(right.lastActivity) - parseTimestamp(left.lastActivity); + + if (timestampDifference !== 0) { + return timestampDifference; + } + + return left.workspaceDisplayName.localeCompare(right.workspaceDisplayName); + }); + +const deleteSessionFileByProvider = async ( + session: SessionDeletionTarget, +): Promise => { + switch (session.provider) { + case 'claude': + return deleteClaudeSession(session.session_id, session.workspace_path); + case 'codex': + return deleteCodexSession(session.session_id, session.created_at); + case 'cursor': + return deleteCursorSession(session.session_id, session.workspace_path); + case 'gemini': + return deleteGeminiSession(session.session_id); + default: + return false; + } +}; + +export const getWorkspaceSessionsCollection = (): SidebarWorkspaceRecord[] => { + const workspaceRows = workspaceOriginalPathsDb.getWorkspacePaths(); + const sessionRows = sessionsDb.getAllSessions(); + const sessionsByWorkspace = new Map(); + + // Build grouped sessions once to keep the response shape deterministic. + for (const sessionRow of sessionRows) { + const existing = sessionsByWorkspace.get(sessionRow.workspace_path) || []; + existing.push(toSidebarSessionRecord(sessionRow)); + sessionsByWorkspace.set(sessionRow.workspace_path, existing); + } + + const workspaceRecords = workspaceRows.map((workspaceRow) => { + const sessions = sortSessionsByLastActivity( + sessionsByWorkspace.get(workspaceRow.workspace_path) || [], + ); + const lastActivity = sessions[0]?.lastActivity || null; + + return { + workspaceOriginalPath: workspaceRow.workspace_path, + workspaceCustomName: workspaceRow.custom_workspace_name, + workspaceDisplayName: + workspaceRow.custom_workspace_name || + path.basename(workspaceRow.workspace_path) || + workspaceRow.workspace_path, + isStarred: workspaceRow.isStarred === 1, + lastActivity, + sessions, + }; + }); + + return sortWorkspacesByLastActivity(workspaceRecords); +}; + +export const updateWorkspaceStarByPath = (workspacePath: string): boolean => { + const workspaceRow = workspaceOriginalPathsDb.getWorkspacePath(workspacePath); + if (!workspaceRow) { + throw new Error('Workspace not found'); + } + + const nextIsStarred = workspaceRow.isStarred !== 1; + workspaceOriginalPathsDb.updateWorkspaceIsStarred(workspacePath, nextIsStarred); + + return nextIsStarred; +}; + +export const updateWorkspaceNameByPath = ( + workspacePath: string, + workspaceCustomName: string | null, +): void => { + workspaceOriginalPathsDb.updateCustomWorkspaceName(workspacePath, workspaceCustomName); +}; + +export const updateSessionNameById = ( + sessionId: string, + sessionCustomName: string, +): void => { + const sessionMetadata = sessionsDb.getSessionById(sessionId); + if (!sessionMetadata) { + throw new Error('Session not found'); + } + + sessionsDb.updateSessionCustomName(sessionId, sessionCustomName); +}; + +export const deleteSessionById = async ( + sessionId: string, +): Promise => { + const sessionMetadata = sessionsDb.getSessionById(sessionId); + if (!sessionMetadata) { + return { + deleted: false, + jsonlDeleted: false, + }; + } + + const jsonlDeleted = await deleteSessionFileByProvider({ + session_id: sessionMetadata.session_id, + provider: sessionMetadata.provider, + workspace_path: sessionMetadata.workspace_path, + created_at: sessionMetadata.created_at, + }); + + sessionsDb.deleteSession(sessionId); + + return { + deleted: true, + jsonlDeleted, + }; +}; + +export const deleteWorkspaceByPath = async ( + workspacePath: string, +): Promise => { + const sessionRows = sessionsDb.getSessionsByWorkspacePath(workspacePath); + const failedSessionFileDeletes: string[] = []; + let jsonlDeletedCount = 0; + + // Remove all session files first, then clean up DB rows. + for (const sessionRow of sessionRows) { + try { + const deleted = await deleteSessionFileByProvider({ + session_id: sessionRow.session_id, + provider: sessionRow.provider, + workspace_path: sessionRow.workspace_path, + created_at: sessionRow.created_at, + }); + + if (deleted) { + jsonlDeletedCount += 1; + } + } catch { + failedSessionFileDeletes.push(sessionRow.session_id); + } finally { + sessionsDb.deleteSession(sessionRow.session_id); + } + } + + workspaceOriginalPathsDb.deleteWorkspacePath(workspacePath); + + return { + deletedWorkspace: true, + deletedSessionCount: sessionRows.length, + jsonlDeletedCount, + failedSessionFileDeletes, + }; +}; diff --git a/server/src/runner.ts b/server/src/runner.ts index 1f70e455..6435a059 100644 --- a/server/src/runner.ts +++ b/server/src/runner.ts @@ -61,6 +61,7 @@ const [ geminiRoutes, pluginsRoutes, messagesRoutes, + sidebarRoutes, projectsInlineRoutes, filesRoutes, sessionsInlineRoutes, @@ -85,6 +86,7 @@ const [ importRoute('./modules/gemini/gemini.routes.js'), importRoute('./modules/plugins/plugins.routes.js'), importRoute('./modules/messages/messages.routes.js'), + importRoute('./modules/sidebar/sidebar.routes.js'), importRoute('./modules/projects/projects.inline.routes.js'), importRoute('./modules/files/files.routes.js'), importRoute('./modules/sessions/sessions.inline.routes.js'), @@ -174,6 +176,9 @@ app.use('/api/plugins', authenticateToken, pluginsRoutes); // Unified session messages route (protected) app.use('/api/sessions', authenticateToken, messagesRoutes); +// Refactored sidebar routes (protected) +app.use(sidebarRoutes); + // Agent API Routes (uses API key authentication) app.use('/api/agent', agentRoutes); diff --git a/server/src/shared/database/migrations.ts b/server/src/shared/database/migrations.ts index 0fa2ba8c..8ea5d6b4 100644 --- a/server/src/shared/database/migrations.ts +++ b/server/src/shared/database/migrations.ts @@ -49,6 +49,9 @@ export const runMigrations = (db: Database) => { db.exec( "CREATE INDEX IF NOT EXISTS idx_session_ids_lookup ON sessions(session_id)" ); + db.exec( + "CREATE INDEX IF NOT EXISTS idx_sessions_workspace_path ON sessions(workspace_path)" + ); const sessionsTableInfo = db.prepare("PRAGMA table_info(sessions)").all() as { name: string }[]; const sessionColumnNames = sessionsTableInfo.map((col) => col.name); addColumnToTableIfNotExists(db, "sessions", sessionColumnNames, "created_at", "DATETIME"); @@ -66,6 +69,16 @@ export const runMigrations = (db: Database) => { "custom_workspace_name", "TEXT DEFAULT NULL", ); + addColumnToTableIfNotExists( + db, + "workspace_original_paths", + workspaceOriginalPathsColumnNames, + "isStarred", + "BOOLEAN DEFAULT 0", + ); + db.exec( + "CREATE INDEX IF NOT EXISTS idx_workspace_original_paths_is_starred ON workspace_original_paths(isStarred)" + ); db.exec(LAST_SCANNED_AT_SQL); diff --git a/server/src/shared/database/repositories/sessions.db.ts b/server/src/shared/database/repositories/sessions.db.ts index 18cf7212..c57c5861 100644 --- a/server/src/shared/database/repositories/sessions.db.ts +++ b/server/src/shared/database/repositories/sessions.db.ts @@ -90,7 +90,7 @@ export const sessionsDb = { const db = getConnection(); db.prepare( `UPDATE sessions - SET custom_name = ?, updated_at = CURRENT_TIMESTAMP + SET custom_name = ? WHERE session_id = ?` ).run(customName, sessionId); }, @@ -100,7 +100,7 @@ export const sessionsDb = { const db = getConnection(); db.prepare( `UPDATE sessions - SET custom_name = ?, updated_at = CURRENT_TIMESTAMP + SET custom_name = ? WHERE session_id = ? AND provider = ?` ).run(customName, sessionId, provider); }, @@ -118,6 +118,27 @@ export const sessionsDb = { return row ?? null; }, + getAllSessions(): SessionsRow[] { + const db = getConnection(); + return db + .prepare( + `SELECT session_id, provider, workspace_path, custom_name, created_at, updated_at + FROM sessions` + ) + .all() as SessionsRow[]; + }, + + getSessionsByWorkspacePath(workspacePath: string): SessionsRow[] { + const db = getConnection(); + return db + .prepare( + `SELECT session_id, provider, workspace_path, custom_name, created_at, updated_at + FROM sessions + WHERE workspace_path = ?` + ) + .all(workspacePath) as SessionsRow[]; + }, + getSessionName(sessionId: string, provider: string): string | null { const db = getConnection(); const row = db diff --git a/server/src/shared/database/repositories/workspace-original-paths.db.ts b/server/src/shared/database/repositories/workspace-original-paths.db.ts index 31abd605..c77f28bc 100644 --- a/server/src/shared/database/repositories/workspace-original-paths.db.ts +++ b/server/src/shared/database/repositories/workspace-original-paths.db.ts @@ -16,6 +16,25 @@ export const workspaceOriginalPathsDb = { `).run(workspacePath, customWorkspaceName); }, + getWorkspacePath(workspacePath: string): WorkspaceOriginalPathRow | null { + const db = getConnection(); + const row = db.prepare(` + SELECT workspace_path, custom_workspace_name, isStarred + FROM workspace_original_paths + WHERE workspace_path = ? + `).get(workspacePath) as WorkspaceOriginalPathRow | undefined; + + return row ?? null; + }, + + getWorkspacePaths(): WorkspaceOriginalPathRow[] { + const db = getConnection(); + return db.prepare(` + SELECT workspace_path, custom_workspace_name, isStarred + FROM workspace_original_paths + `).all() as WorkspaceOriginalPathRow[]; + }, + getCustomWorkspaceName(workspacePath: string): string | null { const db = getConnection(); const row = db.prepare(` @@ -35,4 +54,21 @@ export const workspaceOriginalPathsDb = { ON CONFLICT(workspace_path) DO UPDATE SET custom_workspace_name = excluded.custom_workspace_name `).run(workspacePath, customWorkspaceName); }, + + updateWorkspaceIsStarred(workspacePath: string, isStarred: boolean): void { + const db = getConnection(); + db.prepare(` + UPDATE workspace_original_paths + SET isStarred = ? + WHERE workspace_path = ? + `).run(isStarred ? 1 : 0, workspacePath); + }, + + deleteWorkspacePath(workspacePath: string): void { + const db = getConnection(); + db.prepare(` + DELETE FROM workspace_original_paths + WHERE workspace_path = ? + `).run(workspacePath); + }, }; diff --git a/server/src/shared/database/schema.ts b/server/src/shared/database/schema.ts index ba36d873..3c825dee 100644 --- a/server/src/shared/database/schema.ts +++ b/server/src/shared/database/schema.ts @@ -86,7 +86,8 @@ CREATE TABLE IF NOT EXISTS sessions ( export const WORK_SPACE_PATH_SQL = ` CREATE TABLE IF NOT EXISTS workspace_original_paths ( workspace_path TEXT PRIMARY KEY NOT NULL, - custom_workspace_name TEXT DEFAULT NULL + custom_workspace_name TEXT DEFAULT NULL, + isStarred BOOLEAN DEFAULT 0 ); ` @@ -135,8 +136,10 @@ CREATE INDEX IF NOT EXISTS idx_push_subscriptions_user_id ON push_subscriptions( ${SESSIONS_TABLE_SCHEMA_SQL} CREATE INDEX IF NOT EXISTS idx_session_ids_lookup ON sessions(session_id); +CREATE INDEX IF NOT EXISTS idx_sessions_workspace_path ON sessions(workspace_path); ${WORK_SPACE_PATH_SQL} +CREATE INDEX IF NOT EXISTS idx_workspace_original_paths_is_starred ON workspace_original_paths(isStarred); ${LAST_SCANNED_AT_SQL} diff --git a/server/src/shared/database/types.ts b/server/src/shared/database/types.ts index fa009e80..b5055f8a 100644 --- a/server/src/shared/database/types.ts +++ b/server/src/shared/database/types.ts @@ -130,6 +130,7 @@ export type SessionWithSummary = { export type WorkspaceOriginalPathRow = { workspace_path: string; custom_workspace_name: string | null; + isStarred: number; // SQLite boolean: 0 | 1 }; diff --git a/src/App.tsx b/src/App.tsx index 986f3f7c..0ac60761 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -14,10 +14,14 @@ const router = createBrowserRouter([ path: "/", element: , // The layout wraps all children children: [ - { + { path: "/", element: , }, + { + path: "/session/:sessionId", + element: , + }, { path: "/sessions/:sessionId", element: , diff --git a/src/components/refactored/sidebar/data/workspacesApi.ts b/src/components/refactored/sidebar/data/workspacesApi.ts index 1d29df94..be08c49e 100644 --- a/src/components/refactored/sidebar/data/workspacesApi.ts +++ b/src/components/refactored/sidebar/data/workspacesApi.ts @@ -1,23 +1,120 @@ +import type { WorkspaceRecord } from '@/components/refactored/sidebar/types'; import { authenticatedFetch } from '@/utils/api'; -import type { Project } from '@/types/app'; -/** - * Data Extractor layer - * Handles fetching workspaces from the API and formatting them. - */ -export const fetchWorkspaces = async (): Promise => { +const SIDEBAR_ENDPOINTS = { + getWorkspaceSessions: '/api/sidebar/get-workspaces-sessions', + updateWorkspaceStar: '/api/sidebar/update-workspace-star', + updateWorkspaceCustomName: '/api/sidebar/update-workspace-custom-name', + updateSessionCustomName: '/api/sidebar/update-session-custom-name', + deleteWorkspace: '/api/sidebar/delete-workspace', + deleteSession: '/api/sidebar/delete-session', +} as const; + +const parseJsonSafely = async (response: Response): Promise => { try { - const response = await authenticatedFetch('/api/projects'); - if (!response.ok) { - throw new Error(`Failed to fetch workspaces: ${response.statusText}`); - } - const data = await response.json(); - - // Normalize response formats depending on the actual backend implementation - return data.projects || data.workspaces || data || []; - } catch (error) { - console.error('Error fetching workspaces:', error); - // Return empty array to gracefully handle failure - return []; + return (await response.json()) as T; + } catch { + return null; + } +}; + +const getErrorMessage = (fallbackMessage: string, payload: unknown): string => { + if ( + payload && + typeof payload === 'object' && + 'error' in payload && + typeof (payload as { error?: unknown }).error === 'string' + ) { + return (payload as { error: string }).error; + } + + return fallbackMessage; +}; + +export const getWorkspaceSessions = async (): Promise => { + const response = await authenticatedFetch(SIDEBAR_ENDPOINTS.getWorkspaceSessions); + const payload = await parseJsonSafely<{ workspaces?: WorkspaceRecord[]; error?: string }>(response); + + if (!response.ok) { + throw new Error(getErrorMessage('Failed to fetch workspaces', payload)); + } + + return payload?.workspaces || []; +}; + +export const updateWorkspaceStar = async ( + workspacePath: string, +): Promise<{ workspacePath: string; isStarred: boolean }> => { + const response = await authenticatedFetch(SIDEBAR_ENDPOINTS.updateWorkspaceStar, { + method: 'PUT', + body: JSON.stringify({ workspacePath }), + }); + const payload = await parseJsonSafely<{ + workspacePath?: string; + isStarred?: boolean; + error?: string; + }>(response); + + if (!response.ok) { + throw new Error(getErrorMessage('Failed to update workspace star', payload)); + } + + return { + workspacePath: payload?.workspacePath || workspacePath, + isStarred: Boolean(payload?.isStarred), + }; +}; + +export const updateWorkspaceCustomName = async ( + workspacePath: string, + workspaceCustomName: string | null, +): Promise => { + const response = await authenticatedFetch(SIDEBAR_ENDPOINTS.updateWorkspaceCustomName, { + method: 'PUT', + body: JSON.stringify({ workspacePath, workspaceCustomName }), + }); + const payload = await parseJsonSafely<{ error?: string }>(response); + + if (!response.ok) { + throw new Error(getErrorMessage('Failed to update workspace name', payload)); + } +}; + +export const deleteWorkspaceByPath = async (workspacePath: string): Promise => { + const response = await authenticatedFetch(SIDEBAR_ENDPOINTS.deleteWorkspace, { + method: 'DELETE', + body: JSON.stringify({ workspacePath }), + }); + const payload = await parseJsonSafely<{ error?: string }>(response); + + if (!response.ok) { + throw new Error(getErrorMessage('Failed to delete workspace', payload)); + } +}; + +export const updateSessionCustomName = async ( + sessionId: string, + sessionCustomName: string, +): Promise => { + const response = await authenticatedFetch(SIDEBAR_ENDPOINTS.updateSessionCustomName, { + method: 'PUT', + body: JSON.stringify({ sessionId, sessionCustomName }), + }); + const payload = await parseJsonSafely<{ error?: string }>(response); + + if (!response.ok) { + throw new Error(getErrorMessage('Failed to update session name', payload)); + } +}; + +export const deleteSessionById = async (sessionId: string): Promise => { + const response = await authenticatedFetch(SIDEBAR_ENDPOINTS.deleteSession, { + method: 'DELETE', + body: JSON.stringify({ sessionId }), + }); + const payload = await parseJsonSafely<{ error?: string }>(response); + + if (!response.ok) { + throw new Error(getErrorMessage('Failed to delete session', payload)); } }; diff --git a/src/components/refactored/sidebar/hooks/useWorkspaces.ts b/src/components/refactored/sidebar/hooks/useWorkspaces.ts index d6983900..65d82db4 100644 --- a/src/components/refactored/sidebar/hooks/useWorkspaces.ts +++ b/src/components/refactored/sidebar/hooks/useWorkspaces.ts @@ -1,33 +1,327 @@ -import { useState, useEffect, useCallback } from 'react'; -import { fetchWorkspaces } from '../data/workspacesApi'; -import type { Project } from '@/types/app'; +import { useCallback, useEffect, useMemo, useState } from 'react'; +import { useLocation, useNavigate } from 'react-router-dom'; + +import { + deleteSessionById, + deleteWorkspaceByPath, + getWorkspaceSessions, + updateSessionCustomName, + updateWorkspaceCustomName, + updateWorkspaceStar, +} from '@/components/refactored/sidebar/data/workspacesApi'; +import type { + SearchMode, + SessionDeleteTarget, + WorkspaceDeleteTarget, + WorkspaceRecord, + WorkspaceSession, +} from '@/components/refactored/sidebar/types'; +import { filterWorkspacesBySearch } from '@/components/refactored/sidebar/utils/search'; +import { + getSessionDisplayName, + getWorkspaceDisplayName, + sortWorkspacesByLastActivity, + splitWorkspacesByStarred, +} from '@/components/refactored/sidebar/utils/workspaceTransforms'; + +const SESSION_ROUTE_PATTERN = /^\/session\/([^/]+)$/; +const LEGACY_SESSION_ROUTE_PATTERN = /^\/sessions\/([^/]+)$/; + +const extractSessionIdFromPathname = (pathname: string): string | null => { + const sessionMatch = pathname.match(SESSION_ROUTE_PATTERN); + if (sessionMatch?.[1]) { + return decodeURIComponent(sessionMatch[1]); + } + + const legacySessionMatch = pathname.match(LEGACY_SESSION_ROUTE_PATTERN); + if (legacySessionMatch?.[1]) { + return decodeURIComponent(legacySessionMatch[1]); + } + + return null; +}; /** * Hook layer (The Manager) - * Manages fetching workspaces and loading states. + * Owns sidebar workspace/session state and coordinates UI actions. */ export const useWorkspaces = () => { - const [workspaces, setWorkspaces] = useState([]); + const navigate = useNavigate(); + const location = useLocation(); + + const [workspaces, setWorkspaces] = useState([]); const [isRefreshing, setIsRefreshing] = useState(false); + const [searchMode, setSearchMode] = useState('projects'); + const [searchFilter, setSearchFilter] = useState(''); + const [expandedWorkspaces, setExpandedWorkspaces] = useState>(new Set()); + const [editingWorkspacePath, setEditingWorkspacePath] = useState(null); + const [editingWorkspaceName, setEditingWorkspaceName] = useState(''); + const [workspaceDeleteTarget, setWorkspaceDeleteTarget] = useState(null); + const [sessionDeleteTarget, setSessionDeleteTarget] = useState(null); + const [isSavingWorkspaceName, setIsSavingWorkspaceName] = useState(false); + const [editingSessionId, setEditingSessionId] = useState(null); + const [editingSessionName, setEditingSessionName] = useState(''); + const [isSavingSessionName, setIsSavingSessionName] = useState(false); + + const selectedSessionId = useMemo( + () => extractSessionIdFromPathname(location.pathname), + [location.pathname], + ); const refreshWorkspaces = useCallback(async () => { setIsRefreshing(true); try { - const data = await fetchWorkspaces(); - setWorkspaces(data); + const fetchedWorkspaces = await getWorkspaceSessions(); + setWorkspaces(sortWorkspacesByLastActivity(fetchedWorkspaces)); + } catch (error) { + console.error('Failed to refresh workspaces:', error); + setWorkspaces([]); } finally { setIsRefreshing(false); } }, []); - // Fetch on mount useEffect(() => { - refreshWorkspaces(); + void refreshWorkspaces(); }, [refreshWorkspaces]); + const filteredWorkspaces = useMemo( + () => filterWorkspacesBySearch(workspaces, searchMode, searchFilter), + [searchFilter, searchMode, workspaces], + ); + + const workspaceGroups = useMemo( + () => splitWorkspacesByStarred(filteredWorkspaces), + [filteredWorkspaces], + ); + + const toggleWorkspace = useCallback((workspacePath: string) => { + setExpandedWorkspaces((previousSet) => { + const nextSet = new Set(previousSet); + + if (nextSet.has(workspacePath)) { + nextSet.delete(workspacePath); + } else { + nextSet.add(workspacePath); + } + + return nextSet; + }); + }, []); + + const openSession = useCallback( + (workspacePath: string, sessionId: string) => { + setExpandedWorkspaces((previousSet) => { + const nextSet = new Set(previousSet); + nextSet.add(workspacePath); + return nextSet; + }); + navigate(`/session/${encodeURIComponent(sessionId)}`); + }, + [navigate], + ); + + const openNewSession = useCallback(() => { + navigate('/'); + }, [navigate]); + + const toggleWorkspaceStar = useCallback(async (workspacePath: string) => { + try { + await updateWorkspaceStar(workspacePath); + await refreshWorkspaces(); + } catch (error) { + console.error('Failed to update workspace star:', error); + } + }, [refreshWorkspaces]); + + const startWorkspaceRename = useCallback((workspace: WorkspaceRecord) => { + setEditingWorkspacePath(workspace.workspaceOriginalPath); + setEditingWorkspaceName(workspace.workspaceCustomName || ''); + }, []); + + const cancelWorkspaceRename = useCallback(() => { + setEditingWorkspacePath(null); + setEditingWorkspaceName(''); + }, []); + + const saveWorkspaceRename = useCallback(async () => { + if (!editingWorkspacePath) { + return; + } + + setIsSavingWorkspaceName(true); + try { + const trimmedName = editingWorkspaceName.trim(); + await updateWorkspaceCustomName(editingWorkspacePath, trimmedName || null); + await refreshWorkspaces(); + cancelWorkspaceRename(); + } catch (error) { + console.error('Failed to update workspace name:', error); + } finally { + setIsSavingWorkspaceName(false); + } + }, [ + cancelWorkspaceRename, + editingWorkspaceName, + editingWorkspacePath, + refreshWorkspaces, + ]); + + const requestWorkspaceDelete = useCallback((workspace: WorkspaceRecord) => { + setWorkspaceDeleteTarget({ + workspacePath: workspace.workspaceOriginalPath, + workspaceName: getWorkspaceDisplayName(workspace), + sessionCount: workspace.sessions.length, + }); + }, []); + + const cancelWorkspaceDelete = useCallback(() => { + setWorkspaceDeleteTarget(null); + }, []); + + const confirmWorkspaceDelete = useCallback(async () => { + if (!workspaceDeleteTarget) { + return; + } + + const deletingWorkspacePath = workspaceDeleteTarget.workspacePath; + setWorkspaceDeleteTarget(null); + try { + await deleteWorkspaceByPath(deletingWorkspacePath); + + // If the current session belonged to the deleted workspace, reset to root. + const hadSelectedSession = workspaces.some( + (workspace) => + workspace.workspaceOriginalPath === deletingWorkspacePath && + workspace.sessions.some((session) => session.sessionId === selectedSessionId), + ); + if (hadSelectedSession) { + navigate('/'); + } + + await refreshWorkspaces(); + } catch (error) { + console.error('Failed to delete workspace:', error); + } + }, [ + navigate, + refreshWorkspaces, + selectedSessionId, + workspaceDeleteTarget, + workspaces, + ]); + + const requestSessionDelete = useCallback( + (workspacePath: string, session: WorkspaceSession) => { + setSessionDeleteTarget({ + sessionId: session.sessionId, + sessionName: getSessionDisplayName(session), + workspacePath, + }); + }, + [], + ); + + const cancelSessionDelete = useCallback(() => { + setSessionDeleteTarget(null); + }, []); + + const startSessionRename = useCallback((session: WorkspaceSession) => { + setEditingSessionId(session.sessionId); + setEditingSessionName(getSessionDisplayName(session)); + }, []); + + const cancelSessionRename = useCallback(() => { + setEditingSessionId(null); + setEditingSessionName(''); + }, []); + + const saveSessionRename = useCallback(async () => { + if (!editingSessionId) { + return; + } + + const trimmedName = editingSessionName.trim(); + if (!trimmedName) { + cancelSessionRename(); + return; + } + + setIsSavingSessionName(true); + try { + await updateSessionCustomName(editingSessionId, trimmedName); + await refreshWorkspaces(); + cancelSessionRename(); + } catch (error) { + console.error('Failed to rename session:', error); + } finally { + setIsSavingSessionName(false); + } + }, [ + cancelSessionRename, + editingSessionId, + editingSessionName, + refreshWorkspaces, + ]); + + const confirmSessionDelete = useCallback(async () => { + if (!sessionDeleteTarget) { + return; + } + + const deletingSessionId = sessionDeleteTarget.sessionId; + setSessionDeleteTarget(null); + + try { + await deleteSessionById(deletingSessionId); + + if (selectedSessionId === deletingSessionId) { + navigate('/'); + } + + await refreshWorkspaces(); + } catch (error) { + console.error('Failed to delete session:', error); + } + }, [navigate, refreshWorkspaces, selectedSessionId, sessionDeleteTarget]); + return { workspaces, + starredWorkspaces: workspaceGroups.starred, + unstarredWorkspaces: workspaceGroups.unstarred, isRefreshing, refreshWorkspaces, + searchMode, + setSearchMode, + searchFilter, + setSearchFilter, + selectedSessionId, + expandedWorkspaces, + toggleWorkspace, + openSession, + openNewSession, + editingWorkspacePath, + editingWorkspaceName, + isSavingWorkspaceName, + editingSessionId, + editingSessionName, + isSavingSessionName, + setEditingWorkspaceName, + setEditingSessionName, + startWorkspaceRename, + cancelWorkspaceRename, + saveWorkspaceRename, + startSessionRename, + cancelSessionRename, + saveSessionRename, + toggleWorkspaceStar, + workspaceDeleteTarget, + sessionDeleteTarget, + requestWorkspaceDelete, + cancelWorkspaceDelete, + confirmWorkspaceDelete, + requestSessionDelete, + cancelSessionDelete, + confirmSessionDelete, }; }; diff --git a/src/components/refactored/sidebar/types/index.ts b/src/components/refactored/sidebar/types/index.ts index b02dac8c..835d2917 100644 --- a/src/components/refactored/sidebar/types/index.ts +++ b/src/components/refactored/sidebar/types/index.ts @@ -1 +1,41 @@ -export type SearchMode = 'projects' | 'conversations'; \ No newline at end of file +import type { SessionProvider } from '@/types/app'; + +export type SearchMode = 'projects' | 'conversations'; + +export type WorkspaceSession = { + sessionId: string; + id: string; + provider: SessionProvider; + customName: string | null; + summary: string; + workspacePath: string; + createdAt: string | null; + updatedAt: string | null; + lastActivity: string | null; +}; + +export type WorkspaceRecord = { + workspaceOriginalPath: string; + workspaceCustomName: string | null; + workspaceDisplayName: string; + isStarred: boolean; + lastActivity: string | null; + sessions: WorkspaceSession[]; +}; + +export type WorkspaceDeleteTarget = { + workspacePath: string; + workspaceName: string; + sessionCount: number; +}; + +export type SessionDeleteTarget = { + sessionId: string; + sessionName: string; + workspacePath: string; +}; + +export type WorkspaceGroups = { + starred: WorkspaceRecord[]; + unstarred: WorkspaceRecord[]; +}; diff --git a/src/components/refactored/sidebar/utils/search.ts b/src/components/refactored/sidebar/utils/search.ts index 44c13553..463bed8e 100644 --- a/src/components/refactored/sidebar/utils/search.ts +++ b/src/components/refactored/sidebar/utils/search.ts @@ -1,16 +1,59 @@ -import type { Project } from '@/types/app'; +import type { + SearchMode, + WorkspaceRecord, +} from '@/components/refactored/sidebar/types'; + +const includesSearch = (value: string | null | undefined, searchText: string): boolean => + (value || '').toLowerCase().includes(searchText); /** - * Filters workspaces/projects by matching the search string against - * both `displayName` and `name` (case-insensitive substring match). + * Filters workspaces and sessions based on search mode. + * In conversations mode, sessions are filtered while preserving workspace context. */ -export const filterWorkspacesByName = (workspaces: Project[], filter: string): Project[] => { - const normalized = filter.trim().toLowerCase(); - if (!normalized) return workspaces; +export const filterWorkspacesBySearch = ( + workspaces: WorkspaceRecord[], + searchMode: SearchMode, + filterText: string, +): WorkspaceRecord[] => { + const normalizedFilter = filterText.trim().toLowerCase(); + if (!normalizedFilter) { + return workspaces; + } - return workspaces.filter((project) => { - const displayName = (project.displayName || project.name).toLowerCase(); - const projectName = project.name.toLowerCase(); - return displayName.includes(normalized) || projectName.includes(normalized); - }); + if (searchMode === 'projects') { + return workspaces.filter((workspace) => + includesSearch(workspace.workspaceDisplayName, normalizedFilter) || + includesSearch(workspace.workspaceCustomName, normalizedFilter) || + includesSearch(workspace.workspaceOriginalPath, normalizedFilter), + ); + } + + return workspaces + .map((workspace) => { + const workspaceMatches = + includesSearch(workspace.workspaceDisplayName, normalizedFilter) || + includesSearch(workspace.workspaceCustomName, normalizedFilter) || + includesSearch(workspace.workspaceOriginalPath, normalizedFilter); + + if (workspaceMatches) { + return workspace; + } + + const matchingSessions = workspace.sessions.filter((session) => + includesSearch(session.customName, normalizedFilter) || + includesSearch(session.summary, normalizedFilter) || + includesSearch(session.sessionId, normalizedFilter) || + includesSearch(session.provider, normalizedFilter), + ); + + if (matchingSessions.length === 0) { + return null; + } + + return { + ...workspace, + sessions: matchingSessions, + }; + }) + .filter((workspace): workspace is WorkspaceRecord => Boolean(workspace)); }; diff --git a/src/components/refactored/sidebar/utils/workspaceTransforms.ts b/src/components/refactored/sidebar/utils/workspaceTransforms.ts new file mode 100644 index 00000000..9deeaeb6 --- /dev/null +++ b/src/components/refactored/sidebar/utils/workspaceTransforms.ts @@ -0,0 +1,123 @@ +import type { + WorkspaceGroups, + WorkspaceRecord, + WorkspaceSession, +} from '@/components/refactored/sidebar/types'; + +const parseTimestamp = (timestamp: string | null | undefined): number => { + if (!timestamp) { + return 0; + } + + // SQLite CURRENT_TIMESTAMP is UTC but does not include timezone metadata. + // Convert it to an explicit UTC ISO-like string before parsing. + const sqliteUtcPattern = /^\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}$/; + const normalizedTimestamp = sqliteUtcPattern.test(timestamp) + ? `${timestamp.replace(' ', 'T')}Z` + : timestamp; + + const parsed = new Date(normalizedTimestamp).getTime(); + return Number.isFinite(parsed) ? parsed : 0; +}; + +export const sortSessionsByLastActivity = ( + sessions: WorkspaceSession[], +): WorkspaceSession[] => + [...sessions].sort((left, right) => { + const timestampDiff = + parseTimestamp(right.lastActivity) - parseTimestamp(left.lastActivity); + + if (timestampDiff !== 0) { + return timestampDiff; + } + + return right.sessionId.localeCompare(left.sessionId); + }); + +export const sortWorkspacesByLastActivity = ( + workspaces: WorkspaceRecord[], +): WorkspaceRecord[] => + [...workspaces].sort((left, right) => { + const timestampDiff = + parseTimestamp(right.lastActivity) - parseTimestamp(left.lastActivity); + + if (timestampDiff !== 0) { + return timestampDiff; + } + + return left.workspaceDisplayName.localeCompare(right.workspaceDisplayName); + }); + +export const splitWorkspacesByStarred = ( + workspaces: WorkspaceRecord[], +): WorkspaceGroups => { + const starred = workspaces.filter((workspace) => workspace.isStarred); + const unstarred = workspaces.filter((workspace) => !workspace.isStarred); + + return { + starred: sortWorkspacesByLastActivity(starred), + unstarred: sortWorkspacesByLastActivity(unstarred), + }; +}; + +export const getWorkspaceDisplayName = (workspace: WorkspaceRecord): string => + workspace.workspaceCustomName || + workspace.workspaceDisplayName || + workspace.workspaceOriginalPath; + +export const getSessionDisplayName = (session: WorkspaceSession): string => + session.customName || session.summary || session.sessionId || 'Untitled Session'; + +export const formatRelativeTime = (timestamp: string | null | undefined): string => { + if (!timestamp) { + return '--'; + } + + const parsedTime = parseTimestamp(timestamp); + if (!Number.isFinite(parsedTime)) { + return '--'; + } + + const diffMs = Math.max(0, Date.now() - parsedTime); + const diffMinutes = Math.floor(diffMs / (1000 * 60)); + const diffHours = Math.floor(diffMs / (1000 * 60 * 60)); + const diffDays = Math.floor(diffMs / (1000 * 60 * 60 * 24)); + const diffMonths = Math.floor(diffDays / 30); + const diffYears = Math.floor(diffDays / 365); + + if (diffMinutes < 1) { + return '1m'; + } + + if (diffMinutes < 60) { + return `${diffMinutes}m`; + } + + if (diffHours < 24) { + return `${diffHours}h`; + } + + if (diffDays < 30) { + return `${diffDays}d`; + } + + if (diffDays < 365) { + return `${diffMonths}mo`; + } + + return `${diffYears}y`; +}; + +export const isRecentActivity = (timestamp: string | null | undefined): boolean => { + if (!timestamp) { + return false; + } + + const parsedTime = parseTimestamp(timestamp); + if (!Number.isFinite(parsedTime)) { + return false; + } + + const diffMs = Math.max(0, Date.now() - parsedTime); + return diffMs <= 10 * 60 * 1000; +}; diff --git a/src/components/refactored/sidebar/view/Sidebar.tsx b/src/components/refactored/sidebar/view/Sidebar.tsx index 1dc71cfb..6c60c0fe 100644 --- a/src/components/refactored/sidebar/view/Sidebar.tsx +++ b/src/components/refactored/sidebar/view/Sidebar.tsx @@ -1,17 +1,69 @@ import { PanelRightOpen } from 'lucide-react'; -import { useSidebarSettings } from '../hooks/useSidebarSettings'; -import { useWorkspaces } from '../hooks/useWorkspaces'; -import { useSidebarModals } from '../hooks/useSidebarModals'; -import SidebarHeader from './SidebarHeader'; +import { useSidebarSettings } from '@/components/refactored/sidebar/hooks/useSidebarSettings'; +import { useSidebarModals } from '@/components/refactored/sidebar/hooks/useSidebarModals'; +import { useWorkspaces } from '@/components/refactored/sidebar/hooks/useWorkspaces'; +import SidebarHeader from '@/components/refactored/sidebar/view/SidebarHeader'; +import { SidebarDeleteModals } from '@/components/refactored/sidebar/view/SidebarDeleteModals'; +import { SidebarWorkspaceList } from '@/components/refactored/sidebar/view/SidebarWorkspaceList'; import { cn } from '@/lib/utils'; import { Button } from '@/shared/view/ui'; import ProjectCreationWizard from '@/components/project-creation-wizard'; export function Sidebar() { const { isCollapsed, toggleCollapse, setCollapsed } = useSidebarSettings(); - const { workspaces, isRefreshing, refreshWorkspaces } = useWorkspaces(); + const { + workspaces, + starredWorkspaces, + unstarredWorkspaces, + isRefreshing, + refreshWorkspaces, + searchMode, + setSearchMode, + searchFilter, + setSearchFilter, + selectedSessionId, + expandedWorkspaces, + toggleWorkspace, + openSession, + openNewSession, + editingWorkspacePath, + editingWorkspaceName, + isSavingWorkspaceName, + editingSessionId, + editingSessionName, + isSavingSessionName, + setEditingWorkspaceName, + setEditingSessionName, + startWorkspaceRename, + cancelWorkspaceRename, + saveWorkspaceRename, + startSessionRename, + cancelSessionRename, + saveSessionRename, + toggleWorkspaceStar, + workspaceDeleteTarget, + sessionDeleteTarget, + requestWorkspaceDelete, + cancelWorkspaceDelete, + confirmWorkspaceDelete, + requestSessionDelete, + cancelSessionDelete, + confirmSessionDelete, + } = useWorkspaces(); const { showNewProject, openNewProject, closeNewProject } = useSidebarModals(); + const handleSessionDeleteRequest = (workspacePath: string, sessionId: string) => { + const workspace = workspaces.find( + (workspaceItem) => workspaceItem.workspaceOriginalPath === workspacePath, + ); + const session = workspace?.sessions.find((item) => item.sessionId === sessionId); + if (!workspace || !session) { + return; + } + + requestSessionDelete(workspacePath, session); + }; + return ( <> <> @@ -38,12 +90,41 @@ export function Sidebar() { isRefreshing={isRefreshing} onRefresh={refreshWorkspaces} onNewProject={openNewProject} + searchMode={searchMode} + onSearchModeChange={setSearchMode} + searchFilter={searchFilter} + onSearchFilterChange={setSearchFilter} /> - {/* Placeholder for the rest of the sidebar content */} {!isCollapsed && (
- {/* Future list component will go here */} - {/* Can pass workspaces to the future list component as props */} +
)} @@ -54,7 +135,7 @@ export function Sidebar() { + + + + , + document.body, + )} + + {sessionDeleteTarget && + ReactDOM.createPortal( +
+
+
+
+
+ +
+
+

Delete session

+

+ Delete{' '} + + {sessionDeleteTarget.sessionName} + + ? +

+
+

+ The associated JSONL session file will also be deleted. +

+
+

+ This action cannot be undone. +

+
+
+
+
+ + +
+
+
, + document.body, + )} + + ); +} diff --git a/src/components/refactored/sidebar/view/SidebarHeader.tsx b/src/components/refactored/sidebar/view/SidebarHeader.tsx index 96cf68c1..38c3e2fb 100644 --- a/src/components/refactored/sidebar/view/SidebarHeader.tsx +++ b/src/components/refactored/sidebar/view/SidebarHeader.tsx @@ -1,7 +1,7 @@ -import { useState } from 'react'; import { FolderPlus, Plus, RefreshCw, PanelLeftClose } from 'lucide-react'; -import type { SearchMode } from '../types'; -import { SidebarSearch } from './SidebarSearch'; + +import type { SearchMode } from '@/components/refactored/sidebar/types'; +import { SidebarSearch } from '@/components/refactored/sidebar/view/SidebarSearch'; import { Button } from '@/shared/view/ui'; import { cn } from '@/lib/utils'; import { IS_PLATFORM } from '@/constants/config'; @@ -12,6 +12,10 @@ type SidebarHeaderProps = { isRefreshing: boolean; onRefresh: () => void; onNewProject: () => void; + searchMode: SearchMode; + onSearchModeChange: (mode: SearchMode) => void; + searchFilter: string; + onSearchFilterChange: (value: string) => void; }; export default function SidebarHeader({ @@ -19,12 +23,12 @@ export default function SidebarHeader({ onToggleCollapse, isRefreshing, onRefresh, - onNewProject + onNewProject, + searchMode, + onSearchModeChange, + searchFilter, + onSearchFilterChange, }: SidebarHeaderProps) { - // UI States for search - const [searchMode, setSearchMode] = useState('projects'); - const [searchFilter, setSearchFilter] = useState(''); - const LogoBlock = () => (
@@ -63,7 +67,7 @@ export default function SidebarHeader({
@@ -108,14 +112,14 @@ export default function SidebarHeader({
@@ -135,4 +139,4 @@ export default function SidebarHeader({
); -} \ No newline at end of file +} diff --git a/src/components/refactored/sidebar/view/SidebarSearch.tsx b/src/components/refactored/sidebar/view/SidebarSearch.tsx index aac36d5b..5179a30a 100644 --- a/src/components/refactored/sidebar/view/SidebarSearch.tsx +++ b/src/components/refactored/sidebar/view/SidebarSearch.tsx @@ -1,7 +1,7 @@ import { Folder, MessageSquare, Search, X } from 'lucide-react'; import { Input } from '@/shared/view/ui'; import { cn } from '@/lib/utils'; -import { SearchMode } from '@/components/refactored/sidebar/types/index.js'; +import type { SearchMode } from '@/components/refactored/sidebar/types'; type SidebarSearchProps = { diff --git a/src/components/refactored/sidebar/view/SidebarSessionItem.tsx b/src/components/refactored/sidebar/view/SidebarSessionItem.tsx new file mode 100644 index 00000000..9475d4bc --- /dev/null +++ b/src/components/refactored/sidebar/view/SidebarSessionItem.tsx @@ -0,0 +1,196 @@ +import { Check, Edit2, Trash2, X } from 'lucide-react'; + +import SessionProviderLogo from '@/components/llm-logo-provider/SessionProviderLogo'; +import type { WorkspaceSession } from '@/components/refactored/sidebar/types'; +import { + formatRelativeTime, + getSessionDisplayName, + isRecentActivity, +} from '@/components/refactored/sidebar/utils/workspaceTransforms'; +import { cn } from '@/lib/utils'; +import { Button } from '@/shared/view/ui'; + +type SidebarSessionItemProps = { + session: WorkspaceSession; + isSelected: boolean; + isEditing: boolean; + editingSessionName: string; + isSavingSessionName: boolean; + onEditingSessionNameChange: (name: string) => void; + onStartEdit: () => void; + onCancelEdit: () => void; + onSaveEdit: () => void; + onSelect: () => void; + onDelete: () => void; +}; + +export function SidebarSessionItem({ + session, + isSelected, + isEditing, + editingSessionName, + isSavingSessionName, + onEditingSessionNameChange, + onStartEdit, + onCancelEdit, + onSaveEdit, + onSelect, + onDelete, +}: SidebarSessionItemProps) { + const sessionName = getSessionDisplayName(session); + const sessionActivityLabel = formatRelativeTime(session.lastActivity); + const showRecentBadge = isRecentActivity(session.lastActivity); + + const handleSaveEdit = () => { + if (!isSavingSessionName) { + onSaveEdit(); + } + }; + + return ( +
+
+
+
+
+ +
+ +
+
+
+ {sessionName} +
+
+ {showRecentBadge && } + {sessionActivityLabel} +
+
+
+ + +
+
+
+ +
+ + + {isEditing ? ( +
+ + +
+ ) : ( +
+ + +
+ )} +
+
+ ); +} diff --git a/src/components/refactored/sidebar/view/SidebarWorkspaceItem.tsx b/src/components/refactored/sidebar/view/SidebarWorkspaceItem.tsx new file mode 100644 index 00000000..88c19e68 --- /dev/null +++ b/src/components/refactored/sidebar/view/SidebarWorkspaceItem.tsx @@ -0,0 +1,420 @@ +import { + Check, + ChevronDown, + ChevronRight, + Edit3, + Folder, + FolderOpen, + Plus, + Star, + Trash2, + X, +} from 'lucide-react'; + +import { SidebarSessionItem } from '@/components/refactored/sidebar/view/SidebarSessionItem'; +import type { WorkspaceRecord } from '@/components/refactored/sidebar/types'; +import { getWorkspaceDisplayName } from '@/components/refactored/sidebar/utils/workspaceTransforms'; +import { cn } from '@/lib/utils'; +import { Button } from '@/shared/view/ui'; + +type SidebarWorkspaceItemProps = { + workspace: WorkspaceRecord; + isExpanded: boolean; + selectedSessionId: string | null; + editingWorkspacePath: string | null; + editingWorkspaceName: string; + isSavingWorkspaceName: boolean; + editingSessionId: string | null; + editingSessionName: string; + isSavingSessionName: boolean; + onEditingWorkspaceNameChange: (name: string) => void; + onEditingSessionNameChange: (name: string) => void; + onToggleWorkspace: (workspacePath: string) => void; + onToggleWorkspaceStar: (workspacePath: string) => void; + onStartWorkspaceRename: (workspace: WorkspaceRecord) => void; + onCancelWorkspaceRename: () => void; + onSaveWorkspaceRename: () => void; + onStartSessionRename: (session: WorkspaceRecord['sessions'][number]) => void; + onCancelSessionRename: () => void; + onSaveSessionRename: () => void; + onDeleteWorkspace: (workspace: WorkspaceRecord) => void; + onSessionSelect: (workspacePath: string, sessionId: string) => void; + onSessionDelete: (workspacePath: string, sessionId: string) => void; + onNewSession: () => void; +}; + +export function SidebarWorkspaceItem({ + workspace, + isExpanded, + selectedSessionId, + editingWorkspacePath, + editingWorkspaceName, + isSavingWorkspaceName, + editingSessionId, + editingSessionName, + isSavingSessionName, + onEditingWorkspaceNameChange, + onEditingSessionNameChange, + onToggleWorkspace, + onToggleWorkspaceStar, + onStartWorkspaceRename, + onCancelWorkspaceRename, + onSaveWorkspaceRename, + onStartSessionRename, + onCancelSessionRename, + onSaveSessionRename, + onDeleteWorkspace, + onSessionSelect, + onSessionDelete, + onNewSession, +}: SidebarWorkspaceItemProps) { + const isEditing = editingWorkspacePath === workspace.workspaceOriginalPath; + const hasSelectedSession = workspace.sessions.some( + (session) => session.sessionId === selectedSessionId, + ); + const workspaceName = getWorkspaceDisplayName(workspace); + const sessionCountLabel = `${workspace.sessions.length} session${ + workspace.sessions.length === 1 ? '' : 's' + }`; + + const handleSaveRename = () => { + if (!isSavingWorkspaceName) { + onSaveWorkspaceRename(); + } + }; + + return ( +
+
+
+
onToggleWorkspace(workspace.workspaceOriginalPath)} + > +
+
+
+ {isExpanded ? ( + + ) : ( + + )} +
+
+ {isEditing ? ( + onEditingWorkspaceNameChange(event.target.value)} + className="w-full rounded-lg border-2 border-primary/40 bg-background px-3 py-2 text-sm text-foreground shadow-sm transition-all duration-200 focus:border-primary focus:shadow-md focus:outline-none" + placeholder="Workspace name" + autoFocus + autoComplete="off" + onClick={(event) => event.stopPropagation()} + onKeyDown={(event) => { + if (event.key === 'Enter') { + handleSaveRename(); + } + + if (event.key === 'Escape') { + onCancelWorkspaceRename(); + } + }} + style={{ + fontSize: '16px', + WebkitAppearance: 'none', + borderRadius: '8px', + }} + /> + ) : ( + <> +

{workspaceName}

+

{sessionCountLabel}

+ + )} +
+
+ +
+ {isEditing ? ( + <> + + + + ) : ( + <> + + + + + + +
+ {isExpanded ? ( + + ) : ( + + )} +
+ + )} +
+
+
+
+ + +
+ + {isExpanded && ( +
+ {workspace.sessions.length === 0 ? ( +
+

No sessions yet

+
+ ) : ( + workspace.sessions.map((session) => ( + onStartSessionRename(session)} + onCancelEdit={onCancelSessionRename} + onSaveEdit={onSaveSessionRename} + onSelect={() => + onSessionSelect(workspace.workspaceOriginalPath, session.sessionId) + } + onDelete={() => + onSessionDelete(workspace.workspaceOriginalPath, session.sessionId) + } + /> + )) + )} + +
+ +
+ + +
+ )} +
+ ); +} diff --git a/src/components/refactored/sidebar/view/SidebarWorkspaceList.tsx b/src/components/refactored/sidebar/view/SidebarWorkspaceList.tsx new file mode 100644 index 00000000..35099b83 --- /dev/null +++ b/src/components/refactored/sidebar/view/SidebarWorkspaceList.tsx @@ -0,0 +1,169 @@ +import type { WorkspaceRecord } from '@/components/refactored/sidebar/types'; +import { SidebarWorkspaceItem } from '@/components/refactored/sidebar/view/SidebarWorkspaceItem'; + +type SidebarWorkspaceListProps = { + workspacesCount: number; + searchFilter: string; + starredWorkspaces: WorkspaceRecord[]; + unstarredWorkspaces: WorkspaceRecord[]; + expandedWorkspaces: Set; + selectedSessionId: string | null; + editingWorkspacePath: string | null; + editingWorkspaceName: string; + isSavingWorkspaceName: boolean; + editingSessionId: string | null; + editingSessionName: string; + isSavingSessionName: boolean; + onEditingWorkspaceNameChange: (name: string) => void; + onEditingSessionNameChange: (name: string) => void; + onToggleWorkspace: (workspacePath: string) => void; + onToggleWorkspaceStar: (workspacePath: string) => void; + onStartWorkspaceRename: (workspace: WorkspaceRecord) => void; + onCancelWorkspaceRename: () => void; + onSaveWorkspaceRename: () => void; + onStartSessionRename: (session: WorkspaceRecord['sessions'][number]) => void; + onCancelSessionRename: () => void; + onSaveSessionRename: () => void; + onDeleteWorkspace: (workspace: WorkspaceRecord) => void; + onSessionSelect: (workspacePath: string, sessionId: string) => void; + onSessionDelete: (workspacePath: string, sessionId: string) => void; + onNewSession: () => void; +}; + +const SectionHeading = ({ title }: { title: string }) => ( +
+

{title}

+
+); + +const EmptyState = ({ title, description }: { title: string; description: string }) => ( +
+

{title}

+

{description}

+
+); + +/** + * Component layer (The Face) + * Displays Starred and regular workspace sections with shared item rendering. + */ +export function SidebarWorkspaceList({ + workspacesCount, + searchFilter, + starredWorkspaces, + unstarredWorkspaces, + expandedWorkspaces, + selectedSessionId, + editingWorkspacePath, + editingWorkspaceName, + isSavingWorkspaceName, + editingSessionId, + editingSessionName, + isSavingSessionName, + onEditingWorkspaceNameChange, + onEditingSessionNameChange, + onToggleWorkspace, + onToggleWorkspaceStar, + onStartWorkspaceRename, + onCancelWorkspaceRename, + onSaveWorkspaceRename, + onStartSessionRename, + onCancelSessionRename, + onSaveSessionRename, + onDeleteWorkspace, + onSessionSelect, + onSessionDelete, + onNewSession, +}: SidebarWorkspaceListProps) { + const visibleWorkspaceCount = starredWorkspaces.length + unstarredWorkspaces.length; + + if (workspacesCount === 0) { + return ( + + ); + } + + if (visibleWorkspaceCount === 0) { + return ( + + ); + } + + return ( +
+ {starredWorkspaces.length > 0 && ( + <> + + {starredWorkspaces.map((workspace) => ( + + ))} + + )} + + {unstarredWorkspaces.length > 0 && ( + <> + + {unstarredWorkspaces.map((workspace) => ( + + ))} + + )} +
+ ); +}