diff --git a/server/modules/database/migrations.ts b/server/modules/database/migrations.ts index 60debe52..5b0490cb 100644 --- a/server/modules/database/migrations.ts +++ b/server/modules/database/migrations.ts @@ -257,8 +257,10 @@ const rebuildSessionsTableWithProjectSchema = (db: Database): void => { if (!shouldRebuild) { addColumnToTableIfNotExists(db, 'sessions', columnNames, 'jsonl_path', 'TEXT'); + addColumnToTableIfNotExists(db, 'sessions', columnNames, 'isArchived', 'BOOLEAN DEFAULT 0'); addColumnToTableIfNotExists(db, 'sessions', columnNames, 'created_at', 'DATETIME'); addColumnToTableIfNotExists(db, 'sessions', columnNames, 'updated_at', 'DATETIME'); + db.exec('UPDATE sessions SET isArchived = COALESCE(isArchived, 0)'); db.exec('UPDATE sessions SET created_at = COALESCE(created_at, CURRENT_TIMESTAMP)'); db.exec('UPDATE sessions SET updated_at = COALESCE(updated_at, CURRENT_TIMESTAMP)'); return; @@ -284,6 +286,10 @@ const rebuildSessionsTableWithProjectSchema = (db: Database): void => { ? 'jsonl_path' : 'NULL'; + const isArchivedExpression = columnNames.includes('isArchived') + ? 'COALESCE(isArchived, 0)' + : '0'; + const createdAtExpression = columnNames.includes('created_at') ? 'COALESCE(created_at, CURRENT_TIMESTAMP)' : 'CURRENT_TIMESTAMP'; @@ -303,6 +309,7 @@ const rebuildSessionsTableWithProjectSchema = (db: Database): void => { custom_name TEXT, project_path TEXT, jsonl_path TEXT, + isArchived BOOLEAN DEFAULT 0, created_at DATETIME DEFAULT CURRENT_TIMESTAMP, updated_at DATETIME DEFAULT CURRENT_TIMESTAMP, PRIMARY KEY (session_id), @@ -319,6 +326,7 @@ const rebuildSessionsTableWithProjectSchema = (db: Database): void => { ${customNameExpression} AS custom_name, ${projectPathExpression} AS project_path, ${jsonlPathExpression} AS jsonl_path, + ${isArchivedExpression} AS isArchived, ${createdAtExpression} AS created_at, ${updatedAtExpression} AS updated_at, rowid AS source_rowid @@ -332,6 +340,7 @@ const rebuildSessionsTableWithProjectSchema = (db: Database): void => { custom_name, project_path, jsonl_path, + isArchived, created_at, updated_at, ROW_NUMBER() OVER ( @@ -346,6 +355,7 @@ const rebuildSessionsTableWithProjectSchema = (db: Database): void => { custom_name, project_path, jsonl_path, + isArchived, created_at, updated_at ) @@ -355,6 +365,7 @@ const rebuildSessionsTableWithProjectSchema = (db: Database): void => { custom_name, project_path, jsonl_path, + isArchived, created_at, updated_at FROM ranked_rows @@ -421,6 +432,7 @@ 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_project_path ON sessions(project_path)'); + db.exec('CREATE INDEX IF NOT EXISTS idx_sessions_is_archived ON sessions(isArchived)'); db.exec('CREATE INDEX IF NOT EXISTS idx_projects_is_starred ON projects(isStarred)'); db.exec('CREATE INDEX IF NOT EXISTS idx_projects_is_archived ON projects(isArchived)'); diff --git a/server/modules/database/repositories/projects.db.ts b/server/modules/database/repositories/projects.db.ts index c99b8a54..ddbec8fa 100644 --- a/server/modules/database/repositories/projects.db.ts +++ b/server/modules/database/repositories/projects.db.ts @@ -95,6 +95,19 @@ export const projectsDb = { `).all() as ProjectRepositoryRow[]; }, + /** + * Archived rows are queried separately so archive-focused UIs can present + * hidden workspaces without reintroducing them into the active sidebar list. + */ + getArchivedProjectPaths(): ProjectRepositoryRow[] { + const db = getConnection(); + return db.prepare(` + SELECT project_id, project_path, custom_project_name, isStarred, isArchived + FROM projects + WHERE isArchived = 1 + `).all() as ProjectRepositoryRow[]; + }, + getCustomProjectName(projectPath: string): string | null { const db = getConnection(); const normalizedProjectPath = normalizeProjectPath(projectPath); diff --git a/server/modules/database/repositories/sessions.db.integration.test.ts b/server/modules/database/repositories/sessions.db.integration.test.ts new file mode 100644 index 00000000..d14ec5ae --- /dev/null +++ b/server/modules/database/repositories/sessions.db.integration.test.ts @@ -0,0 +1,72 @@ +import assert from 'node:assert/strict'; +import { mkdtemp, rm } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import path from 'node:path'; +import test from 'node:test'; + +import { closeConnection } from '@/modules/database/connection.js'; +import { initializeDatabase } from '@/modules/database/init-db.js'; +import { sessionsDb } from '@/modules/database/repositories/sessions.db.js'; + +async function withIsolatedDatabase(runTest: () => void | Promise): Promise { + const previousDatabasePath = process.env.DATABASE_PATH; + const tempDirectory = await mkdtemp(path.join(tmpdir(), 'sessions-db-')); + const databasePath = path.join(tempDirectory, 'auth.db'); + + closeConnection(); + process.env.DATABASE_PATH = databasePath; + await initializeDatabase(); + + try { + await runTest(); + } finally { + closeConnection(); + if (previousDatabasePath === undefined) { + delete process.env.DATABASE_PATH; + } else { + process.env.DATABASE_PATH = previousDatabasePath; + } + await rm(tempDirectory, { recursive: true, force: true }); + } +} + +test('session archive queries hide archived rows from active project views', async () => { + await withIsolatedDatabase(() => { + sessionsDb.createSession('session-active', 'claude', '/workspace/demo-project', 'Active Session'); + sessionsDb.createSession('session-archived', 'claude', '/workspace/demo-project', 'Archived Session'); + sessionsDb.updateSessionIsArchived('session-archived', true); + + const activeSessions = sessionsDb.getAllSessions(); + const archivedSessions = sessionsDb.getArchivedSessions(); + const activeProjectSessions = sessionsDb.getSessionsByProjectPath('/workspace/demo-project'); + const allProjectSessions = sessionsDb.getSessionsByProjectPathIncludingArchived('/workspace/demo-project'); + + assert.deepEqual(activeSessions.map((session) => session.session_id), ['session-active']); + assert.deepEqual(archivedSessions.map((session) => session.session_id), ['session-archived']); + assert.deepEqual(activeProjectSessions.map((session) => session.session_id), ['session-active']); + assert.deepEqual( + allProjectSessions.map((session) => session.session_id).sort(), + ['session-active', 'session-archived'], + ); + assert.equal(sessionsDb.countSessionsByProjectPath('/workspace/demo-project'), 1); + }); +}); + +test('createSession reactivates archived rows when the session becomes active again', async () => { + await withIsolatedDatabase(() => { + sessionsDb.createSession('session-reused', 'claude', '/workspace/demo-project', 'First Name'); + sessionsDb.updateSessionIsArchived('session-reused', true); + + sessionsDb.createSession('session-reused', 'claude', '/workspace/demo-project', 'Updated Name'); + + const activeSessions = sessionsDb.getAllSessions(); + const archivedSessions = sessionsDb.getArchivedSessions(); + const restoredSession = sessionsDb.getSessionById('session-reused'); + + assert.equal(activeSessions.length, 1); + assert.equal(activeSessions[0]?.session_id, 'session-reused'); + assert.equal(activeSessions[0]?.custom_name, 'Updated Name'); + assert.equal(archivedSessions.length, 0); + assert.equal(restoredSession?.isArchived, 0); + }); +}); diff --git a/server/modules/database/repositories/sessions.db.ts b/server/modules/database/repositories/sessions.db.ts index 19a96a56..d79fdeb8 100644 --- a/server/modules/database/repositories/sessions.db.ts +++ b/server/modules/database/repositories/sessions.db.ts @@ -8,13 +8,14 @@ type SessionRow = { project_path: string | null; jsonl_path: string | null; custom_name: string | null; + isArchived: number; created_at: string; updated_at: string; }; type SessionMetadataLookupRow = Pick< SessionRow, - 'session_id' | 'provider' | 'project_path' | 'jsonl_path' | 'custom_name' | 'created_at' | 'updated_at' + 'session_id' | 'provider' | 'project_path' | 'jsonl_path' | 'custom_name' | 'isArchived' | 'created_at' | 'updated_at' >; function normalizeTimestamp(value?: string): string | null { @@ -53,13 +54,14 @@ export const sessionsDb = { projectsDb.createProjectPath(normalizedProjectPath); db.prepare( - `INSERT INTO sessions (session_id, provider, custom_name, project_path, jsonl_path, created_at, updated_at) - VALUES (?, ?, ?, ?, ?, COALESCE(?, CURRENT_TIMESTAMP), COALESCE(?, CURRENT_TIMESTAMP)) + `INSERT INTO sessions (session_id, provider, custom_name, project_path, jsonl_path, isArchived, created_at, updated_at) + VALUES (?, ?, ?, ?, ?, 0, COALESCE(?, CURRENT_TIMESTAMP), COALESCE(?, CURRENT_TIMESTAMP)) ON CONFLICT(session_id) DO UPDATE SET provider = excluded.provider, updated_at = excluded.updated_at, project_path = excluded.project_path, jsonl_path = excluded.jsonl_path, + isArchived = 0, custom_name = COALESCE(excluded.custom_name, sessions.custom_name)` ).run( sessionId, @@ -87,7 +89,7 @@ export const sessionsDb = { const db = getConnection(); const row = db .prepare( - `SELECT session_id, provider, project_path, jsonl_path, custom_name, created_at, updated_at + `SELECT session_id, provider, project_path, jsonl_path, custom_name, isArchived, created_at, updated_at FROM sessions WHERE session_id = ? ORDER BY updated_at DESC @@ -102,8 +104,25 @@ export const sessionsDb = { const db = getConnection(); return db .prepare( - `SELECT session_id, provider, project_path, jsonl_path, custom_name, created_at, updated_at - FROM sessions` + `SELECT session_id, provider, project_path, jsonl_path, custom_name, isArchived, created_at, updated_at + FROM sessions + WHERE isArchived = 0` + ) + .all() as SessionRow[]; + }, + + /** + * Archived rows are intentionally queried separately so the caller can render + * them in a dedicated view without reintroducing them into active session lists. + */ + getArchivedSessions(): SessionRow[] { + const db = getConnection(); + return db + .prepare( + `SELECT session_id, provider, project_path, jsonl_path, custom_name, isArchived, created_at, updated_at + FROM sessions + WHERE isArchived = 1 + ORDER BY datetime(COALESCE(updated_at, created_at)) DESC, session_id DESC` ) .all() as SessionRow[]; }, @@ -113,7 +132,24 @@ export const sessionsDb = { const normalizedProjectPath = normalizeProjectPath(projectPath); return db .prepare( - `SELECT session_id, provider, project_path, jsonl_path, custom_name, created_at, updated_at + `SELECT session_id, provider, project_path, jsonl_path, custom_name, isArchived, created_at, updated_at + FROM sessions + WHERE project_path = ? + AND isArchived = 0` + ) + .all(normalizedProjectPath) as SessionRow[]; + }, + + /** + * Permanent project deletion must see every session row for the path, + * including archived ones, so their transcript files can be cleaned up. + */ + getSessionsByProjectPathIncludingArchived(projectPath: string): SessionRow[] { + const db = getConnection(); + const normalizedProjectPath = normalizeProjectPath(projectPath); + return db + .prepare( + `SELECT session_id, provider, project_path, jsonl_path, custom_name, isArchived, created_at, updated_at FROM sessions WHERE project_path = ?` ) @@ -125,9 +161,10 @@ export const sessionsDb = { const normalizedProjectPath = normalizeProjectPath(projectPath); return db .prepare( - `SELECT session_id, provider, project_path, jsonl_path, custom_name, created_at, updated_at + `SELECT session_id, provider, project_path, jsonl_path, custom_name, isArchived, created_at, updated_at FROM sessions WHERE project_path = ? + AND isArchived = 0 ORDER BY datetime(COALESCE(updated_at, created_at)) DESC, session_id DESC LIMIT ? OFFSET ?` ) @@ -141,7 +178,8 @@ export const sessionsDb = { .prepare( `SELECT COUNT(*) AS count FROM sessions - WHERE project_path = ?` + WHERE project_path = ? + AND isArchived = 0` ) .get(normalizedProjectPath) as { count: number } | undefined; @@ -167,6 +205,19 @@ export const sessionsDb = { return row?.custom_name ?? null; }, + /** + * Soft-delete and restore both use the same flag update so callers keep the + * row, metadata, and file path intact while toggling visibility. + */ + updateSessionIsArchived(sessionId: string, isArchived: boolean): void { + const db = getConnection(); + db.prepare( + `UPDATE sessions + SET isArchived = ? + WHERE session_id = ?` + ).run(isArchived ? 1 : 0, sessionId); + }, + deleteSessionById(sessionId: string): boolean { const db = getConnection(); return db.prepare('DELETE FROM sessions WHERE session_id = ?').run(sessionId).changes > 0; diff --git a/server/modules/database/schema.ts b/server/modules/database/schema.ts index 7af3d80d..b3639af2 100644 --- a/server/modules/database/schema.ts +++ b/server/modules/database/schema.ts @@ -86,6 +86,7 @@ CREATE TABLE IF NOT EXISTS sessions ( custom_name TEXT, project_path TEXT, jsonl_path TEXT, + isArchived BOOLEAN DEFAULT 0, created_at DATETIME DEFAULT CURRENT_TIMESTAMP, updated_at DATETIME DEFAULT CURRENT_TIMESTAMP, PRIMARY KEY (session_id), diff --git a/server/modules/projects/projects.routes.ts b/server/modules/projects/projects.routes.ts index a1c94352..5b52425c 100644 --- a/server/modules/projects/projects.routes.ts +++ b/server/modules/projects/projects.routes.ts @@ -3,9 +3,9 @@ import express from 'express'; import { createProject, updateProjectDisplayName } from '@/modules/projects/services/project-management.service.js'; import { startCloneProject } from '@/modules/projects/services/project-clone.service.js'; import { getProjectTaskMaster } from '@/modules/projects/services/projects-has-taskmaster.service.js'; -import { AppError, asyncHandler } from '@/shared/utils.js'; -import { getProjectSessionsPage, getProjectsWithSessions } from '@/modules/projects/services/projects-with-sessions-fetch.service.js'; -import { deleteOrArchiveProject } from '@/modules/projects/services/project-delete.service.js'; +import { AppError, asyncHandler, createApiSuccessResponse } from '@/shared/utils.js'; +import { getArchivedProjectsWithSessions, getProjectSessionsPage, getProjectsWithSessions } from '@/modules/projects/services/projects-with-sessions-fetch.service.js'; +import { deleteOrArchiveProject, restoreArchivedProject } from '@/modules/projects/services/project-delete.service.js'; import { applyLegacyStarredProjectIds, toggleProjectStar } from '@/modules/projects/services/project-star.service.js'; const router = express.Router(); @@ -73,6 +73,14 @@ router.get( }), ); +router.get( + '/archived', + asyncHandler(async (_req, res) => { + const projects = await getArchivedProjectsWithSessions(); + res.json(createApiSuccessResponse({ projects })); + }), +); + router.get( '/:projectId/sessions', asyncHandler(async (req, res) => { @@ -230,6 +238,15 @@ router.post( }), ); +router.post( + '/:projectId/restore', + asyncHandler(async (req, res) => { + const projectId = typeof req.params.projectId === 'string' ? req.params.projectId : ''; + restoreArchivedProject(projectId); + res.json(createApiSuccessResponse({ projectId, isArchived: false })); + }), +); + /** * - `force` not set / false: archive project in DB only (`isArchived` = 1; hidden from active list). * - `force=true`: remove DB row, delete session rows for that path, remove all `*.jsonl` under the Claude project dir. diff --git a/server/modules/projects/services/project-delete.service.ts b/server/modules/projects/services/project-delete.service.ts index a743b4b6..cbb1c7ed 100644 --- a/server/modules/projects/services/project-delete.service.ts +++ b/server/modules/projects/services/project-delete.service.ts @@ -42,7 +42,7 @@ async function unlinkJsonlIfExists(filePath: string): Promise { * Loads all session rows for the project path and removes each distinct `jsonl_path` file on disk. */ export async function deleteSessionJsonlFilesForProjectPath(projectPath: string): Promise { - const sessions = sessionsDb.getSessionsByProjectPath(projectPath); + const sessions = sessionsDb.getSessionsByProjectPathIncludingArchived(projectPath); const paths = uniqueJsonlPathsFromSessions(sessions); for (const filePath of paths) { @@ -73,3 +73,18 @@ export async function deleteOrArchiveProject(projectId: string, force: boolean): sessionsDb.deleteSessionsByProjectPath(row.project_path); projectsDb.deleteProjectById(projectId); } + +/** + * Restores one archived project row back into the active project list. + */ +export function restoreArchivedProject(projectId: string): void { + const row = projectsDb.getProjectById(projectId); + if (!row) { + throw new AppError(`Unknown projectId: ${projectId}`, { + code: 'PROJECT_NOT_FOUND', + statusCode: 404, + }); + } + + projectsDb.updateProjectIsArchivedById(projectId, false); +} diff --git a/server/modules/projects/services/projects-with-sessions-fetch.service.ts b/server/modules/projects/services/projects-with-sessions-fetch.service.ts index 4d473a21..55a5d6d8 100644 --- a/server/modules/projects/services/projects-with-sessions-fetch.service.ts +++ b/server/modules/projects/services/projects-with-sessions-fetch.service.ts @@ -40,6 +40,10 @@ export type ProjectListItem = { }; }; +export type ArchivedProjectListItem = ProjectListItem & { + isArchived: true; +}; + type ProgressUpdate = { phase: 'loading' | 'complete'; current: number; @@ -150,6 +154,16 @@ function bucketSessionRowsByProvider(rows: SessionRepositoryRow[]): SessionsByPr return byProvider; } +function readProjectSessionsIncludingArchived(projectPath: string): ProjectSessionsPageResult { + const rows = sessionsDb.getSessionsByProjectPathIncludingArchived(projectPath) as SessionRepositoryRow[]; + + return { + sessionsByProvider: bucketSessionRowsByProvider(rows), + total: rows.length, + hasMore: false, + }; +} + /** * Reads one paginated project session slice from the DB and groups rows by provider. */ @@ -255,6 +269,56 @@ export async function getProjectsWithSessions( return projects; } +/** + * Reads archived projects from DB and includes every session row for each + * project path, because an archived workspace should surface all preserved + * conversation history in the archive view regardless of each session's flag. + */ +export async function getArchivedProjectsWithSessions( + options: Pick = {}, +): Promise { + if (!options.skipSynchronization) { + await sessionSynchronizerService.synchronizeSessions(); + } + + const projectRows = projectsDb.getArchivedProjectPaths() as Array<{ + project_id: string; + project_path: string; + custom_project_name?: string | null; + isStarred?: number; + }>; + + const archivedProjects: ArchivedProjectListItem[] = []; + + for (const row of projectRows) { + const displayName = + row.custom_project_name && row.custom_project_name.trim().length > 0 + ? row.custom_project_name + : await generateDisplayName(path.basename(row.project_path) || row.project_path, row.project_path); + + const sessionsPage = readProjectSessionsIncludingArchived(row.project_path); + + archivedProjects.push({ + projectId: row.project_id, + path: row.project_path, + displayName, + fullPath: row.project_path, + isStarred: Boolean(row.isStarred), + isArchived: true, + sessions: sessionsPage.sessionsByProvider.claude, + cursorSessions: sessionsPage.sessionsByProvider.cursor, + codexSessions: sessionsPage.sessionsByProvider.codex, + geminiSessions: sessionsPage.sessionsByProvider.gemini, + sessionMeta: { + hasMore: sessionsPage.hasMore, + total: sessionsPage.total, + }, + }); + } + + return archivedProjects; +} + /** * Loads one paginated session slice for a specific project id. */ diff --git a/server/modules/providers/provider.routes.ts b/server/modules/providers/provider.routes.ts index af6d16d6..ea95f83d 100644 --- a/server/modules/providers/provider.routes.ts +++ b/server/modules/providers/provider.routes.ts @@ -311,12 +311,33 @@ router.post( ); // ----------------- Session routes ----------------- +router.get( + '/sessions/archived', + asyncHandler(async (_req: Request, res: Response) => { + const sessions = sessionsService.listArchivedSessions(); + res.json(createApiSuccessResponse({ sessions })); + }), +); + router.delete( '/sessions/:sessionId', asyncHandler(async (req: Request, res: Response) => { const sessionId = parseSessionId(req.params.sessionId); - const deletedFromDisk = parseOptionalBooleanQuery(req.query.deletedFromDisk, 'deletedFromDisk') ?? false; - const result = await sessionsService.deleteSessionById(sessionId, deletedFromDisk); + const force = parseOptionalBooleanQuery(req.query.force, 'force') ?? false; + const deletedFromDisk = parseOptionalBooleanQuery(req.query.deletedFromDisk, 'deletedFromDisk') ?? force; + const result = await sessionsService.deleteOrArchiveSessionById(sessionId, { + force, + deletedFromDisk, + }); + res.json(createApiSuccessResponse(result)); + }), +); + +router.post( + '/sessions/:sessionId/restore', + asyncHandler(async (req: Request, res: Response) => { + const sessionId = parseSessionId(req.params.sessionId); + const result = sessionsService.restoreSessionById(sessionId); res.json(createApiSuccessResponse(result)); }), ); diff --git a/server/modules/providers/services/sessions.service.ts b/server/modules/providers/services/sessions.service.ts index 32572e95..49b5dcb7 100644 --- a/server/modules/providers/services/sessions.service.ts +++ b/server/modules/providers/services/sessions.service.ts @@ -1,6 +1,7 @@ import fsp from 'node:fs/promises'; +import path from 'node:path'; -import { sessionsDb } from '@/modules/database/index.js'; +import { projectsDb, sessionsDb } from '@/modules/database/index.js'; import { providerRegistry } from '@/modules/providers/provider.registry.js'; import type { FetchHistoryOptions, @@ -10,6 +11,19 @@ import type { } from '@/shared/types.js'; import { AppError } from '@/shared/utils.js'; +type ArchivedSessionListItem = { + sessionId: string; + provider: LLMProvider; + projectId: string | null; + projectPath: string | null; + projectDisplayName: string; + sessionTitle: string; + createdAt: string | null; + updatedAt: string | null; + lastActivity: string | null; + isProjectArchived: boolean; +}; + /** * Removes one file if it exists. */ @@ -26,6 +40,28 @@ async function removeFileIfExists(filePath: string): Promise { } } +/** + * Archive rows need a stable project label even when the owning project is not + * part of the active sidebar payload. This lightweight resolver keeps the + * archive API self-contained while still matching the project's stored display + * name when one exists. + */ +function resolveProjectDisplayName( + projectPath: string | null, + customProjectName: string | null | undefined, +): string { + const trimmedCustomName = typeof customProjectName === 'string' ? customProjectName.trim() : ''; + if (trimmedCustomName.length > 0) { + return trimmedCustomName; + } + + if (!projectPath) { + return 'Unknown Project'; + } + + return path.basename(projectPath) || projectPath; +} + /** * Application service for provider-backed session message operations. * @@ -79,15 +115,53 @@ export const sessionsService = { }, /** - * Deletes one persisted session row by id. - * - * When `deletedFromDisk` is true and a session `jsonl_path` exists, the path - * is deleted from disk before the DB row is removed. + * Returns archived sessions with enough project metadata for the sidebar to + * group, filter, open, and restore them without a per-row follow-up query. */ - async deleteSessionById( + listArchivedSessions(): ArchivedSessionListItem[] { + const archivedSessions = sessionsDb.getArchivedSessions(); + const projectCache = new Map>(); + + return archivedSessions.map((session) => { + const projectPath = session.project_path?.trim() ? session.project_path : null; + let project = null; + + if (projectPath) { + if (!projectCache.has(projectPath)) { + projectCache.set(projectPath, projectsDb.getProjectPath(projectPath)); + } + project = projectCache.get(projectPath) ?? null; + } + + return { + sessionId: session.session_id, + provider: session.provider as LLMProvider, + projectId: project?.project_id ?? null, + projectPath, + projectDisplayName: resolveProjectDisplayName(projectPath, project?.custom_project_name), + sessionTitle: session.custom_name?.trim() || session.session_id, + createdAt: session.created_at ?? null, + updatedAt: session.updated_at ?? null, + lastActivity: session.updated_at ?? session.created_at ?? null, + isProjectArchived: Boolean(project?.isArchived), + }; + }); + }, + + /** + * Archives or permanently deletes one persisted session row by id. + * + * Soft-delete mirrors the project behavior by toggling `isArchived` so the + * row disappears from active lists but remains restorable. Force-delete + * optionally removes the transcript file before deleting the database row. + */ + async deleteOrArchiveSessionById( sessionId: string, - deletedFromDisk = false, - ): Promise<{ sessionId: string; deletedFromDisk: boolean }> { + options: { + force?: boolean; + deletedFromDisk?: boolean; + } = {}, + ): Promise<{ sessionId: string; action: 'archived' | 'deleted'; deletedFromDisk: boolean }> { const session = sessionsDb.getSessionById(sessionId); if (!session) { throw new AppError(`Session "${sessionId}" was not found.`, { @@ -96,8 +170,17 @@ export const sessionsService = { }); } + if (!options.force) { + sessionsDb.updateSessionIsArchived(sessionId, true); + return { + sessionId, + action: 'archived', + deletedFromDisk: false, + }; + } + let removedFromDisk = false; - if (deletedFromDisk && session.jsonl_path) { + if (options.deletedFromDisk && session.jsonl_path) { removedFromDisk = await removeFileIfExists(session.jsonl_path); } @@ -109,7 +192,27 @@ export const sessionsService = { }); } - return { sessionId, deletedFromDisk: removedFromDisk }; + return { + sessionId, + action: 'deleted', + deletedFromDisk: removedFromDisk, + }; + }, + + /** + * Restores one archived session back into the active sidebar lists. + */ + restoreSessionById(sessionId: string): { sessionId: string; isArchived: false } { + const session = sessionsDb.getSessionById(sessionId); + if (!session) { + throw new AppError(`Session "${sessionId}" was not found.`, { + code: 'SESSION_NOT_FOUND', + statusCode: 404, + }); + } + + sessionsDb.updateSessionIsArchived(sessionId, false); + return { sessionId, isArchived: false }; }, /** diff --git a/src/components/sidebar/hooks/useSidebarController.ts b/src/components/sidebar/hooks/useSidebarController.ts index d950bc43..ba559442 100644 --- a/src/components/sidebar/hooks/useSidebarController.ts +++ b/src/components/sidebar/hooks/useSidebarController.ts @@ -5,8 +5,11 @@ import { api } from '../../../utils/api'; import { usePaletteOps } from '../../../contexts/PaletteOpsContext'; import type { Project, ProjectSession, LLMProvider } from '../../../types/app'; import type { + ArchivedProjectListItem, + ArchivedSessionListItem, DeleteProjectConfirmation, ProjectSortOrder, + SidebarSearchMode, SessionDeleteConfirmation, SessionWithProvider, } from '../types/types'; @@ -60,6 +63,20 @@ export type SearchProgress = { totalProjects: number; }; +type ArchivedSessionsApiPayload = { + success?: boolean; + data?: { + sessions?: ArchivedSessionListItem[]; + }; +}; + +type ArchivedProjectsApiPayload = { + success?: boolean; + data?: { + projects?: ArchivedProjectListItem[]; + }; +}; + type UseSidebarControllerArgs = { projects: Project[]; selectedProject: Project | null; @@ -112,10 +129,13 @@ export function useSidebarController({ const [deleteConfirmation, setDeleteConfirmation] = useState(null); const [sessionDeleteConfirmation, setSessionDeleteConfirmation] = useState(null); const [showVersionModal, setShowVersionModal] = useState(false); - const [searchMode, setSearchMode] = useState<'projects' | 'conversations'>('projects'); + const [searchMode, setSearchMode] = useState('projects'); const [conversationResults, setConversationResults] = useState(null); const [isSearching, setIsSearching] = useState(false); const [searchProgress, setSearchProgress] = useState(null); + const [archivedProjects, setArchivedProjects] = useState([]); + const [archivedSessions, setArchivedSessions] = useState([]); + const [isArchivedSessionsLoading, setIsArchivedSessionsLoading] = useState(false); const [debouncedSearchQuery, setDebouncedSearchQuery] = useState(''); const [optimisticStarByProjectId, setOptimisticStarByProjectId] = useState>(new Map()); const [loadingMoreProjects, setLoadingMoreProjects] = useState>(new Set()); @@ -201,6 +221,40 @@ export function useSidebarController({ onRefreshRef.current = onRefresh; }, [onRefresh]); + const fetchArchivedSessions = useCallback(async () => { + setIsArchivedSessionsLoading(true); + + try { + const [archivedProjectsResponse, archivedSessionsResponse] = await Promise.all([ + api.archivedProjects(), + api.getArchivedSessions(), + ]); + + if (!archivedProjectsResponse.ok) { + throw new Error(`Failed to load archived projects: ${archivedProjectsResponse.status}`); + } + + if (!archivedSessionsResponse.ok) { + throw new Error(`Failed to load archived sessions: ${archivedSessionsResponse.status}`); + } + + const archivedProjectsPayload = (await archivedProjectsResponse.json()) as ArchivedProjectsApiPayload; + const archivedSessionsPayload = (await archivedSessionsResponse.json()) as ArchivedSessionsApiPayload; + const nextProjects = Array.isArray(archivedProjectsPayload.data?.projects) ? archivedProjectsPayload.data.projects : []; + const archivedProjectIds = new Set(nextProjects.map((project) => project.projectId)); + const nextStandaloneSessions = Array.isArray(archivedSessionsPayload.data?.sessions) + ? archivedSessionsPayload.data.sessions.filter((session) => !session.projectId || !archivedProjectIds.has(session.projectId)) + : []; + + setArchivedProjects(nextProjects); + setArchivedSessions(nextStandaloneSessions); + } catch (error) { + console.error('[Sidebar] Failed to load archived sessions:', error); + } finally { + setIsArchivedSessionsLoading(false); + } + }, []); + useEffect(() => { if (migrationStartedRef.current) { return; @@ -227,6 +281,20 @@ export function useSidebarController({ void migrateLegacyStars(); }, [onRefresh]); + useEffect(() => { + void fetchArchivedSessions(); + }, [fetchArchivedSessions]); + + useEffect(() => { + if (searchMode !== 'archived') { + return; + } + + // Refresh archive contents when the archived tab opens so restore actions + // and background synchronizer updates are reflected without a full reload. + void fetchArchivedSessions(); + }, [fetchArchivedSessions, searchMode]); + useEffect(() => { setOptimisticStarByProjectId((previous) => { if (previous.size === 0) { @@ -519,6 +587,56 @@ export function useSidebarController({ [debouncedSearchQuery, sortedProjects], ); + const filteredArchivedSessions = useMemo(() => { + const normalizedSearch = debouncedSearchQuery.trim().toLowerCase(); + if (!normalizedSearch) { + return archivedSessions; + } + + return archivedSessions.filter((session) => { + const searchableFields = [ + session.sessionTitle, + session.projectDisplayName, + session.projectPath ?? '', + session.provider, + ]; + + return searchableFields.some((value) => value.toLowerCase().includes(normalizedSearch)); + }); + }, [archivedSessions, debouncedSearchQuery]); + + const filteredArchivedProjects = useMemo(() => { + const normalizedSearch = debouncedSearchQuery.trim().toLowerCase(); + if (!normalizedSearch) { + return archivedProjects; + } + + return archivedProjects.filter((project) => { + const projectMatches = [ + project.displayName, + project.fullPath || '', + ].some((value) => value.toLowerCase().includes(normalizedSearch)); + + if (projectMatches) { + return true; + } + + return getAllSessions(project).some((session) => { + const sessionSummary = + typeof session.summary === 'string' && session.summary.trim().length > 0 + ? session.summary + : typeof session.name === 'string' + ? session.name + : ''; + + return [ + sessionSummary, + session.__provider, + ].some((value) => value.toLowerCase().includes(normalizedSearch)); + }); + }); + }, [archivedProjects, debouncedSearchQuery]); + const startEditing = useCallback((project: Project) => { // `editingProject` is keyed by projectId so it stays stable across // display-name mutations that happen while the input is open. @@ -556,17 +674,26 @@ export function useSidebarController({ // Kept with project/provider arguments for component wiring compatibility; // deletion now uses only `sessionId` via /api/providers/sessions/:sessionId. ( - projectId: string, + projectId: string | null, sessionId: string, sessionTitle: string, provider: SessionDeleteConfirmation['provider'] = 'claude', + options: { + isArchived?: boolean; + } = {}, ) => { - setSessionDeleteConfirmation({ projectId, sessionId, sessionTitle, provider }); + setSessionDeleteConfirmation({ + projectId, + sessionId, + sessionTitle, + provider, + isArchived: Boolean(options.isArchived), + }); }, [], ); - const confirmDeleteSession = useCallback(async () => { + const confirmDeleteSession = useCallback(async (hardDelete = false) => { if (!sessionDeleteConfirmation) { return; } @@ -575,10 +702,11 @@ export function useSidebarController({ setSessionDeleteConfirmation(null); try { - const response = await api.deleteSession(sessionId); + const response = await api.deleteSession(sessionId, hardDelete); if (response.ok) { onSessionDelete?.(sessionId); + await fetchArchivedSessions(); } else { const errorText = await response.text(); console.error('[Sidebar] Failed to delete session:', { @@ -591,7 +719,7 @@ export function useSidebarController({ console.error('[Sidebar] Error deleting session:', error); alert(t('messages.deleteSessionError')); } - }, [onSessionDelete, sessionDeleteConfirmation, t]); + }, [fetchArchivedSessions, onSessionDelete, sessionDeleteConfirmation, t]); const requestProjectDelete = useCallback( (project: Project) => { @@ -647,14 +775,88 @@ export function useSidebarController({ [onProjectSelect, setCurrentProject], ); + const openArchivedSession = useCallback((session: ArchivedSessionListItem) => { + const activeProject = session.projectId + ? projects.find((candidate) => candidate.projectId === session.projectId) + : null; + const archivedProject = session.projectId + ? archivedProjects.find((candidate) => candidate.projectId === session.projectId) + : null; + const matchingProject = activeProject ?? archivedProject ?? null; + const sessionPayload: ProjectSession = { + id: session.sessionId, + summary: session.sessionTitle, + __provider: session.provider, + __projectId: matchingProject?.projectId ?? session.projectId ?? undefined, + }; + + // Archived sessions still need a selected project context. Active projects + // come from the normal sidebar list, while archived-project sessions resolve + // through the archive payload loaded by this controller. + if (matchingProject) { + handleProjectSelect(matchingProject); + } + + onSessionSelect(sessionPayload); + }, [archivedProjects, handleProjectSelect, onSessionSelect, projects]); + + const restoreArchivedProject = useCallback(async (projectId: string) => { + try { + const response = await api.restoreProject(projectId); + if (!response.ok) { + const errorText = await response.text(); + console.error('[Sidebar] Failed to restore project:', { + status: response.status, + error: errorText, + }); + alert(t('messages.restoreProjectFailed', 'Failed to restore project. Please try again.')); + return; + } + + await Promise.all([ + Promise.resolve(onRefresh()), + fetchArchivedSessions(), + ]); + } catch (error) { + console.error('[Sidebar] Error restoring project:', error); + alert(t('messages.restoreProjectError', 'Error restoring project. Please try again.')); + } + }, [fetchArchivedSessions, onRefresh, t]); + + const restoreArchivedSession = useCallback(async (sessionId: string) => { + try { + const response = await api.restoreSession(sessionId); + if (!response.ok) { + const errorText = await response.text(); + console.error('[Sidebar] Failed to restore session:', { + status: response.status, + error: errorText, + }); + alert(t('messages.restoreSessionFailed', 'Failed to restore session. Please try again.')); + return; + } + + await Promise.all([ + Promise.resolve(onRefresh()), + fetchArchivedSessions(), + ]); + } catch (error) { + console.error('[Sidebar] Error restoring session:', error); + alert(t('messages.restoreSessionError', 'Error restoring session. Please try again.')); + } + }, [fetchArchivedSessions, onRefresh, t]); + const refreshProjects = useCallback(async () => { setIsRefreshing(true); try { - await onRefresh(); + await Promise.all([ + Promise.resolve(onRefresh()), + fetchArchivedSessions(), + ]); } finally { setIsRefreshing(false); } - }, [onRefresh]); + }, [fetchArchivedSessions, onRefresh]); const updateSessionSummary = useCallback( // `_projectId` and `_provider` are preserved for compatibility with @@ -712,6 +914,10 @@ export function useSidebarController({ sessionDeleteConfirmation, showVersionModal, filteredProjects, + archivedProjects: filteredArchivedProjects, + archivedSessions: filteredArchivedSessions, + archivedSessionsCount: archivedProjects.length + archivedSessions.length, + isArchivedSessionsLoading, toggleProject, handleSessionClick, toggleStarProject, @@ -726,6 +932,9 @@ export function useSidebarController({ requestProjectDelete, confirmDeleteProject, handleProjectSelect, + openArchivedSession, + restoreArchivedProject, + restoreArchivedSession, refreshProjects, updateSessionSummary, collapseSidebar, diff --git a/src/components/sidebar/types/types.ts b/src/components/sidebar/types/types.ts index 6db25126..0f44cf29 100644 --- a/src/components/sidebar/types/types.ts +++ b/src/components/sidebar/types/types.ts @@ -1,11 +1,26 @@ import type { LoadingProgress, Project, ProjectSession, LLMProvider } from '../../../types/app'; export type ProjectSortOrder = 'name' | 'date'; +export type SidebarSearchMode = 'projects' | 'conversations' | 'archived'; +export type ArchivedProjectListItem = Project & { isArchived: true }; export type SessionWithProvider = ProjectSession & { __provider: LLMProvider; }; +export type ArchivedSessionListItem = { + sessionId: string; + provider: LLMProvider; + projectId: string | null; + projectPath: string | null; + projectDisplayName: string; + sessionTitle: string; + createdAt: string | null; + updatedAt: string | null; + lastActivity: string | null; + isProjectArchived: boolean; +}; + export type DeleteProjectConfirmation = { project: Project; sessionCount: number; @@ -14,10 +29,11 @@ export type DeleteProjectConfirmation = { // Delete confirmation payload used by sidebar UX. `projectId`/`provider` are // kept for wiring compatibility, while API deletion now keys only by sessionId. export type SessionDeleteConfirmation = { - projectId: string; + projectId: string | null; sessionId: string; sessionTitle: string; provider: LLMProvider; + isArchived: boolean; }; export type SidebarProps = { diff --git a/src/components/sidebar/view/Sidebar.tsx b/src/components/sidebar/view/Sidebar.tsx index 97484b01..ebc046c5 100644 --- a/src/components/sidebar/view/Sidebar.tsx +++ b/src/components/sidebar/view/Sidebar.tsx @@ -75,6 +75,10 @@ function Sidebar({ sessionDeleteConfirmation, showVersionModal, filteredProjects, + archivedProjects, + archivedSessions, + archivedSessionsCount, + isArchivedSessionsLoading, toggleProject, handleSessionClick, toggleStarProject, @@ -90,6 +94,9 @@ function Sidebar({ requestProjectDelete, confirmDeleteProject, handleProjectSelect, + openArchivedSession, + restoreArchivedProject, + restoreArchivedSession, refreshProjects, updateSessionSummary, collapseSidebar: handleCollapseSidebar, @@ -184,8 +191,8 @@ function Sidebar({ return ( <> - ) : ( <> - setSearchFilter('')} searchMode={searchMode} - onSearchModeChange={(mode: 'projects' | 'conversations') => { + onSearchModeChange={(mode) => { setSearchMode(mode); if (mode === 'projects') clearConversationResults(); }} conversationResults={conversationResults} isSearching={isSearching} searchProgress={searchProgress} + onRestoreArchivedProject={restoreArchivedProject} + onArchivedSessionClick={openArchivedSession} + onRestoreArchivedSession={restoreArchivedSession} + onDeleteArchivedSession={(session) => { + showDeleteSessionConfirmation( + session.projectId, + session.sessionId, + session.sessionTitle, + session.provider, + { isArchived: true }, + ); + }} onConversationResultClick={(projectId: string | null, sessionId: string, provider: string, messageTimestamp?: string | null, messageSnippet?: string | null) => { // `projectId` (DB key) is the canonical identifier post-migration. // The server emits null when it can't resolve a project row for diff --git a/src/components/sidebar/view/subcomponents/SidebarContent.tsx b/src/components/sidebar/view/subcomponents/SidebarContent.tsx index 3e675f2d..5ce63b8b 100644 --- a/src/components/sidebar/view/subcomponents/SidebarContent.tsx +++ b/src/components/sidebar/view/subcomponents/SidebarContent.tsx @@ -1,15 +1,16 @@ import { type ReactNode } from 'react'; -import { Folder, MessageSquare, Search } from 'lucide-react'; +import { Archive, Folder, MessageSquare, RotateCcw, Search, Trash2 } from 'lucide-react'; import type { TFunction } from 'i18next'; import { ScrollArea } from '../../../../shared/view/ui'; import type { Project } from '../../../../types/app'; import type { ReleaseInfo } from '../../../../types/sharedTypes'; import type { ConversationSearchResults, SearchProgress } from '../../hooks/useSidebarController'; +import type { ArchivedProjectListItem, ArchivedSessionListItem, SidebarSearchMode } from '../../types/types'; +import SessionProviderLogo from '../../../llm-logo-provider/SessionProviderLogo'; import SidebarFooter from './SidebarFooter'; import SidebarHeader from './SidebarHeader'; import SidebarProjectList, { type SidebarProjectListProps } from './SidebarProjectList'; - -type SearchMode = 'projects' | 'conversations'; +import { getAllSessions } from '../../utils/utils'; function HighlightedSnippet({ snippet, highlights }: { snippet: string; highlights: { start: number; end: number }[] }) { const parts: ReactNode[] = []; @@ -35,19 +36,100 @@ function HighlightedSnippet({ snippet, highlights }: { snippet: string; highligh ); } +type ArchivedSessionGroup = { + key: string; + projectId: string | null; + projectDisplayName: string; + projectPath: string | null; + isProjectArchived: boolean; + sessions: ArchivedSessionListItem[]; + latestActivity: string | null; +}; + +/** + * Groups archived sessions by project metadata so the archive view preserves + * the same mental model as the active sidebar: projects first, then sessions. + */ +function groupArchivedSessionsByProject(sessions: ArchivedSessionListItem[]): ArchivedSessionGroup[] { + const groups = new Map(); + + for (const session of sessions) { + const key = session.projectId ?? session.projectPath ?? `session:${session.sessionId}`; + const existingGroup = groups.get(key); + + if (existingGroup) { + existingGroup.sessions.push(session); + if (!existingGroup.latestActivity || (session.lastActivity && session.lastActivity > existingGroup.latestActivity)) { + existingGroup.latestActivity = session.lastActivity; + } + continue; + } + + groups.set(key, { + key, + projectId: session.projectId, + projectDisplayName: session.projectDisplayName, + projectPath: session.projectPath, + isProjectArchived: session.isProjectArchived, + sessions: [session], + latestActivity: session.lastActivity, + }); + } + + return [...groups.values()].sort((groupA, groupB) => { + const a = groupA.latestActivity ?? ''; + const b = groupB.latestActivity ?? ''; + return b.localeCompare(a); + }); +} + +function formatCompactArchivedAge(dateString: string | null): string { + if (!dateString) { + return ''; + } + + const date = new Date(dateString); + if (Number.isNaN(date.getTime())) { + return ''; + } + + const diffInMinutes = Math.floor(Math.max(0, Date.now() - date.getTime()) / (1000 * 60)); + if (diffInMinutes < 1) { + return '<1m'; + } + if (diffInMinutes < 60) { + return `${diffInMinutes}m`; + } + + const diffInHours = Math.floor(diffInMinutes / 60); + if (diffInHours < 24) { + return `${diffInHours}hr`; + } + + return `${Math.floor(diffInHours / 24)}d`; +} + type SidebarContentProps = { isPWA: boolean; isMobile: boolean; isLoading: boolean; projects: Project[]; + archivedProjects: ArchivedProjectListItem[]; + archivedSessions: ArchivedSessionListItem[]; + archivedSessionsCount: number; + isArchivedSessionsLoading: boolean; searchFilter: string; onSearchFilterChange: (value: string) => void; onClearSearchFilter: () => void; - searchMode: SearchMode; - onSearchModeChange: (mode: SearchMode) => void; + searchMode: SidebarSearchMode; + onSearchModeChange: (mode: SidebarSearchMode) => void; conversationResults: ConversationSearchResults | null; isSearching: boolean; searchProgress: SearchProgress | null; + onRestoreArchivedProject: (projectId: string) => void; + onArchivedSessionClick: (session: ArchivedSessionListItem) => void; + onRestoreArchivedSession: (sessionId: string) => void; + onDeleteArchivedSession: (session: ArchivedSessionListItem) => void; // Conversation result clicks pass back the DB projectId (or null when the // server couldn't resolve it). Consumers must handle the null case. onConversationResultClick: (projectId: string | null, sessionId: string, provider: string, messageTimestamp?: string | null, messageSnippet?: string | null) => void; @@ -70,6 +152,10 @@ export default function SidebarContent({ isMobile, isLoading, projects, + archivedProjects, + archivedSessions, + archivedSessionsCount, + isArchivedSessionsLoading, searchFilter, onSearchFilterChange, onClearSearchFilter, @@ -78,6 +164,10 @@ export default function SidebarContent({ conversationResults, isSearching, searchProgress, + onRestoreArchivedProject, + onArchivedSessionClick, + onRestoreArchivedSession, + onDeleteArchivedSession, onConversationResultClick, onRefresh, isRefreshing, @@ -94,6 +184,7 @@ export default function SidebarContent({ }: SidebarContentProps) { const showConversationSearch = searchMode === 'conversations' && searchFilter.trim().length >= 2; const hasPartialResults = conversationResults && conversationResults.results.length > 0; + const groupedArchivedSessions = groupArchivedSessionsByProject(archivedSessions); return (
) : null + ) : searchMode === 'archived' ? ( + isArchivedSessionsLoading ? ( +
+
+
+
+

+ {t('archived.loadingTitle', 'Loading archive...')} +

+

+ {t('archived.loadingDescription', 'Fetching hidden workspaces and sessions you can restore later.')} +

+
+ ) : archivedProjects.length === 0 && groupedArchivedSessions.length === 0 ? ( +
+
+ +
+

+ {archivedSessionsCount > 0 + ? t('archived.noMatchingSessions', 'No matching archived items') + : t('archived.emptyTitle', 'No archived items')} +

+

+ {archivedSessionsCount > 0 + ? t('archived.tryDifferentSearch', 'Try a different search term.') + : t('archived.emptyDescription', 'Archived workspaces and sessions will appear here when you hide them from the active list.')} +

+
+ ) : ( +
+
+

+ {`${archivedSessionsCount} ${t( + archivedSessionsCount === 1 ? 'archived.sessionCountOne' : 'archived.sessionCountOther', + archivedSessionsCount === 1 ? 'archived item' : 'archived items', + )}`} +

+
+ {archivedProjects.map((project) => { + const projectSessions = getAllSessions(project); + + return ( +
+
+
+
+ + + {project.displayName} + + + {t('archived.projectArchived', 'Project archived')} + +
+

+ {project.fullPath} +

+
+ +
+ {projectSessions.length > 0 && ( +
+ {projectSessions.map((session) => ( + + ))} +
+ )} +
+ ); + })} + {groupedArchivedSessions.map((group) => ( +
+
+
+
+ + + {group.projectDisplayName} + + {group.isProjectArchived && ( + + {t('archived.projectArchived', 'Project archived')} + + )} +
+ {group.projectPath && ( +

+ {group.projectPath} +

+ )} +
+ + {group.sessions.length} + +
+
+ {group.sessions.map((session) => ( +
+ + + +
+ ))} +
+
+ ))} +
+ ) ) : ( )} diff --git a/src/components/sidebar/view/subcomponents/SidebarHeader.tsx b/src/components/sidebar/view/subcomponents/SidebarHeader.tsx index ab1eed7e..5117b8db 100644 --- a/src/components/sidebar/view/subcomponents/SidebarHeader.tsx +++ b/src/components/sidebar/view/subcomponents/SidebarHeader.tsx @@ -1,25 +1,26 @@ -import { Folder, FolderPlus, MessageSquare, Plus, RefreshCw, Search, X, PanelLeftClose } from 'lucide-react'; +import { Archive, Folder, FolderPlus, MessageSquare, Plus, RefreshCw, Search, X, PanelLeftClose } from 'lucide-react'; import type { TFunction } from 'i18next'; -import { Button, Input } from '../../../../shared/view/ui'; +import { Button, Input, Tooltip } from '../../../../shared/view/ui'; import { IS_PLATFORM } from '../../../../constants/config'; import { cn } from '../../../../lib/utils'; +import type { SidebarSearchMode } from '../../types/types'; import GitHubStarBadge from './GitHubStarBadge'; const MOD_KEY = typeof navigator !== 'undefined' && /Mac|iPhone|iPad/.test(navigator.platform) ? '⌘' : 'Ctrl'; -type SearchMode = 'projects' | 'conversations'; - type SidebarHeaderProps = { isPWA: boolean; isMobile: boolean; isLoading: boolean; projectsCount: number; + archivedSessionsCount: number; + isArchivedSessionsLoading: boolean; searchFilter: string; onSearchFilterChange: (value: string) => void; onClearSearchFilter: () => void; - searchMode: SearchMode; - onSearchModeChange: (mode: SearchMode) => void; + searchMode: SidebarSearchMode; + onSearchModeChange: (mode: SidebarSearchMode) => void; onRefresh: () => void; isRefreshing: boolean; onCreateProject: () => void; @@ -32,6 +33,8 @@ export default function SidebarHeader({ isMobile, isLoading, projectsCount, + archivedSessionsCount, + isArchivedSessionsLoading, searchFilter, onSearchFilterChange, onClearSearchFilter, @@ -43,6 +46,13 @@ export default function SidebarHeader({ onCollapseSidebar, t, }: SidebarHeaderProps) { + const showSearchTools = (projectsCount > 0 || archivedSessionsCount > 0 || isArchivedSessionsLoading) && !isLoading; + const searchPlaceholder = searchMode === 'conversations' + ? t('search.conversationsPlaceholder') + : searchMode === 'archived' + ? t('search.archivedPlaceholder', 'Search archived sessions...') + : t('projects.searchPlaceholder'); + const LogoBlock = () => (
@@ -113,7 +123,7 @@ export default function SidebarHeader({ {/* Search bar */} - {projectsCount > 0 && !isLoading && ( + {showSearchTools && (
{/* Search mode toggle */}
@@ -143,12 +153,28 @@ export default function SidebarHeader({ {t('search.modeConversations')} + + +
onSearchFilterChange(event.target.value)} className="nav-search-input h-9 rounded-xl border-0 pl-9 pr-14 text-sm transition-all duration-200 placeholder:text-muted-foreground/40 focus-visible:ring-0 focus-visible:ring-offset-0" @@ -215,7 +241,7 @@ export default function SidebarHeader({
{/* Mobile search */} - {projectsCount > 0 && !isLoading && ( + {showSearchTools && (
+ + +
onSearchFilterChange(event.target.value)} className="nav-search-input h-10 rounded-xl border-0 pl-10 pr-9 text-sm transition-all duration-200 placeholder:text-muted-foreground/40 focus-visible:ring-0 focus-visible:ring-offset-0" diff --git a/src/components/sidebar/view/subcomponents/SidebarModals.tsx b/src/components/sidebar/view/subcomponents/SidebarModals.tsx index 27127541..28404940 100644 --- a/src/components/sidebar/view/subcomponents/SidebarModals.tsx +++ b/src/components/sidebar/view/subcomponents/SidebarModals.tsx @@ -25,7 +25,7 @@ type SidebarModalsProps = { onConfirmDeleteProject: (deleteData?: boolean) => void; sessionDeleteConfirmation: SessionDeleteConfirmation | null; onCancelDeleteSession: () => void; - onConfirmDeleteSession: () => void; + onConfirmDeleteSession: (hardDelete?: boolean) => void; showVersionModal: boolean; onCloseVersionModal: () => void; releaseInfo: ReleaseInfo | null; @@ -133,7 +133,7 @@ export default function SidebarModals({ onClick={() => onConfirmDeleteProject(false)} > - {t('deleteConfirmation.removeFromSidebar')} + {t('deleteConfirmation.archiveProject', 'Archive project')}
-
- +
+ {!sessionDeleteConfirmation.isArchived && ( + + )} +
diff --git a/src/components/sidebar/view/subcomponents/SidebarSessionItem.tsx b/src/components/sidebar/view/subcomponents/SidebarSessionItem.tsx index 7da02cb2..4e97a6b4 100644 --- a/src/components/sidebar/view/subcomponents/SidebarSessionItem.tsx +++ b/src/components/sidebar/view/subcomponents/SidebarSessionItem.tsx @@ -239,7 +239,7 @@ export default function SidebarSessionItem({ event.stopPropagation(); requestDeleteSession(); }} - title={t('tooltips.deleteSession')} + title={t('tooltips.deleteSessionOptions', 'Archive or permanently delete this session')} > diff --git a/src/utils/api.js b/src/utils/api.js index 0ac8d426..999ee316 100644 --- a/src/utils/api.js +++ b/src/utils/api.js @@ -54,6 +54,7 @@ export const api = { // After the projectName → projectId migration the path/query identifier is // the DB-assigned `projectId`; parameter names reflect that for clarity. projects: () => authenticatedFetch('/api/projects'), + archivedProjects: () => authenticatedFetch('/api/projects/archived'), projectSessions: (projectId, { limit = 20, offset = 0 } = {}) => { const params = new URLSearchParams(); params.set('limit', String(limit)); @@ -78,9 +79,28 @@ export const api = { method: 'PUT', body: JSON.stringify({ displayName }), }), - deleteSession: (sessionId) => - authenticatedFetch(`/api/providers/sessions/${sessionId}`, { + restoreProject: (projectId) => + authenticatedFetch(`/api/projects/${encodeURIComponent(projectId)}/restore`, { + method: 'POST', + }), + // Session deletion now mirrors project deletion: + // - default: archive only (`isArchived = 1`) + // - hardDelete: remove the row and, by default, its persisted transcript file + deleteSession: (sessionId, hardDelete = false) => { + const params = new URLSearchParams(); + if (hardDelete) { + params.set('force', 'true'); + } + const qs = params.toString(); + return authenticatedFetch(`/api/providers/sessions/${sessionId}${qs ? `?${qs}` : ''}`, { method: 'DELETE', + }); + }, + getArchivedSessions: () => + authenticatedFetch('/api/providers/sessions/archived'), + restoreSession: (sessionId) => + authenticatedFetch(`/api/providers/sessions/${sessionId}/restore`, { + method: 'POST', }), renameSession: (sessionId, summary) => authenticatedFetch(`/api/providers/sessions/${sessionId}`, {