mirror of
https://github.com/siteboon/claudecodeui.git
synced 2026-05-11 15:31:50 +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:
@@ -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);
|
||||
|
||||
@@ -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<void>): Promise<void> {
|
||||
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);
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user