mirror of
https://github.com/siteboon/claudecodeui.git
synced 2026-05-11 07:25:35 +00:00
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.
This commit is contained in:
@@ -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<boolean> {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 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<string, ReturnType<typeof projectsDb.getProjectPath>>();
|
||||
|
||||
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 };
|
||||
},
|
||||
|
||||
/**
|
||||
|
||||
Reference in New Issue
Block a user