refactor: setup sidebar workspace and session list

This commit is contained in:
Haileyesus
2026-03-30 15:48:20 +03:00
parent e165d2ca24
commit dfe9c75cfd
22 changed files with 2217 additions and 81 deletions

View File

@@ -51,6 +51,17 @@ export async function processCodexSessions() {
}
}
function getPathNumberVariants(value: number): string[] {
const unpadded = String(value);
const padded = unpadded.padStart(2, '0');
if (unpadded === padded) {
return [unpadded];
}
return [unpadded, padded];
}
function buildCodexDatePathParts(createdAt: string): Array<{ year: string; month: string; day: string }> {
const parsedDate = new Date(createdAt);
if (Number.isNaN(parsedDate.getTime())) {
@@ -59,25 +70,40 @@ function buildCodexDatePathParts(createdAt: string): Array<{ year: string; month
const localDate = {
year: String(parsedDate.getFullYear()),
month: String(parsedDate.getMonth() + 1),
day: String(parsedDate.getDate()),
month: parsedDate.getMonth() + 1,
day: parsedDate.getDate(),
};
const utcDate = {
year: String(parsedDate.getUTCFullYear()),
month: String(parsedDate.getUTCMonth() + 1),
day: String(parsedDate.getUTCDate()),
month: parsedDate.getUTCMonth() + 1,
day: parsedDate.getUTCDate(),
};
if (
const rawDateParts =
localDate.year === utcDate.year &&
localDate.month === utcDate.month &&
localDate.day === utcDate.day
) {
return [localDate];
localDate.month === utcDate.month &&
localDate.day === utcDate.day
? [localDate]
: [localDate, utcDate];
const uniqueDateParts = new Map<string, { year: string; month: string; day: string }>();
for (const datePart of rawDateParts) {
const monthVariants = getPathNumberVariants(datePart.month);
const dayVariants = getPathNumberVariants(datePart.day);
for (const month of monthVariants) {
for (const day of dayVariants) {
uniqueDateParts.set(`${datePart.year}-${month}-${day}`, {
year: datePart.year,
month,
day,
});
}
}
}
return [localDate, utcDate];
return [...uniqueDateParts.values()];
}
async function removeFileIfExists(filePath: string): Promise<boolean> {

View File

@@ -0,0 +1,171 @@
import express, { type Request, type Response } from 'express';
import { authenticateToken } from '@/modules/auth/auth.middleware.js';
import {
deleteSessionById,
deleteWorkspaceByPath,
getWorkspaceSessionsCollection,
updateSessionNameById,
updateWorkspaceNameByPath,
updateWorkspaceStarByPath,
} from '@/modules/sidebar/sidebar.service.js';
const router = express.Router();
const getTrimmedString = (value: unknown): string => {
if (typeof value !== 'string') {
return '';
}
return value.trim();
};
const getWorkspacePathFromBody = (req: Request): string => getTrimmedString(req.body?.workspacePath);
router.get(
'/api/sidebar/get-workspaces-sessions',
authenticateToken,
async (_req: Request, res: Response): Promise<void> => {
try {
const workspaces = getWorkspaceSessionsCollection();
res.json({ workspaces });
} catch (error) {
const message = error instanceof Error ? error.message : 'Failed to fetch workspaces';
res.status(500).json({ error: message });
}
},
);
router.put(
'/api/sidebar/update-workspace-star',
authenticateToken,
async (req: Request, res: Response): Promise<void> => {
try {
const workspacePath = getWorkspacePathFromBody(req);
if (!workspacePath) {
res.status(400).json({ error: 'workspacePath is required' });
return;
}
const isStarred = updateWorkspaceStarByPath(workspacePath);
res.json({ success: true, workspacePath, isStarred });
} catch (error) {
const message = error instanceof Error ? error.message : 'Failed to update workspace star';
const statusCode = message.toLowerCase().includes('not found') ? 404 : 500;
res.status(statusCode).json({ error: message });
}
},
);
router.put(
'/api/sidebar/update-workspace-custom-name',
authenticateToken,
async (req: Request, res: Response): Promise<void> => {
try {
const workspacePath = getWorkspacePathFromBody(req);
if (!workspacePath) {
res.status(400).json({ error: 'workspacePath is required' });
return;
}
const customWorkspaceName = getTrimmedString(req.body?.workspaceCustomName);
updateWorkspaceNameByPath(workspacePath, customWorkspaceName || null);
res.json({ success: true, workspacePath, workspaceCustomName: customWorkspaceName || null });
} catch (error) {
const message = error instanceof Error ? error.message : 'Failed to update workspace name';
res.status(500).json({ error: message });
}
},
);
router.put(
'/api/sidebar/update-session-custom-name',
authenticateToken,
async (req: Request, res: Response): Promise<void> => {
try {
const sessionId = getTrimmedString(req.body?.sessionId);
const sessionCustomName = getTrimmedString(req.body?.sessionCustomName);
if (!sessionId) {
res.status(400).json({ error: 'sessionId is required' });
return;
}
if (!sessionCustomName) {
res.status(400).json({ error: 'sessionCustomName is required' });
return;
}
if (sessionCustomName.length > 500) {
res
.status(400)
.json({ error: 'sessionCustomName must not exceed 500 characters' });
return;
}
updateSessionNameById(sessionId, sessionCustomName);
res.json({ success: true, sessionId, sessionCustomName });
} catch (error) {
const message =
error instanceof Error ? error.message : 'Failed to update session name';
const statusCode = message.toLowerCase().includes('not found') ? 404 : 500;
res.status(statusCode).json({ error: message });
}
},
);
router.delete(
'/api/sidebar/delete-workspace',
authenticateToken,
async (req: Request, res: Response): Promise<void> => {
try {
const workspacePath = getWorkspacePathFromBody(req);
if (!workspacePath) {
res.status(400).json({ error: 'workspacePath is required' });
return;
}
const result = await deleteWorkspaceByPath(workspacePath);
res.json({
success: true,
workspacePath,
...result,
});
} catch (error) {
const message = error instanceof Error ? error.message : 'Failed to delete workspace';
res.status(500).json({ error: message });
}
},
);
router.delete(
'/api/sidebar/delete-session',
authenticateToken,
async (req: Request, res: Response): Promise<void> => {
try {
const sessionId = getTrimmedString(req.body?.sessionId);
if (!sessionId) {
res.status(400).json({ error: 'sessionId is required' });
return;
}
const result = await deleteSessionById(sessionId);
if (!result.deleted) {
res.status(404).json({ error: 'Session not found' });
return;
}
res.json({
success: true,
sessionId,
...result,
});
} catch (error) {
const message = error instanceof Error ? error.message : 'Failed to delete session';
res.status(500).json({ error: message });
}
},
);
export default router;

View File

@@ -0,0 +1,247 @@
import path from 'node:path';
import { deleteClaudeSession } from '@/modules/providers/claude/claude.session-processor.js';
import { deleteCodexSession } from '@/modules/providers/codex/codex.session-processor.js';
import { deleteCursorSession } from '@/modules/providers/cursor/cursor.session-processor.js';
import { deleteGeminiSession } from '@/modules/providers/gemini/gemini.session-processor.js';
import { sessionsDb } from '@/shared/database/repositories/sessions.db.js';
import { workspaceOriginalPathsDb } from '@/shared/database/repositories/workspace-original-paths.db.js';
import type { SessionsRow } from '@/shared/database/types.js';
export type SidebarSessionRecord = {
sessionId: string;
id: string;
provider: SessionsRow['provider'];
customName: string | null;
summary: string;
workspacePath: string;
createdAt: string | null;
updatedAt: string | null;
lastActivity: string | null;
};
export type SidebarWorkspaceRecord = {
workspaceOriginalPath: string;
workspaceCustomName: string | null;
workspaceDisplayName: string;
isStarred: boolean;
lastActivity: string | null;
sessions: SidebarSessionRecord[];
};
export type DeleteSessionResult = {
deleted: boolean;
jsonlDeleted: boolean;
};
export type DeleteWorkspaceResult = {
deletedWorkspace: boolean;
deletedSessionCount: number;
jsonlDeletedCount: number;
failedSessionFileDeletes: string[];
};
type SessionDeletionTarget = Pick<SessionsRow, 'session_id' | 'provider' | 'workspace_path' | 'created_at'>;
const parseTimestamp = (timestamp: string | null | undefined): number => {
if (!timestamp) {
return 0;
}
// SQLite CURRENT_TIMESTAMP is UTC but stored without timezone ("YYYY-MM-DD HH:MM:SS").
// Normalize this format so parsing is always timezone-correct.
const sqliteUtcPattern = /^\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}$/;
const normalizedTimestamp = sqliteUtcPattern.test(timestamp)
? `${timestamp.replace(' ', 'T')}Z`
: timestamp;
const parsed = new Date(normalizedTimestamp).getTime();
return Number.isFinite(parsed) ? parsed : 0;
};
const toSidebarSessionRecord = (session: SessionsRow): SidebarSessionRecord => {
const lastActivity = session.updated_at || session.created_at || null;
return {
sessionId: session.session_id,
id: session.session_id,
provider: session.provider,
customName: session.custom_name,
summary: session.custom_name || 'Untitled Session',
workspacePath: session.workspace_path,
createdAt: session.created_at || null,
updatedAt: session.updated_at || null,
lastActivity,
};
};
const sortSessionsByLastActivity = (sessions: SidebarSessionRecord[]): SidebarSessionRecord[] =>
[...sessions].sort((left, right) => {
const timestampDifference =
parseTimestamp(right.lastActivity) - parseTimestamp(left.lastActivity);
if (timestampDifference !== 0) {
return timestampDifference;
}
return right.sessionId.localeCompare(left.sessionId);
});
const sortWorkspacesByLastActivity = (
workspaces: SidebarWorkspaceRecord[],
): SidebarWorkspaceRecord[] =>
[...workspaces].sort((left, right) => {
const timestampDifference =
parseTimestamp(right.lastActivity) - parseTimestamp(left.lastActivity);
if (timestampDifference !== 0) {
return timestampDifference;
}
return left.workspaceDisplayName.localeCompare(right.workspaceDisplayName);
});
const deleteSessionFileByProvider = async (
session: SessionDeletionTarget,
): Promise<boolean> => {
switch (session.provider) {
case 'claude':
return deleteClaudeSession(session.session_id, session.workspace_path);
case 'codex':
return deleteCodexSession(session.session_id, session.created_at);
case 'cursor':
return deleteCursorSession(session.session_id, session.workspace_path);
case 'gemini':
return deleteGeminiSession(session.session_id);
default:
return false;
}
};
export const getWorkspaceSessionsCollection = (): SidebarWorkspaceRecord[] => {
const workspaceRows = workspaceOriginalPathsDb.getWorkspacePaths();
const sessionRows = sessionsDb.getAllSessions();
const sessionsByWorkspace = new Map<string, SidebarSessionRecord[]>();
// Build grouped sessions once to keep the response shape deterministic.
for (const sessionRow of sessionRows) {
const existing = sessionsByWorkspace.get(sessionRow.workspace_path) || [];
existing.push(toSidebarSessionRecord(sessionRow));
sessionsByWorkspace.set(sessionRow.workspace_path, existing);
}
const workspaceRecords = workspaceRows.map((workspaceRow) => {
const sessions = sortSessionsByLastActivity(
sessionsByWorkspace.get(workspaceRow.workspace_path) || [],
);
const lastActivity = sessions[0]?.lastActivity || null;
return {
workspaceOriginalPath: workspaceRow.workspace_path,
workspaceCustomName: workspaceRow.custom_workspace_name,
workspaceDisplayName:
workspaceRow.custom_workspace_name ||
path.basename(workspaceRow.workspace_path) ||
workspaceRow.workspace_path,
isStarred: workspaceRow.isStarred === 1,
lastActivity,
sessions,
};
});
return sortWorkspacesByLastActivity(workspaceRecords);
};
export const updateWorkspaceStarByPath = (workspacePath: string): boolean => {
const workspaceRow = workspaceOriginalPathsDb.getWorkspacePath(workspacePath);
if (!workspaceRow) {
throw new Error('Workspace not found');
}
const nextIsStarred = workspaceRow.isStarred !== 1;
workspaceOriginalPathsDb.updateWorkspaceIsStarred(workspacePath, nextIsStarred);
return nextIsStarred;
};
export const updateWorkspaceNameByPath = (
workspacePath: string,
workspaceCustomName: string | null,
): void => {
workspaceOriginalPathsDb.updateCustomWorkspaceName(workspacePath, workspaceCustomName);
};
export const updateSessionNameById = (
sessionId: string,
sessionCustomName: string,
): void => {
const sessionMetadata = sessionsDb.getSessionById(sessionId);
if (!sessionMetadata) {
throw new Error('Session not found');
}
sessionsDb.updateSessionCustomName(sessionId, sessionCustomName);
};
export const deleteSessionById = async (
sessionId: string,
): Promise<DeleteSessionResult> => {
const sessionMetadata = sessionsDb.getSessionById(sessionId);
if (!sessionMetadata) {
return {
deleted: false,
jsonlDeleted: false,
};
}
const jsonlDeleted = await deleteSessionFileByProvider({
session_id: sessionMetadata.session_id,
provider: sessionMetadata.provider,
workspace_path: sessionMetadata.workspace_path,
created_at: sessionMetadata.created_at,
});
sessionsDb.deleteSession(sessionId);
return {
deleted: true,
jsonlDeleted,
};
};
export const deleteWorkspaceByPath = async (
workspacePath: string,
): Promise<DeleteWorkspaceResult> => {
const sessionRows = sessionsDb.getSessionsByWorkspacePath(workspacePath);
const failedSessionFileDeletes: string[] = [];
let jsonlDeletedCount = 0;
// Remove all session files first, then clean up DB rows.
for (const sessionRow of sessionRows) {
try {
const deleted = await deleteSessionFileByProvider({
session_id: sessionRow.session_id,
provider: sessionRow.provider,
workspace_path: sessionRow.workspace_path,
created_at: sessionRow.created_at,
});
if (deleted) {
jsonlDeletedCount += 1;
}
} catch {
failedSessionFileDeletes.push(sessionRow.session_id);
} finally {
sessionsDb.deleteSession(sessionRow.session_id);
}
}
workspaceOriginalPathsDb.deleteWorkspacePath(workspacePath);
return {
deletedWorkspace: true,
deletedSessionCount: sessionRows.length,
jsonlDeletedCount,
failedSessionFileDeletes,
};
};

View File

@@ -61,6 +61,7 @@ const [
geminiRoutes,
pluginsRoutes,
messagesRoutes,
sidebarRoutes,
projectsInlineRoutes,
filesRoutes,
sessionsInlineRoutes,
@@ -85,6 +86,7 @@ const [
importRoute('./modules/gemini/gemini.routes.js'),
importRoute('./modules/plugins/plugins.routes.js'),
importRoute('./modules/messages/messages.routes.js'),
importRoute('./modules/sidebar/sidebar.routes.js'),
importRoute('./modules/projects/projects.inline.routes.js'),
importRoute('./modules/files/files.routes.js'),
importRoute('./modules/sessions/sessions.inline.routes.js'),
@@ -174,6 +176,9 @@ app.use('/api/plugins', authenticateToken, pluginsRoutes);
// Unified session messages route (protected)
app.use('/api/sessions', authenticateToken, messagesRoutes);
// Refactored sidebar routes (protected)
app.use(sidebarRoutes);
// Agent API Routes (uses API key authentication)
app.use('/api/agent', agentRoutes);

View File

@@ -49,6 +49,9 @@ 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_workspace_path ON sessions(workspace_path)"
);
const sessionsTableInfo = db.prepare("PRAGMA table_info(sessions)").all() as { name: string }[];
const sessionColumnNames = sessionsTableInfo.map((col) => col.name);
addColumnToTableIfNotExists(db, "sessions", sessionColumnNames, "created_at", "DATETIME");
@@ -66,6 +69,16 @@ export const runMigrations = (db: Database) => {
"custom_workspace_name",
"TEXT DEFAULT NULL",
);
addColumnToTableIfNotExists(
db,
"workspace_original_paths",
workspaceOriginalPathsColumnNames,
"isStarred",
"BOOLEAN DEFAULT 0",
);
db.exec(
"CREATE INDEX IF NOT EXISTS idx_workspace_original_paths_is_starred ON workspace_original_paths(isStarred)"
);
db.exec(LAST_SCANNED_AT_SQL);

View File

@@ -90,7 +90,7 @@ export const sessionsDb = {
const db = getConnection();
db.prepare(
`UPDATE sessions
SET custom_name = ?, updated_at = CURRENT_TIMESTAMP
SET custom_name = ?
WHERE session_id = ?`
).run(customName, sessionId);
},
@@ -100,7 +100,7 @@ export const sessionsDb = {
const db = getConnection();
db.prepare(
`UPDATE sessions
SET custom_name = ?, updated_at = CURRENT_TIMESTAMP
SET custom_name = ?
WHERE session_id = ? AND provider = ?`
).run(customName, sessionId, provider);
},
@@ -118,6 +118,27 @@ export const sessionsDb = {
return row ?? null;
},
getAllSessions(): SessionsRow[] {
const db = getConnection();
return db
.prepare(
`SELECT session_id, provider, workspace_path, custom_name, created_at, updated_at
FROM sessions`
)
.all() as SessionsRow[];
},
getSessionsByWorkspacePath(workspacePath: string): SessionsRow[] {
const db = getConnection();
return db
.prepare(
`SELECT session_id, provider, workspace_path, custom_name, created_at, updated_at
FROM sessions
WHERE workspace_path = ?`
)
.all(workspacePath) as SessionsRow[];
},
getSessionName(sessionId: string, provider: string): string | null {
const db = getConnection();
const row = db

View File

@@ -16,6 +16,25 @@ export const workspaceOriginalPathsDb = {
`).run(workspacePath, customWorkspaceName);
},
getWorkspacePath(workspacePath: string): WorkspaceOriginalPathRow | null {
const db = getConnection();
const row = db.prepare(`
SELECT workspace_path, custom_workspace_name, isStarred
FROM workspace_original_paths
WHERE workspace_path = ?
`).get(workspacePath) as WorkspaceOriginalPathRow | undefined;
return row ?? null;
},
getWorkspacePaths(): WorkspaceOriginalPathRow[] {
const db = getConnection();
return db.prepare(`
SELECT workspace_path, custom_workspace_name, isStarred
FROM workspace_original_paths
`).all() as WorkspaceOriginalPathRow[];
},
getCustomWorkspaceName(workspacePath: string): string | null {
const db = getConnection();
const row = db.prepare(`
@@ -35,4 +54,21 @@ export const workspaceOriginalPathsDb = {
ON CONFLICT(workspace_path) DO UPDATE SET custom_workspace_name = excluded.custom_workspace_name
`).run(workspacePath, customWorkspaceName);
},
updateWorkspaceIsStarred(workspacePath: string, isStarred: boolean): void {
const db = getConnection();
db.prepare(`
UPDATE workspace_original_paths
SET isStarred = ?
WHERE workspace_path = ?
`).run(isStarred ? 1 : 0, workspacePath);
},
deleteWorkspacePath(workspacePath: string): void {
const db = getConnection();
db.prepare(`
DELETE FROM workspace_original_paths
WHERE workspace_path = ?
`).run(workspacePath);
},
};

View File

@@ -86,7 +86,8 @@ CREATE TABLE IF NOT EXISTS sessions (
export const WORK_SPACE_PATH_SQL = `
CREATE TABLE IF NOT EXISTS workspace_original_paths (
workspace_path TEXT PRIMARY KEY NOT NULL,
custom_workspace_name TEXT DEFAULT NULL
custom_workspace_name TEXT DEFAULT NULL,
isStarred BOOLEAN DEFAULT 0
);
`
@@ -135,8 +136,10 @@ CREATE INDEX IF NOT EXISTS idx_push_subscriptions_user_id ON push_subscriptions(
${SESSIONS_TABLE_SCHEMA_SQL}
CREATE INDEX IF NOT EXISTS idx_session_ids_lookup ON sessions(session_id);
CREATE INDEX IF NOT EXISTS idx_sessions_workspace_path ON sessions(workspace_path);
${WORK_SPACE_PATH_SQL}
CREATE INDEX IF NOT EXISTS idx_workspace_original_paths_is_starred ON workspace_original_paths(isStarred);
${LAST_SCANNED_AT_SQL}

View File

@@ -130,6 +130,7 @@ export type SessionWithSummary = {
export type WorkspaceOriginalPathRow = {
workspace_path: string;
custom_workspace_name: string | null;
isStarred: number; // SQLite boolean: 0 | 1
};