From b2e3a610300459167a4a0860b873828d2b5dbbd0 Mon Sep 17 00:00:00 2001 From: Haileyesus <118998054+blackmammoth@users.noreply.github.com> Date: Fri, 8 May 2026 20:14:33 +0300 Subject: [PATCH] feat: add archiving flows for sessions and workspaces Users previously had an all-or-nothing choice for completed sessions: either keep them in the active sidebar or permanently delete them. That made long-lived usage brittle because valuable history stayed in the way unless users destroyed it. This change introduces archiving as a first-class lifecycle so completed work can be hidden without losing transcript history, workspace context, or restoreability. The backend now persists session archive state and excludes archived rows from active session queries by default. Dedicated archive queries and routes make archived sessions and archived workspaces addressable on their own, which is necessary once hidden data can no longer be rebuilt from the active project list. Hard-delete behavior still cleans up transcript files so destructive deletes remain truly destructive. The frontend now mirrors that lifecycle in the sidebar. Delete flows distinguish between archive and permanent delete, archived sessions can be restored, archived workspaces appear beside standalone archived sessions, and archived project sessions open with the correct workspace context instead of routing to a session URL that leaves the main view empty. Follow-up archive UI polish keeps the status affordances explicit without competing with workspace names. --- server/modules/database/migrations.ts | 12 + .../database/repositories/projects.db.ts | 13 + .../sessions.db.integration.test.ts | 72 +++++ .../database/repositories/sessions.db.ts | 69 +++- server/modules/database/schema.ts | 1 + server/modules/projects/projects.routes.ts | 23 +- .../services/project-delete.service.ts | 17 +- .../projects-with-sessions-fetch.service.ts | 64 ++++ server/modules/providers/provider.routes.ts | 25 +- .../providers/services/sessions.service.ts | 123 ++++++- .../sidebar/hooks/useSidebarController.ts | 225 ++++++++++++- src/components/sidebar/types/types.ts | 18 +- src/components/sidebar/view/Sidebar.tsx | 31 +- .../view/subcomponents/SidebarContent.tsx | 304 +++++++++++++++++- .../view/subcomponents/SidebarHeader.tsx | 62 +++- .../view/subcomponents/SidebarModals.tsx | 32 +- .../view/subcomponents/SidebarSessionItem.tsx | 2 +- src/utils/api.js | 24 +- 18 files changed, 1051 insertions(+), 66 deletions(-) create mode 100644 server/modules/database/repositories/sessions.db.integration.test.ts 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}`, {