mirror of
https://github.com/siteboon/claudecodeui.git
synced 2026-05-09 22:18:19 +00:00
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.
226 lines
7.1 KiB
TypeScript
226 lines
7.1 KiB
TypeScript
import { getConnection } from '@/modules/database/connection.js';
|
|
import { projectsDb } from '@/modules/database/repositories/projects.db.js';
|
|
import { normalizeProjectPath } from '@/shared/utils.js';
|
|
|
|
type SessionRow = {
|
|
session_id: string;
|
|
provider: string;
|
|
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' | 'isArchived' | 'created_at' | 'updated_at'
|
|
>;
|
|
|
|
function normalizeTimestamp(value?: string): string | null {
|
|
if (!value) return null;
|
|
|
|
const parsed = new Date(value);
|
|
if (Number.isNaN(parsed.getTime())) {
|
|
return null;
|
|
}
|
|
|
|
return parsed.toISOString();
|
|
}
|
|
|
|
function normalizeProjectPathForProvider(provider: string, projectPath: string): string {
|
|
void provider;
|
|
return normalizeProjectPath(projectPath);
|
|
}
|
|
|
|
export const sessionsDb = {
|
|
createSession(
|
|
sessionId: string,
|
|
provider: string,
|
|
projectPath: string,
|
|
customName?: string,
|
|
createdAt?: string,
|
|
updatedAt?: string,
|
|
jsonlPath?: string | null
|
|
): string {
|
|
const db = getConnection();
|
|
const createdAtValue = normalizeTimestamp(createdAt);
|
|
const updatedAtValue = normalizeTimestamp(updatedAt);
|
|
const normalizedProjectPath = normalizeProjectPathForProvider(provider, projectPath);
|
|
|
|
// First, ensure the project path is recorded in the projects table,
|
|
// since it's a foreign key in the sessions table.
|
|
projectsDb.createProjectPath(normalizedProjectPath);
|
|
|
|
db.prepare(
|
|
`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,
|
|
provider,
|
|
customName ?? null,
|
|
normalizedProjectPath,
|
|
jsonlPath ?? null,
|
|
createdAtValue,
|
|
updatedAtValue
|
|
);
|
|
|
|
return sessionId;
|
|
},
|
|
|
|
updateSessionCustomName(sessionId: string, customName: string): void {
|
|
const db = getConnection();
|
|
db.prepare(
|
|
`UPDATE sessions
|
|
SET custom_name = ?
|
|
WHERE session_id = ?`
|
|
).run(customName, sessionId);
|
|
},
|
|
|
|
getSessionById(sessionId: string): SessionMetadataLookupRow | null {
|
|
const db = getConnection();
|
|
const row = db
|
|
.prepare(
|
|
`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
|
|
LIMIT 1`
|
|
)
|
|
.get(sessionId) as SessionMetadataLookupRow | undefined;
|
|
|
|
return row ?? null;
|
|
},
|
|
|
|
getAllSessions(): 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 = 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[];
|
|
},
|
|
|
|
getSessionsByProjectPath(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 = ?
|
|
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 = ?`
|
|
)
|
|
.all(normalizedProjectPath) as SessionRow[];
|
|
},
|
|
|
|
getSessionsByProjectPathPage(projectPath: string, limit: number, offset: number): 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 = ?
|
|
AND isArchived = 0
|
|
ORDER BY datetime(COALESCE(updated_at, created_at)) DESC, session_id DESC
|
|
LIMIT ? OFFSET ?`
|
|
)
|
|
.all(normalizedProjectPath, limit, offset) as SessionRow[];
|
|
},
|
|
|
|
countSessionsByProjectPath(projectPath: string): number {
|
|
const db = getConnection();
|
|
const normalizedProjectPath = normalizeProjectPath(projectPath);
|
|
const row = db
|
|
.prepare(
|
|
`SELECT COUNT(*) AS count
|
|
FROM sessions
|
|
WHERE project_path = ?
|
|
AND isArchived = 0`
|
|
)
|
|
.get(normalizedProjectPath) as { count: number } | undefined;
|
|
|
|
return Number(row?.count ?? 0);
|
|
},
|
|
|
|
deleteSessionsByProjectPath(projectPath: string): void {
|
|
const db = getConnection();
|
|
const normalizedProjectPath = normalizeProjectPath(projectPath);
|
|
db.prepare(`DELETE FROM sessions WHERE project_path = ?`).run(normalizedProjectPath);
|
|
},
|
|
|
|
getSessionName(sessionId: string, provider: string): string | null {
|
|
const db = getConnection();
|
|
const row = db
|
|
.prepare(
|
|
`SELECT custom_name
|
|
FROM sessions
|
|
WHERE session_id = ? AND provider = ?`
|
|
)
|
|
.get(sessionId, provider) as { custom_name: string | null } | undefined;
|
|
|
|
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;
|
|
},
|
|
};
|