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
};

View File

@@ -14,10 +14,14 @@ const router = createBrowserRouter([
path: "/",
element: <RootLayout />, // The layout wraps all children
children: [
{
{
path: "/",
element: <Home />,
},
{
path: "/session/:sessionId",
element: <SessionContent />,
},
{
path: "/sessions/:sessionId",
element: <SessionContent />,

View File

@@ -1,23 +1,120 @@
import type { WorkspaceRecord } from '@/components/refactored/sidebar/types';
import { authenticatedFetch } from '@/utils/api';
import type { Project } from '@/types/app';
/**
* Data Extractor layer
* Handles fetching workspaces from the API and formatting them.
*/
export const fetchWorkspaces = async (): Promise<Project[]> => {
const SIDEBAR_ENDPOINTS = {
getWorkspaceSessions: '/api/sidebar/get-workspaces-sessions',
updateWorkspaceStar: '/api/sidebar/update-workspace-star',
updateWorkspaceCustomName: '/api/sidebar/update-workspace-custom-name',
updateSessionCustomName: '/api/sidebar/update-session-custom-name',
deleteWorkspace: '/api/sidebar/delete-workspace',
deleteSession: '/api/sidebar/delete-session',
} as const;
const parseJsonSafely = async <T>(response: Response): Promise<T | null> => {
try {
const response = await authenticatedFetch('/api/projects');
if (!response.ok) {
throw new Error(`Failed to fetch workspaces: ${response.statusText}`);
}
const data = await response.json();
// Normalize response formats depending on the actual backend implementation
return data.projects || data.workspaces || data || [];
} catch (error) {
console.error('Error fetching workspaces:', error);
// Return empty array to gracefully handle failure
return [];
return (await response.json()) as T;
} catch {
return null;
}
};
const getErrorMessage = (fallbackMessage: string, payload: unknown): string => {
if (
payload &&
typeof payload === 'object' &&
'error' in payload &&
typeof (payload as { error?: unknown }).error === 'string'
) {
return (payload as { error: string }).error;
}
return fallbackMessage;
};
export const getWorkspaceSessions = async (): Promise<WorkspaceRecord[]> => {
const response = await authenticatedFetch(SIDEBAR_ENDPOINTS.getWorkspaceSessions);
const payload = await parseJsonSafely<{ workspaces?: WorkspaceRecord[]; error?: string }>(response);
if (!response.ok) {
throw new Error(getErrorMessage('Failed to fetch workspaces', payload));
}
return payload?.workspaces || [];
};
export const updateWorkspaceStar = async (
workspacePath: string,
): Promise<{ workspacePath: string; isStarred: boolean }> => {
const response = await authenticatedFetch(SIDEBAR_ENDPOINTS.updateWorkspaceStar, {
method: 'PUT',
body: JSON.stringify({ workspacePath }),
});
const payload = await parseJsonSafely<{
workspacePath?: string;
isStarred?: boolean;
error?: string;
}>(response);
if (!response.ok) {
throw new Error(getErrorMessage('Failed to update workspace star', payload));
}
return {
workspacePath: payload?.workspacePath || workspacePath,
isStarred: Boolean(payload?.isStarred),
};
};
export const updateWorkspaceCustomName = async (
workspacePath: string,
workspaceCustomName: string | null,
): Promise<void> => {
const response = await authenticatedFetch(SIDEBAR_ENDPOINTS.updateWorkspaceCustomName, {
method: 'PUT',
body: JSON.stringify({ workspacePath, workspaceCustomName }),
});
const payload = await parseJsonSafely<{ error?: string }>(response);
if (!response.ok) {
throw new Error(getErrorMessage('Failed to update workspace name', payload));
}
};
export const deleteWorkspaceByPath = async (workspacePath: string): Promise<void> => {
const response = await authenticatedFetch(SIDEBAR_ENDPOINTS.deleteWorkspace, {
method: 'DELETE',
body: JSON.stringify({ workspacePath }),
});
const payload = await parseJsonSafely<{ error?: string }>(response);
if (!response.ok) {
throw new Error(getErrorMessage('Failed to delete workspace', payload));
}
};
export const updateSessionCustomName = async (
sessionId: string,
sessionCustomName: string,
): Promise<void> => {
const response = await authenticatedFetch(SIDEBAR_ENDPOINTS.updateSessionCustomName, {
method: 'PUT',
body: JSON.stringify({ sessionId, sessionCustomName }),
});
const payload = await parseJsonSafely<{ error?: string }>(response);
if (!response.ok) {
throw new Error(getErrorMessage('Failed to update session name', payload));
}
};
export const deleteSessionById = async (sessionId: string): Promise<void> => {
const response = await authenticatedFetch(SIDEBAR_ENDPOINTS.deleteSession, {
method: 'DELETE',
body: JSON.stringify({ sessionId }),
});
const payload = await parseJsonSafely<{ error?: string }>(response);
if (!response.ok) {
throw new Error(getErrorMessage('Failed to delete session', payload));
}
};

View File

@@ -1,33 +1,327 @@
import { useState, useEffect, useCallback } from 'react';
import { fetchWorkspaces } from '../data/workspacesApi';
import type { Project } from '@/types/app';
import { useCallback, useEffect, useMemo, useState } from 'react';
import { useLocation, useNavigate } from 'react-router-dom';
import {
deleteSessionById,
deleteWorkspaceByPath,
getWorkspaceSessions,
updateSessionCustomName,
updateWorkspaceCustomName,
updateWorkspaceStar,
} from '@/components/refactored/sidebar/data/workspacesApi';
import type {
SearchMode,
SessionDeleteTarget,
WorkspaceDeleteTarget,
WorkspaceRecord,
WorkspaceSession,
} from '@/components/refactored/sidebar/types';
import { filterWorkspacesBySearch } from '@/components/refactored/sidebar/utils/search';
import {
getSessionDisplayName,
getWorkspaceDisplayName,
sortWorkspacesByLastActivity,
splitWorkspacesByStarred,
} from '@/components/refactored/sidebar/utils/workspaceTransforms';
const SESSION_ROUTE_PATTERN = /^\/session\/([^/]+)$/;
const LEGACY_SESSION_ROUTE_PATTERN = /^\/sessions\/([^/]+)$/;
const extractSessionIdFromPathname = (pathname: string): string | null => {
const sessionMatch = pathname.match(SESSION_ROUTE_PATTERN);
if (sessionMatch?.[1]) {
return decodeURIComponent(sessionMatch[1]);
}
const legacySessionMatch = pathname.match(LEGACY_SESSION_ROUTE_PATTERN);
if (legacySessionMatch?.[1]) {
return decodeURIComponent(legacySessionMatch[1]);
}
return null;
};
/**
* Hook layer (The Manager)
* Manages fetching workspaces and loading states.
* Owns sidebar workspace/session state and coordinates UI actions.
*/
export const useWorkspaces = () => {
const [workspaces, setWorkspaces] = useState<Project[]>([]);
const navigate = useNavigate();
const location = useLocation();
const [workspaces, setWorkspaces] = useState<WorkspaceRecord[]>([]);
const [isRefreshing, setIsRefreshing] = useState(false);
const [searchMode, setSearchMode] = useState<SearchMode>('projects');
const [searchFilter, setSearchFilter] = useState('');
const [expandedWorkspaces, setExpandedWorkspaces] = useState<Set<string>>(new Set());
const [editingWorkspacePath, setEditingWorkspacePath] = useState<string | null>(null);
const [editingWorkspaceName, setEditingWorkspaceName] = useState('');
const [workspaceDeleteTarget, setWorkspaceDeleteTarget] = useState<WorkspaceDeleteTarget | null>(null);
const [sessionDeleteTarget, setSessionDeleteTarget] = useState<SessionDeleteTarget | null>(null);
const [isSavingWorkspaceName, setIsSavingWorkspaceName] = useState(false);
const [editingSessionId, setEditingSessionId] = useState<string | null>(null);
const [editingSessionName, setEditingSessionName] = useState('');
const [isSavingSessionName, setIsSavingSessionName] = useState(false);
const selectedSessionId = useMemo(
() => extractSessionIdFromPathname(location.pathname),
[location.pathname],
);
const refreshWorkspaces = useCallback(async () => {
setIsRefreshing(true);
try {
const data = await fetchWorkspaces();
setWorkspaces(data);
const fetchedWorkspaces = await getWorkspaceSessions();
setWorkspaces(sortWorkspacesByLastActivity(fetchedWorkspaces));
} catch (error) {
console.error('Failed to refresh workspaces:', error);
setWorkspaces([]);
} finally {
setIsRefreshing(false);
}
}, []);
// Fetch on mount
useEffect(() => {
refreshWorkspaces();
void refreshWorkspaces();
}, [refreshWorkspaces]);
const filteredWorkspaces = useMemo(
() => filterWorkspacesBySearch(workspaces, searchMode, searchFilter),
[searchFilter, searchMode, workspaces],
);
const workspaceGroups = useMemo(
() => splitWorkspacesByStarred(filteredWorkspaces),
[filteredWorkspaces],
);
const toggleWorkspace = useCallback((workspacePath: string) => {
setExpandedWorkspaces((previousSet) => {
const nextSet = new Set(previousSet);
if (nextSet.has(workspacePath)) {
nextSet.delete(workspacePath);
} else {
nextSet.add(workspacePath);
}
return nextSet;
});
}, []);
const openSession = useCallback(
(workspacePath: string, sessionId: string) => {
setExpandedWorkspaces((previousSet) => {
const nextSet = new Set(previousSet);
nextSet.add(workspacePath);
return nextSet;
});
navigate(`/session/${encodeURIComponent(sessionId)}`);
},
[navigate],
);
const openNewSession = useCallback(() => {
navigate('/');
}, [navigate]);
const toggleWorkspaceStar = useCallback(async (workspacePath: string) => {
try {
await updateWorkspaceStar(workspacePath);
await refreshWorkspaces();
} catch (error) {
console.error('Failed to update workspace star:', error);
}
}, [refreshWorkspaces]);
const startWorkspaceRename = useCallback((workspace: WorkspaceRecord) => {
setEditingWorkspacePath(workspace.workspaceOriginalPath);
setEditingWorkspaceName(workspace.workspaceCustomName || '');
}, []);
const cancelWorkspaceRename = useCallback(() => {
setEditingWorkspacePath(null);
setEditingWorkspaceName('');
}, []);
const saveWorkspaceRename = useCallback(async () => {
if (!editingWorkspacePath) {
return;
}
setIsSavingWorkspaceName(true);
try {
const trimmedName = editingWorkspaceName.trim();
await updateWorkspaceCustomName(editingWorkspacePath, trimmedName || null);
await refreshWorkspaces();
cancelWorkspaceRename();
} catch (error) {
console.error('Failed to update workspace name:', error);
} finally {
setIsSavingWorkspaceName(false);
}
}, [
cancelWorkspaceRename,
editingWorkspaceName,
editingWorkspacePath,
refreshWorkspaces,
]);
const requestWorkspaceDelete = useCallback((workspace: WorkspaceRecord) => {
setWorkspaceDeleteTarget({
workspacePath: workspace.workspaceOriginalPath,
workspaceName: getWorkspaceDisplayName(workspace),
sessionCount: workspace.sessions.length,
});
}, []);
const cancelWorkspaceDelete = useCallback(() => {
setWorkspaceDeleteTarget(null);
}, []);
const confirmWorkspaceDelete = useCallback(async () => {
if (!workspaceDeleteTarget) {
return;
}
const deletingWorkspacePath = workspaceDeleteTarget.workspacePath;
setWorkspaceDeleteTarget(null);
try {
await deleteWorkspaceByPath(deletingWorkspacePath);
// If the current session belonged to the deleted workspace, reset to root.
const hadSelectedSession = workspaces.some(
(workspace) =>
workspace.workspaceOriginalPath === deletingWorkspacePath &&
workspace.sessions.some((session) => session.sessionId === selectedSessionId),
);
if (hadSelectedSession) {
navigate('/');
}
await refreshWorkspaces();
} catch (error) {
console.error('Failed to delete workspace:', error);
}
}, [
navigate,
refreshWorkspaces,
selectedSessionId,
workspaceDeleteTarget,
workspaces,
]);
const requestSessionDelete = useCallback(
(workspacePath: string, session: WorkspaceSession) => {
setSessionDeleteTarget({
sessionId: session.sessionId,
sessionName: getSessionDisplayName(session),
workspacePath,
});
},
[],
);
const cancelSessionDelete = useCallback(() => {
setSessionDeleteTarget(null);
}, []);
const startSessionRename = useCallback((session: WorkspaceSession) => {
setEditingSessionId(session.sessionId);
setEditingSessionName(getSessionDisplayName(session));
}, []);
const cancelSessionRename = useCallback(() => {
setEditingSessionId(null);
setEditingSessionName('');
}, []);
const saveSessionRename = useCallback(async () => {
if (!editingSessionId) {
return;
}
const trimmedName = editingSessionName.trim();
if (!trimmedName) {
cancelSessionRename();
return;
}
setIsSavingSessionName(true);
try {
await updateSessionCustomName(editingSessionId, trimmedName);
await refreshWorkspaces();
cancelSessionRename();
} catch (error) {
console.error('Failed to rename session:', error);
} finally {
setIsSavingSessionName(false);
}
}, [
cancelSessionRename,
editingSessionId,
editingSessionName,
refreshWorkspaces,
]);
const confirmSessionDelete = useCallback(async () => {
if (!sessionDeleteTarget) {
return;
}
const deletingSessionId = sessionDeleteTarget.sessionId;
setSessionDeleteTarget(null);
try {
await deleteSessionById(deletingSessionId);
if (selectedSessionId === deletingSessionId) {
navigate('/');
}
await refreshWorkspaces();
} catch (error) {
console.error('Failed to delete session:', error);
}
}, [navigate, refreshWorkspaces, selectedSessionId, sessionDeleteTarget]);
return {
workspaces,
starredWorkspaces: workspaceGroups.starred,
unstarredWorkspaces: workspaceGroups.unstarred,
isRefreshing,
refreshWorkspaces,
searchMode,
setSearchMode,
searchFilter,
setSearchFilter,
selectedSessionId,
expandedWorkspaces,
toggleWorkspace,
openSession,
openNewSession,
editingWorkspacePath,
editingWorkspaceName,
isSavingWorkspaceName,
editingSessionId,
editingSessionName,
isSavingSessionName,
setEditingWorkspaceName,
setEditingSessionName,
startWorkspaceRename,
cancelWorkspaceRename,
saveWorkspaceRename,
startSessionRename,
cancelSessionRename,
saveSessionRename,
toggleWorkspaceStar,
workspaceDeleteTarget,
sessionDeleteTarget,
requestWorkspaceDelete,
cancelWorkspaceDelete,
confirmWorkspaceDelete,
requestSessionDelete,
cancelSessionDelete,
confirmSessionDelete,
};
};

View File

@@ -1 +1,41 @@
export type SearchMode = 'projects' | 'conversations';
import type { SessionProvider } from '@/types/app';
export type SearchMode = 'projects' | 'conversations';
export type WorkspaceSession = {
sessionId: string;
id: string;
provider: SessionProvider;
customName: string | null;
summary: string;
workspacePath: string;
createdAt: string | null;
updatedAt: string | null;
lastActivity: string | null;
};
export type WorkspaceRecord = {
workspaceOriginalPath: string;
workspaceCustomName: string | null;
workspaceDisplayName: string;
isStarred: boolean;
lastActivity: string | null;
sessions: WorkspaceSession[];
};
export type WorkspaceDeleteTarget = {
workspacePath: string;
workspaceName: string;
sessionCount: number;
};
export type SessionDeleteTarget = {
sessionId: string;
sessionName: string;
workspacePath: string;
};
export type WorkspaceGroups = {
starred: WorkspaceRecord[];
unstarred: WorkspaceRecord[];
};

View File

@@ -1,16 +1,59 @@
import type { Project } from '@/types/app';
import type {
SearchMode,
WorkspaceRecord,
} from '@/components/refactored/sidebar/types';
const includesSearch = (value: string | null | undefined, searchText: string): boolean =>
(value || '').toLowerCase().includes(searchText);
/**
* Filters workspaces/projects by matching the search string against
* both `displayName` and `name` (case-insensitive substring match).
* Filters workspaces and sessions based on search mode.
* In conversations mode, sessions are filtered while preserving workspace context.
*/
export const filterWorkspacesByName = (workspaces: Project[], filter: string): Project[] => {
const normalized = filter.trim().toLowerCase();
if (!normalized) return workspaces;
export const filterWorkspacesBySearch = (
workspaces: WorkspaceRecord[],
searchMode: SearchMode,
filterText: string,
): WorkspaceRecord[] => {
const normalizedFilter = filterText.trim().toLowerCase();
if (!normalizedFilter) {
return workspaces;
}
return workspaces.filter((project) => {
const displayName = (project.displayName || project.name).toLowerCase();
const projectName = project.name.toLowerCase();
return displayName.includes(normalized) || projectName.includes(normalized);
});
if (searchMode === 'projects') {
return workspaces.filter((workspace) =>
includesSearch(workspace.workspaceDisplayName, normalizedFilter) ||
includesSearch(workspace.workspaceCustomName, normalizedFilter) ||
includesSearch(workspace.workspaceOriginalPath, normalizedFilter),
);
}
return workspaces
.map((workspace) => {
const workspaceMatches =
includesSearch(workspace.workspaceDisplayName, normalizedFilter) ||
includesSearch(workspace.workspaceCustomName, normalizedFilter) ||
includesSearch(workspace.workspaceOriginalPath, normalizedFilter);
if (workspaceMatches) {
return workspace;
}
const matchingSessions = workspace.sessions.filter((session) =>
includesSearch(session.customName, normalizedFilter) ||
includesSearch(session.summary, normalizedFilter) ||
includesSearch(session.sessionId, normalizedFilter) ||
includesSearch(session.provider, normalizedFilter),
);
if (matchingSessions.length === 0) {
return null;
}
return {
...workspace,
sessions: matchingSessions,
};
})
.filter((workspace): workspace is WorkspaceRecord => Boolean(workspace));
};

View File

@@ -0,0 +1,123 @@
import type {
WorkspaceGroups,
WorkspaceRecord,
WorkspaceSession,
} from '@/components/refactored/sidebar/types';
const parseTimestamp = (timestamp: string | null | undefined): number => {
if (!timestamp) {
return 0;
}
// SQLite CURRENT_TIMESTAMP is UTC but does not include timezone metadata.
// Convert it to an explicit UTC ISO-like string before parsing.
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;
};
export const sortSessionsByLastActivity = (
sessions: WorkspaceSession[],
): WorkspaceSession[] =>
[...sessions].sort((left, right) => {
const timestampDiff =
parseTimestamp(right.lastActivity) - parseTimestamp(left.lastActivity);
if (timestampDiff !== 0) {
return timestampDiff;
}
return right.sessionId.localeCompare(left.sessionId);
});
export const sortWorkspacesByLastActivity = (
workspaces: WorkspaceRecord[],
): WorkspaceRecord[] =>
[...workspaces].sort((left, right) => {
const timestampDiff =
parseTimestamp(right.lastActivity) - parseTimestamp(left.lastActivity);
if (timestampDiff !== 0) {
return timestampDiff;
}
return left.workspaceDisplayName.localeCompare(right.workspaceDisplayName);
});
export const splitWorkspacesByStarred = (
workspaces: WorkspaceRecord[],
): WorkspaceGroups => {
const starred = workspaces.filter((workspace) => workspace.isStarred);
const unstarred = workspaces.filter((workspace) => !workspace.isStarred);
return {
starred: sortWorkspacesByLastActivity(starred),
unstarred: sortWorkspacesByLastActivity(unstarred),
};
};
export const getWorkspaceDisplayName = (workspace: WorkspaceRecord): string =>
workspace.workspaceCustomName ||
workspace.workspaceDisplayName ||
workspace.workspaceOriginalPath;
export const getSessionDisplayName = (session: WorkspaceSession): string =>
session.customName || session.summary || session.sessionId || 'Untitled Session';
export const formatRelativeTime = (timestamp: string | null | undefined): string => {
if (!timestamp) {
return '--';
}
const parsedTime = parseTimestamp(timestamp);
if (!Number.isFinite(parsedTime)) {
return '--';
}
const diffMs = Math.max(0, Date.now() - parsedTime);
const diffMinutes = Math.floor(diffMs / (1000 * 60));
const diffHours = Math.floor(diffMs / (1000 * 60 * 60));
const diffDays = Math.floor(diffMs / (1000 * 60 * 60 * 24));
const diffMonths = Math.floor(diffDays / 30);
const diffYears = Math.floor(diffDays / 365);
if (diffMinutes < 1) {
return '1m';
}
if (diffMinutes < 60) {
return `${diffMinutes}m`;
}
if (diffHours < 24) {
return `${diffHours}h`;
}
if (diffDays < 30) {
return `${diffDays}d`;
}
if (diffDays < 365) {
return `${diffMonths}mo`;
}
return `${diffYears}y`;
};
export const isRecentActivity = (timestamp: string | null | undefined): boolean => {
if (!timestamp) {
return false;
}
const parsedTime = parseTimestamp(timestamp);
if (!Number.isFinite(parsedTime)) {
return false;
}
const diffMs = Math.max(0, Date.now() - parsedTime);
return diffMs <= 10 * 60 * 1000;
};

View File

@@ -1,17 +1,69 @@
import { PanelRightOpen } from 'lucide-react';
import { useSidebarSettings } from '../hooks/useSidebarSettings';
import { useWorkspaces } from '../hooks/useWorkspaces';
import { useSidebarModals } from '../hooks/useSidebarModals';
import SidebarHeader from './SidebarHeader';
import { useSidebarSettings } from '@/components/refactored/sidebar/hooks/useSidebarSettings';
import { useSidebarModals } from '@/components/refactored/sidebar/hooks/useSidebarModals';
import { useWorkspaces } from '@/components/refactored/sidebar/hooks/useWorkspaces';
import SidebarHeader from '@/components/refactored/sidebar/view/SidebarHeader';
import { SidebarDeleteModals } from '@/components/refactored/sidebar/view/SidebarDeleteModals';
import { SidebarWorkspaceList } from '@/components/refactored/sidebar/view/SidebarWorkspaceList';
import { cn } from '@/lib/utils';
import { Button } from '@/shared/view/ui';
import ProjectCreationWizard from '@/components/project-creation-wizard';
export function Sidebar() {
const { isCollapsed, toggleCollapse, setCollapsed } = useSidebarSettings();
const { workspaces, isRefreshing, refreshWorkspaces } = useWorkspaces();
const {
workspaces,
starredWorkspaces,
unstarredWorkspaces,
isRefreshing,
refreshWorkspaces,
searchMode,
setSearchMode,
searchFilter,
setSearchFilter,
selectedSessionId,
expandedWorkspaces,
toggleWorkspace,
openSession,
openNewSession,
editingWorkspacePath,
editingWorkspaceName,
isSavingWorkspaceName,
editingSessionId,
editingSessionName,
isSavingSessionName,
setEditingWorkspaceName,
setEditingSessionName,
startWorkspaceRename,
cancelWorkspaceRename,
saveWorkspaceRename,
startSessionRename,
cancelSessionRename,
saveSessionRename,
toggleWorkspaceStar,
workspaceDeleteTarget,
sessionDeleteTarget,
requestWorkspaceDelete,
cancelWorkspaceDelete,
confirmWorkspaceDelete,
requestSessionDelete,
cancelSessionDelete,
confirmSessionDelete,
} = useWorkspaces();
const { showNewProject, openNewProject, closeNewProject } = useSidebarModals();
const handleSessionDeleteRequest = (workspacePath: string, sessionId: string) => {
const workspace = workspaces.find(
(workspaceItem) => workspaceItem.workspaceOriginalPath === workspacePath,
);
const session = workspace?.sessions.find((item) => item.sessionId === sessionId);
if (!workspace || !session) {
return;
}
requestSessionDelete(workspacePath, session);
};
return (
<>
<>
@@ -38,12 +90,41 @@ export function Sidebar() {
isRefreshing={isRefreshing}
onRefresh={refreshWorkspaces}
onNewProject={openNewProject}
searchMode={searchMode}
onSearchModeChange={setSearchMode}
searchFilter={searchFilter}
onSearchFilterChange={setSearchFilter}
/>
{/* Placeholder for the rest of the sidebar content */}
{!isCollapsed && (
<div className="flex-1 overflow-y-auto overscroll-contain">
{/* Future list component will go here */}
{/* Can pass workspaces to the future list component as props */}
<SidebarWorkspaceList
workspacesCount={workspaces.length}
searchFilter={searchFilter}
starredWorkspaces={starredWorkspaces}
unstarredWorkspaces={unstarredWorkspaces}
expandedWorkspaces={expandedWorkspaces}
selectedSessionId={selectedSessionId}
editingWorkspacePath={editingWorkspacePath}
editingWorkspaceName={editingWorkspaceName}
isSavingWorkspaceName={isSavingWorkspaceName}
editingSessionId={editingSessionId}
editingSessionName={editingSessionName}
isSavingSessionName={isSavingSessionName}
onEditingWorkspaceNameChange={setEditingWorkspaceName}
onEditingSessionNameChange={setEditingSessionName}
onToggleWorkspace={toggleWorkspace}
onToggleWorkspaceStar={toggleWorkspaceStar}
onStartWorkspaceRename={startWorkspaceRename}
onCancelWorkspaceRename={cancelWorkspaceRename}
onSaveWorkspaceRename={saveWorkspaceRename}
onStartSessionRename={startSessionRename}
onCancelSessionRename={cancelSessionRename}
onSaveSessionRename={saveSessionRename}
onDeleteWorkspace={requestWorkspaceDelete}
onSessionSelect={openSession}
onSessionDelete={handleSessionDeleteRequest}
onNewSession={openNewSession}
/>
</div>
)}
</aside>
@@ -54,7 +135,7 @@ export function Sidebar() {
<Button
variant="ghost"
size="sm"
className="h-8 w-8 rounded-lg p-0 text-muted-foreground hover:bg-accent/80 hover:text-foreground"
className="h-9 w-9 rounded-lg p-0 text-muted-foreground hover:bg-accent/80 hover:text-foreground"
onClick={() => setCollapsed(false)}
title="Show Sidebar"
>
@@ -71,6 +152,15 @@ export function Sidebar() {
onProjectCreated={refreshWorkspaces}
/>
)}
<SidebarDeleteModals
workspaceDeleteTarget={workspaceDeleteTarget}
sessionDeleteTarget={sessionDeleteTarget}
onCancelWorkspaceDelete={cancelWorkspaceDelete}
onConfirmWorkspaceDelete={confirmWorkspaceDelete}
onCancelSessionDelete={cancelSessionDelete}
onConfirmSessionDelete={confirmSessionDelete}
/>
</>
);
}

View File

@@ -0,0 +1,133 @@
import ReactDOM from 'react-dom';
import { AlertTriangle, Trash2 } from 'lucide-react';
import type {
SessionDeleteTarget,
WorkspaceDeleteTarget,
} from '@/components/refactored/sidebar/types';
import { Button } from '@/shared/view/ui';
type SidebarDeleteModalsProps = {
workspaceDeleteTarget: WorkspaceDeleteTarget | null;
sessionDeleteTarget: SessionDeleteTarget | null;
onCancelWorkspaceDelete: () => void;
onConfirmWorkspaceDelete: () => void;
onCancelSessionDelete: () => void;
onConfirmSessionDelete: () => void;
};
/**
* Component layer (The Face)
* Renders deletion confirmations with explicit file-removal messaging.
*/
export function SidebarDeleteModals({
workspaceDeleteTarget,
sessionDeleteTarget,
onCancelWorkspaceDelete,
onConfirmWorkspaceDelete,
onCancelSessionDelete,
onConfirmSessionDelete,
}: SidebarDeleteModalsProps) {
return (
<>
{workspaceDeleteTarget &&
ReactDOM.createPortal(
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/60 p-4 backdrop-blur-sm">
<div className="w-full max-w-md overflow-hidden rounded-xl border border-border bg-card shadow-2xl">
<div className="p-6">
<div className="flex items-start gap-4">
<div className="flex h-12 w-12 flex-shrink-0 items-center justify-center rounded-full bg-red-100 dark:bg-red-900/30">
<AlertTriangle className="h-6 w-6 text-red-600 dark:text-red-400" />
</div>
<div className="min-w-0 flex-1">
<h3 className="mb-2 text-lg font-semibold text-foreground">Delete workspace</h3>
<p className="mb-2 text-sm text-muted-foreground">
Delete{' '}
<span className="font-medium text-foreground">
{workspaceDeleteTarget.workspaceName}
</span>
?
</p>
<div className="rounded-lg border border-red-200 bg-red-50 p-3 dark:border-red-800 dark:bg-red-900/20">
<p className="text-sm font-medium text-red-700 dark:text-red-300">
{workspaceDeleteTarget.sessionCount} session
{workspaceDeleteTarget.sessionCount === 1 ? '' : 's'} and all associated
JSONL files will be deleted.
</p>
<p className="mt-1 text-xs text-red-600 dark:text-red-400">
The workspace folder itself will stay on your system.
</p>
</div>
<p className="mt-3 text-xs text-muted-foreground">
This action cannot be undone.
</p>
</div>
</div>
</div>
<div className="flex gap-3 border-t border-border bg-muted/30 p-4">
<Button variant="outline" className="flex-1" onClick={onCancelWorkspaceDelete}>
Cancel
</Button>
<Button
variant="destructive"
className="flex-1 bg-red-600 text-white hover:bg-red-700"
onClick={onConfirmWorkspaceDelete}
>
<Trash2 className="mr-2 h-4 w-4" />
Delete
</Button>
</div>
</div>
</div>,
document.body,
)}
{sessionDeleteTarget &&
ReactDOM.createPortal(
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/60 p-4 backdrop-blur-sm">
<div className="w-full max-w-md overflow-hidden rounded-xl border border-border bg-card shadow-2xl">
<div className="p-6">
<div className="flex items-start gap-4">
<div className="flex h-12 w-12 flex-shrink-0 items-center justify-center rounded-full bg-red-100 dark:bg-red-900/30">
<AlertTriangle className="h-6 w-6 text-red-600 dark:text-red-400" />
</div>
<div className="min-w-0 flex-1">
<h3 className="mb-2 text-lg font-semibold text-foreground">Delete session</h3>
<p className="mb-2 text-sm text-muted-foreground">
Delete{' '}
<span className="font-medium text-foreground">
{sessionDeleteTarget.sessionName}
</span>
?
</p>
<div className="rounded-lg border border-red-200 bg-red-50 p-3 dark:border-red-800 dark:bg-red-900/20">
<p className="text-sm font-medium text-red-700 dark:text-red-300">
The associated JSONL session file will also be deleted.
</p>
</div>
<p className="mt-3 text-xs text-muted-foreground">
This action cannot be undone.
</p>
</div>
</div>
</div>
<div className="flex gap-3 border-t border-border bg-muted/30 p-4">
<Button variant="outline" className="flex-1" onClick={onCancelSessionDelete}>
Cancel
</Button>
<Button
variant="destructive"
className="flex-1 bg-red-600 text-white hover:bg-red-700"
onClick={onConfirmSessionDelete}
>
<Trash2 className="mr-2 h-4 w-4" />
Delete
</Button>
</div>
</div>
</div>,
document.body,
)}
</>
);
}

View File

@@ -1,7 +1,7 @@
import { useState } from 'react';
import { FolderPlus, Plus, RefreshCw, PanelLeftClose } from 'lucide-react';
import type { SearchMode } from '../types';
import { SidebarSearch } from './SidebarSearch';
import type { SearchMode } from '@/components/refactored/sidebar/types';
import { SidebarSearch } from '@/components/refactored/sidebar/view/SidebarSearch';
import { Button } from '@/shared/view/ui';
import { cn } from '@/lib/utils';
import { IS_PLATFORM } from '@/constants/config';
@@ -12,6 +12,10 @@ type SidebarHeaderProps = {
isRefreshing: boolean;
onRefresh: () => void;
onNewProject: () => void;
searchMode: SearchMode;
onSearchModeChange: (mode: SearchMode) => void;
searchFilter: string;
onSearchFilterChange: (value: string) => void;
};
export default function SidebarHeader({
@@ -19,12 +23,12 @@ export default function SidebarHeader({
onToggleCollapse,
isRefreshing,
onRefresh,
onNewProject
onNewProject,
searchMode,
onSearchModeChange,
searchFilter,
onSearchFilterChange,
}: SidebarHeaderProps) {
// UI States for search
const [searchMode, setSearchMode] = useState<SearchMode>('projects');
const [searchFilter, setSearchFilter] = useState('');
const LogoBlock = () => (
<div className="flex min-w-0 items-center gap-2.5">
<div className="flex h-7 w-7 flex-shrink-0 items-center justify-center rounded-lg bg-primary/90 shadow-sm">
@@ -63,7 +67,7 @@ export default function SidebarHeader({
<Button
variant="ghost"
size="sm"
className="h-7 w-7 rounded-lg p-0 text-muted-foreground hover:bg-accent/80 hover:text-foreground"
className="h-8 w-8 rounded-lg p-0 text-muted-foreground hover:bg-accent/80 hover:text-foreground"
onClick={onRefresh}
disabled={isRefreshing}
title="Refresh"
@@ -73,7 +77,7 @@ export default function SidebarHeader({
<Button
variant="ghost"
size="sm"
className="h-7 w-7 rounded-lg p-0 text-muted-foreground hover:bg-accent/80 hover:text-foreground"
className="h-8 w-8 rounded-lg p-0 text-muted-foreground hover:bg-accent/80 hover:text-foreground"
onClick={onNewProject}
title="New Project"
>
@@ -82,7 +86,7 @@ export default function SidebarHeader({
<Button
variant="ghost"
size="sm"
className="h-7 w-7 rounded-lg p-0 text-muted-foreground hover:bg-accent/80 hover:text-foreground"
className="h-8 w-8 rounded-lg p-0 text-muted-foreground hover:bg-accent/80 hover:text-foreground"
onClick={onToggleCollapse}
title="Hide Sidebar"
>
@@ -93,9 +97,9 @@ export default function SidebarHeader({
<SidebarSearch
searchMode={searchMode}
onSearchModeChange={setSearchMode}
onSearchModeChange={onSearchModeChange}
searchFilter={searchFilter}
onSearchFilterChange={setSearchFilter}
onSearchFilterChange={onSearchFilterChange}
/>
</div>
@@ -108,14 +112,14 @@ export default function SidebarHeader({
<LogoWithLink />
<div className="flex flex-shrink-0 gap-1.5">
<button
className="flex h-8 w-8 items-center justify-center rounded-lg bg-muted/50 transition-all active:scale-95 disabled:opacity-70"
className="flex h-9 w-9 items-center justify-center rounded-lg bg-muted/50 transition-all active:scale-95 disabled:opacity-70"
onClick={onRefresh}
disabled={isRefreshing}
>
<RefreshCw className={cn("h-4 w-4 text-muted-foreground transition-opacity", isRefreshing && "animate-spin opacity-50")} />
</button>
<button
className="flex h-8 w-8 items-center justify-center rounded-lg bg-primary/90 text-primary-foreground transition-all active:scale-95"
className="flex h-9 w-9 items-center justify-center rounded-lg bg-primary/90 text-primary-foreground transition-all active:scale-95"
onClick={onNewProject}
>
<FolderPlus className="h-4 w-4" />
@@ -125,9 +129,9 @@ export default function SidebarHeader({
<SidebarSearch
searchMode={searchMode}
onSearchModeChange={setSearchMode}
onSearchModeChange={onSearchModeChange}
searchFilter={searchFilter}
onSearchFilterChange={setSearchFilter}
onSearchFilterChange={onSearchFilterChange}
/>
</div>
@@ -135,4 +139,4 @@ export default function SidebarHeader({
<div className="nav-divider md:hidden" />
</div>
);
}
}

View File

@@ -1,7 +1,7 @@
import { Folder, MessageSquare, Search, X } from 'lucide-react';
import { Input } from '@/shared/view/ui';
import { cn } from '@/lib/utils';
import { SearchMode } from '@/components/refactored/sidebar/types/index.js';
import type { SearchMode } from '@/components/refactored/sidebar/types';
type SidebarSearchProps = {

View File

@@ -0,0 +1,196 @@
import { Check, Edit2, Trash2, X } from 'lucide-react';
import SessionProviderLogo from '@/components/llm-logo-provider/SessionProviderLogo';
import type { WorkspaceSession } from '@/components/refactored/sidebar/types';
import {
formatRelativeTime,
getSessionDisplayName,
isRecentActivity,
} from '@/components/refactored/sidebar/utils/workspaceTransforms';
import { cn } from '@/lib/utils';
import { Button } from '@/shared/view/ui';
type SidebarSessionItemProps = {
session: WorkspaceSession;
isSelected: boolean;
isEditing: boolean;
editingSessionName: string;
isSavingSessionName: boolean;
onEditingSessionNameChange: (name: string) => void;
onStartEdit: () => void;
onCancelEdit: () => void;
onSaveEdit: () => void;
onSelect: () => void;
onDelete: () => void;
};
export function SidebarSessionItem({
session,
isSelected,
isEditing,
editingSessionName,
isSavingSessionName,
onEditingSessionNameChange,
onStartEdit,
onCancelEdit,
onSaveEdit,
onSelect,
onDelete,
}: SidebarSessionItemProps) {
const sessionName = getSessionDisplayName(session);
const sessionActivityLabel = formatRelativeTime(session.lastActivity);
const showRecentBadge = isRecentActivity(session.lastActivity);
const handleSaveEdit = () => {
if (!isSavingSessionName) {
onSaveEdit();
}
};
return (
<div className="group relative">
<div className="md:hidden">
<div
className={cn(
'mx-3 my-0.5 rounded-md border bg-card p-2 transition-all duration-150 active:scale-[0.98]',
isSelected ? 'border-primary/20 bg-primary/5' : 'border-border/30',
)}
onClick={onSelect}
>
<div className="flex items-center gap-2">
<div
className={cn(
'flex h-5 w-5 flex-shrink-0 items-center justify-center rounded-md',
isSelected ? 'bg-primary/10' : 'bg-muted/50',
)}
>
<SessionProviderLogo provider={session.provider} className="h-3 w-3" />
</div>
<div className="min-w-0 flex-1">
<div className="flex items-center gap-2">
<div className="min-w-0 flex-1 truncate text-xs font-medium text-foreground">
{sessionName}
</div>
<div className="flex flex-shrink-0 items-center gap-1">
{showRecentBadge && <span className="h-2 w-2 rounded-full bg-green-500" />}
<span className="text-xs text-muted-foreground">{sessionActivityLabel}</span>
</div>
</div>
</div>
<button
className="ml-1 flex h-7 w-7 items-center justify-center rounded-md bg-red-50 opacity-70 transition-transform active:scale-95 dark:bg-red-900/20"
onClick={(event) => {
event.stopPropagation();
onDelete();
}}
>
<Trash2 className="h-2.5 w-2.5 text-red-600 dark:text-red-400" />
</button>
</div>
</div>
</div>
<div className="hidden md:block">
<Button
variant="ghost"
className={cn(
'h-auto w-full justify-start p-2 text-left font-normal transition-colors duration-200 hover:bg-accent/50',
isSelected && 'bg-accent text-accent-foreground',
)}
onClick={() => {
if (!isEditing) {
onSelect();
}
}}
>
<div className="flex w-full min-w-0 items-start gap-2">
<SessionProviderLogo provider={session.provider} className="mt-0.5 h-3 w-3 flex-shrink-0" />
<div className="min-w-0 flex-1">
{isEditing ? (
<input
type="text"
value={editingSessionName}
onChange={(event) => onEditingSessionNameChange(event.target.value)}
onClick={(event) => event.stopPropagation()}
onKeyDown={(event) => {
event.stopPropagation();
if (event.key === 'Enter') {
handleSaveEdit();
}
if (event.key === 'Escape') {
onCancelEdit();
}
}}
className="w-full rounded border border-border bg-background px-2 py-1 text-xs text-foreground focus:outline-none focus:ring-1 focus:ring-primary"
autoFocus
/>
) : (
<>
<div className="flex items-center gap-2">
<div className="min-w-0 flex-1 truncate text-xs font-medium text-foreground">
{sessionName}
</div>
<div className="flex flex-shrink-0 items-center gap-1 transition-opacity group-hover:opacity-0">
{showRecentBadge && <span className="h-2 w-2 rounded-full bg-green-500" />}
<span className="text-xs text-muted-foreground">{sessionActivityLabel}</span>
</div>
</div>
</>
)}
</div>
</div>
</Button>
{isEditing ? (
<div className="absolute right-2 top-1/2 flex -translate-y-1/2 transform items-center gap-1">
<button
className="flex h-8 w-8 items-center justify-center rounded bg-green-50 hover:bg-green-100 dark:bg-green-900/20 dark:hover:bg-green-900/40"
onClick={(event) => {
event.stopPropagation();
handleSaveEdit();
}}
title="Save"
>
<Check className="h-3 w-3 text-green-600 dark:text-green-400" />
</button>
<button
className="flex h-8 w-8 items-center justify-center rounded bg-gray-50 hover:bg-gray-100 dark:bg-gray-900/20 dark:hover:bg-gray-900/40"
onClick={(event) => {
event.stopPropagation();
onCancelEdit();
}}
title="Cancel"
>
<X className="h-3 w-3 text-gray-600 dark:text-gray-400" />
</button>
</div>
) : (
<div className="absolute right-2 top-1/2 flex -translate-y-1/2 transform items-center gap-1 opacity-0 transition-all duration-200 group-hover:opacity-100">
<button
className="flex h-8 w-8 items-center justify-center rounded bg-gray-50 hover:bg-gray-100 dark:bg-gray-900/20 dark:hover:bg-gray-900/40"
onClick={(event) => {
event.stopPropagation();
onStartEdit();
}}
title="Rename session"
>
<Edit2 className="h-3 w-3 text-gray-600 dark:text-gray-400" />
</button>
<button
className="flex h-8 w-8 items-center justify-center rounded bg-red-50 hover:bg-red-100 dark:bg-red-900/20 dark:hover:bg-red-900/40"
onClick={(event) => {
event.stopPropagation();
onDelete();
}}
title="Delete session"
>
<Trash2 className="h-3 w-3 text-red-600 dark:text-red-400" />
</button>
</div>
)}
</div>
</div>
);
}

View File

@@ -0,0 +1,420 @@
import {
Check,
ChevronDown,
ChevronRight,
Edit3,
Folder,
FolderOpen,
Plus,
Star,
Trash2,
X,
} from 'lucide-react';
import { SidebarSessionItem } from '@/components/refactored/sidebar/view/SidebarSessionItem';
import type { WorkspaceRecord } from '@/components/refactored/sidebar/types';
import { getWorkspaceDisplayName } from '@/components/refactored/sidebar/utils/workspaceTransforms';
import { cn } from '@/lib/utils';
import { Button } from '@/shared/view/ui';
type SidebarWorkspaceItemProps = {
workspace: WorkspaceRecord;
isExpanded: boolean;
selectedSessionId: string | null;
editingWorkspacePath: string | null;
editingWorkspaceName: string;
isSavingWorkspaceName: boolean;
editingSessionId: string | null;
editingSessionName: string;
isSavingSessionName: boolean;
onEditingWorkspaceNameChange: (name: string) => void;
onEditingSessionNameChange: (name: string) => void;
onToggleWorkspace: (workspacePath: string) => void;
onToggleWorkspaceStar: (workspacePath: string) => void;
onStartWorkspaceRename: (workspace: WorkspaceRecord) => void;
onCancelWorkspaceRename: () => void;
onSaveWorkspaceRename: () => void;
onStartSessionRename: (session: WorkspaceRecord['sessions'][number]) => void;
onCancelSessionRename: () => void;
onSaveSessionRename: () => void;
onDeleteWorkspace: (workspace: WorkspaceRecord) => void;
onSessionSelect: (workspacePath: string, sessionId: string) => void;
onSessionDelete: (workspacePath: string, sessionId: string) => void;
onNewSession: () => void;
};
export function SidebarWorkspaceItem({
workspace,
isExpanded,
selectedSessionId,
editingWorkspacePath,
editingWorkspaceName,
isSavingWorkspaceName,
editingSessionId,
editingSessionName,
isSavingSessionName,
onEditingWorkspaceNameChange,
onEditingSessionNameChange,
onToggleWorkspace,
onToggleWorkspaceStar,
onStartWorkspaceRename,
onCancelWorkspaceRename,
onSaveWorkspaceRename,
onStartSessionRename,
onCancelSessionRename,
onSaveSessionRename,
onDeleteWorkspace,
onSessionSelect,
onSessionDelete,
onNewSession,
}: SidebarWorkspaceItemProps) {
const isEditing = editingWorkspacePath === workspace.workspaceOriginalPath;
const hasSelectedSession = workspace.sessions.some(
(session) => session.sessionId === selectedSessionId,
);
const workspaceName = getWorkspaceDisplayName(workspace);
const sessionCountLabel = `${workspace.sessions.length} session${
workspace.sessions.length === 1 ? '' : 's'
}`;
const handleSaveRename = () => {
if (!isSavingWorkspaceName) {
onSaveWorkspaceRename();
}
};
return (
<div className="md:space-y-1">
<div className="group md:group">
<div className="md:hidden">
<div
className={cn(
'mx-3 my-1 rounded-lg border bg-card p-3 transition-all duration-150 active:scale-[0.98]',
hasSelectedSession && 'border-primary/20 bg-primary/5',
workspace.isStarred &&
!hasSelectedSession &&
'border-yellow-200/30 bg-yellow-50/50 dark:border-yellow-800/30 dark:bg-yellow-900/5',
)}
onClick={() => onToggleWorkspace(workspace.workspaceOriginalPath)}
>
<div className="flex items-center justify-between">
<div className="flex min-w-0 flex-1 items-center gap-3">
<div
className={cn(
'flex h-8 w-8 items-center justify-center rounded-lg transition-colors',
isExpanded ? 'bg-primary/10' : 'bg-muted',
)}
>
{isExpanded ? (
<FolderOpen className="h-4 w-4 text-primary" />
) : (
<Folder className="h-4 w-4 text-muted-foreground" />
)}
</div>
<div className="min-w-0 flex-1">
{isEditing ? (
<input
type="text"
value={editingWorkspaceName}
onChange={(event) => onEditingWorkspaceNameChange(event.target.value)}
className="w-full rounded-lg border-2 border-primary/40 bg-background px-3 py-2 text-sm text-foreground shadow-sm transition-all duration-200 focus:border-primary focus:shadow-md focus:outline-none"
placeholder="Workspace name"
autoFocus
autoComplete="off"
onClick={(event) => event.stopPropagation()}
onKeyDown={(event) => {
if (event.key === 'Enter') {
handleSaveRename();
}
if (event.key === 'Escape') {
onCancelWorkspaceRename();
}
}}
style={{
fontSize: '16px',
WebkitAppearance: 'none',
borderRadius: '8px',
}}
/>
) : (
<>
<h3 className="truncate text-sm font-medium text-foreground">{workspaceName}</h3>
<p className="text-xs text-muted-foreground">{sessionCountLabel}</p>
</>
)}
</div>
</div>
<div className="flex items-center gap-1">
{isEditing ? (
<>
<button
className="flex h-9 w-9 items-center justify-center rounded-lg bg-green-500 shadow-sm transition-all duration-150 active:scale-90 active:shadow-none dark:bg-green-600"
onClick={(event) => {
event.stopPropagation();
handleSaveRename();
}}
>
<Check className="h-4 w-4 text-white" />
</button>
<button
className="flex h-9 w-9 items-center justify-center rounded-lg bg-gray-500 shadow-sm transition-all duration-150 active:scale-90 active:shadow-none dark:bg-gray-600"
onClick={(event) => {
event.stopPropagation();
onCancelWorkspaceRename();
}}
>
<X className="h-4 w-4 text-white" />
</button>
</>
) : (
<>
<button
className={cn(
'flex h-9 w-9 items-center justify-center rounded-lg border transition-all duration-150 active:scale-90',
workspace.isStarred
? 'border-yellow-200 bg-yellow-500/10 dark:border-yellow-800 dark:bg-yellow-900/30'
: 'border-gray-200 bg-gray-500/10 dark:border-gray-800 dark:bg-gray-900/30',
)}
onClick={(event) => {
event.stopPropagation();
onToggleWorkspaceStar(workspace.workspaceOriginalPath);
}}
title={workspace.isStarred ? 'Remove from Starred' : 'Add to Starred'}
>
<Star
className={cn(
'h-4 w-4 transition-colors',
workspace.isStarred
? 'fill-current text-yellow-600 dark:text-yellow-400'
: 'text-gray-600 dark:text-gray-400',
)}
/>
</button>
<button
className="flex h-9 w-9 items-center justify-center rounded-lg border border-red-200 bg-red-500/10 active:scale-90 dark:border-red-800 dark:bg-red-900/30"
onClick={(event) => {
event.stopPropagation();
onDeleteWorkspace(workspace);
}}
>
<Trash2 className="h-4 w-4 text-red-600 dark:text-red-400" />
</button>
<button
className="flex h-9 w-9 items-center justify-center rounded-lg border border-primary/20 bg-primary/10 active:scale-90 dark:border-primary/30 dark:bg-primary/20"
onClick={(event) => {
event.stopPropagation();
onStartWorkspaceRename(workspace);
}}
>
<Edit3 className="h-4 w-4 text-primary" />
</button>
<div className="flex h-6 w-6 items-center justify-center rounded-md bg-muted/30">
{isExpanded ? (
<ChevronDown className="h-3 w-3 text-muted-foreground" />
) : (
<ChevronRight className="h-3 w-3 text-muted-foreground" />
)}
</div>
</>
)}
</div>
</div>
</div>
</div>
<Button
variant="ghost"
className={cn(
'hidden h-auto w-full justify-between p-2 font-normal hover:bg-accent/50 md:flex',
hasSelectedSession && 'bg-accent text-accent-foreground',
workspace.isStarred &&
!hasSelectedSession &&
'bg-yellow-50/50 hover:bg-yellow-100/50 dark:bg-yellow-900/10 dark:hover:bg-yellow-900/20',
)}
onClick={() => onToggleWorkspace(workspace.workspaceOriginalPath)}
>
<div className="flex min-w-0 flex-1 items-center gap-3">
{isExpanded ? (
<FolderOpen className="h-4 w-4 flex-shrink-0 text-primary" />
) : (
<Folder className="h-4 w-4 flex-shrink-0 text-muted-foreground" />
)}
<div className="min-w-0 flex-1 text-left">
{isEditing ? (
<div className="space-y-1">
<input
type="text"
value={editingWorkspaceName}
onChange={(event) => onEditingWorkspaceNameChange(event.target.value)}
className="w-full rounded border border-border bg-background px-2 py-1 text-sm text-foreground focus:ring-2 focus:ring-primary/20"
placeholder="Workspace name"
autoFocus
onClick={(event) => event.stopPropagation()}
onKeyDown={(event) => {
if (event.key === 'Enter') {
handleSaveRename();
}
if (event.key === 'Escape') {
onCancelWorkspaceRename();
}
}}
/>
<div className="truncate text-xs text-muted-foreground" title={workspace.workspaceOriginalPath}>
{workspace.workspaceOriginalPath}
</div>
</div>
) : (
<div>
<div className="truncate text-sm font-semibold text-foreground" title={workspaceName}>
{workspaceName}
</div>
<div className="text-xs text-muted-foreground">
{workspace.sessions.length}
<span className="ml-1 opacity-60" title={workspace.workspaceOriginalPath}>
{' - '}
{workspace.workspaceOriginalPath.length > 25
? `...${workspace.workspaceOriginalPath.slice(-22)}`
: workspace.workspaceOriginalPath}
</span>
</div>
</div>
)}
</div>
</div>
<div className="flex flex-shrink-0 items-center gap-1">
{isEditing ? (
<>
<div
className="flex h-8 w-8 cursor-pointer items-center justify-center rounded text-green-600 transition-colors hover:bg-green-50 hover:text-green-700 dark:hover:bg-green-900/20"
onClick={(event) => {
event.stopPropagation();
handleSaveRename();
}}
>
<Check className="h-3 w-3" />
</div>
<div
className="flex h-8 w-8 cursor-pointer items-center justify-center rounded text-gray-500 transition-colors hover:bg-gray-50 hover:text-gray-700 dark:hover:bg-gray-800"
onClick={(event) => {
event.stopPropagation();
onCancelWorkspaceRename();
}}
>
<X className="h-3 w-3" />
</div>
</>
) : (
<>
<div
className={cn(
'touch:opacity-100 flex h-8 w-8 cursor-pointer items-center justify-center rounded opacity-0 transition-all duration-200 group-hover:opacity-100',
workspace.isStarred ? 'opacity-100 hover:bg-yellow-50 dark:hover:bg-yellow-900/20' : 'hover:bg-accent',
)}
onClick={(event) => {
event.stopPropagation();
onToggleWorkspaceStar(workspace.workspaceOriginalPath);
}}
title={workspace.isStarred ? 'Remove from Starred' : 'Add to Starred'}
>
<Star
className={cn(
'h-3 w-3 transition-colors',
workspace.isStarred
? 'fill-current text-yellow-600 dark:text-yellow-400'
: 'text-muted-foreground',
)}
/>
</div>
<div
className="touch:opacity-100 flex h-8 w-8 cursor-pointer items-center justify-center rounded opacity-0 transition-all duration-200 hover:bg-accent group-hover:opacity-100"
onClick={(event) => {
event.stopPropagation();
onStartWorkspaceRename(workspace);
}}
title="Rename workspace"
>
<Edit3 className="h-3 w-3" />
</div>
<div
className="touch:opacity-100 flex h-8 w-8 cursor-pointer items-center justify-center rounded opacity-0 transition-all duration-200 hover:bg-red-50 group-hover:opacity-100 dark:hover:bg-red-900/20"
onClick={(event) => {
event.stopPropagation();
onDeleteWorkspace(workspace);
}}
title="Delete workspace"
>
<Trash2 className="h-3 w-3 text-red-600 dark:text-red-400" />
</div>
{isExpanded ? (
<ChevronDown className="h-4 w-4 text-muted-foreground transition-colors group-hover:text-foreground" />
) : (
<ChevronRight className="h-4 w-4 text-muted-foreground transition-colors group-hover:text-foreground" />
)}
</>
)}
</div>
</Button>
</div>
{isExpanded && (
<div className="ml-3 space-y-1 border-l border-border pl-3">
{workspace.sessions.length === 0 ? (
<div className="px-3 py-2 text-left">
<p className="text-xs text-muted-foreground">No sessions yet</p>
</div>
) : (
workspace.sessions.map((session) => (
<SidebarSessionItem
key={session.sessionId}
session={session}
isSelected={session.sessionId === selectedSessionId}
isEditing={editingSessionId === session.sessionId}
editingSessionName={editingSessionName}
isSavingSessionName={isSavingSessionName}
onEditingSessionNameChange={onEditingSessionNameChange}
onStartEdit={() => onStartSessionRename(session)}
onCancelEdit={onCancelSessionRename}
onSaveEdit={onSaveSessionRename}
onSelect={() =>
onSessionSelect(workspace.workspaceOriginalPath, session.sessionId)
}
onDelete={() =>
onSessionDelete(workspace.workspaceOriginalPath, session.sessionId)
}
/>
))
)}
<div className="px-3 pb-2 md:hidden">
<button
className="flex h-8 w-full items-center justify-center gap-2 rounded-md bg-primary text-xs font-medium text-primary-foreground transition-all duration-150 hover:bg-primary/90 active:scale-[0.98]"
onClick={(event) => {
event.stopPropagation();
onNewSession();
}}
>
<Plus className="h-3 w-3" />
New Session
</button>
</div>
<Button
variant="default"
size="sm"
className="mt-1 hidden h-8 w-full justify-start gap-2 bg-primary text-xs font-medium text-primary-foreground transition-colors hover:bg-primary/90 md:flex"
onClick={onNewSession}
>
<Plus className="h-3 w-3" />
New Session
</Button>
</div>
)}
</div>
);
}

View File

@@ -0,0 +1,169 @@
import type { WorkspaceRecord } from '@/components/refactored/sidebar/types';
import { SidebarWorkspaceItem } from '@/components/refactored/sidebar/view/SidebarWorkspaceItem';
type SidebarWorkspaceListProps = {
workspacesCount: number;
searchFilter: string;
starredWorkspaces: WorkspaceRecord[];
unstarredWorkspaces: WorkspaceRecord[];
expandedWorkspaces: Set<string>;
selectedSessionId: string | null;
editingWorkspacePath: string | null;
editingWorkspaceName: string;
isSavingWorkspaceName: boolean;
editingSessionId: string | null;
editingSessionName: string;
isSavingSessionName: boolean;
onEditingWorkspaceNameChange: (name: string) => void;
onEditingSessionNameChange: (name: string) => void;
onToggleWorkspace: (workspacePath: string) => void;
onToggleWorkspaceStar: (workspacePath: string) => void;
onStartWorkspaceRename: (workspace: WorkspaceRecord) => void;
onCancelWorkspaceRename: () => void;
onSaveWorkspaceRename: () => void;
onStartSessionRename: (session: WorkspaceRecord['sessions'][number]) => void;
onCancelSessionRename: () => void;
onSaveSessionRename: () => void;
onDeleteWorkspace: (workspace: WorkspaceRecord) => void;
onSessionSelect: (workspacePath: string, sessionId: string) => void;
onSessionDelete: (workspacePath: string, sessionId: string) => void;
onNewSession: () => void;
};
const SectionHeading = ({ title }: { title: string }) => (
<div className="px-3 pb-1 pt-2">
<p className="text-xs font-semibold uppercase tracking-wide text-muted-foreground">{title}</p>
</div>
);
const EmptyState = ({ title, description }: { title: string; description: string }) => (
<div className="px-4 py-8 text-center">
<h3 className="mb-2 text-sm font-medium text-foreground">{title}</h3>
<p className="text-xs text-muted-foreground">{description}</p>
</div>
);
/**
* Component layer (The Face)
* Displays Starred and regular workspace sections with shared item rendering.
*/
export function SidebarWorkspaceList({
workspacesCount,
searchFilter,
starredWorkspaces,
unstarredWorkspaces,
expandedWorkspaces,
selectedSessionId,
editingWorkspacePath,
editingWorkspaceName,
isSavingWorkspaceName,
editingSessionId,
editingSessionName,
isSavingSessionName,
onEditingWorkspaceNameChange,
onEditingSessionNameChange,
onToggleWorkspace,
onToggleWorkspaceStar,
onStartWorkspaceRename,
onCancelWorkspaceRename,
onSaveWorkspaceRename,
onStartSessionRename,
onCancelSessionRename,
onSaveSessionRename,
onDeleteWorkspace,
onSessionSelect,
onSessionDelete,
onNewSession,
}: SidebarWorkspaceListProps) {
const visibleWorkspaceCount = starredWorkspaces.length + unstarredWorkspaces.length;
if (workspacesCount === 0) {
return (
<EmptyState
title="No workspaces yet"
description="Create a project to start adding sessions."
/>
);
}
if (visibleWorkspaceCount === 0) {
return (
<EmptyState
title="No matches found"
description={`No results for "${searchFilter}".`}
/>
);
}
return (
<div className="pb-safe-area-inset-bottom md:space-y-1">
{starredWorkspaces.length > 0 && (
<>
<SectionHeading title="Starred" />
{starredWorkspaces.map((workspace) => (
<SidebarWorkspaceItem
key={workspace.workspaceOriginalPath}
workspace={workspace}
isExpanded={expandedWorkspaces.has(workspace.workspaceOriginalPath)}
selectedSessionId={selectedSessionId}
editingWorkspacePath={editingWorkspacePath}
editingWorkspaceName={editingWorkspaceName}
isSavingWorkspaceName={isSavingWorkspaceName}
editingSessionId={editingSessionId}
editingSessionName={editingSessionName}
isSavingSessionName={isSavingSessionName}
onEditingWorkspaceNameChange={onEditingWorkspaceNameChange}
onEditingSessionNameChange={onEditingSessionNameChange}
onToggleWorkspace={onToggleWorkspace}
onToggleWorkspaceStar={onToggleWorkspaceStar}
onStartWorkspaceRename={onStartWorkspaceRename}
onCancelWorkspaceRename={onCancelWorkspaceRename}
onSaveWorkspaceRename={onSaveWorkspaceRename}
onStartSessionRename={onStartSessionRename}
onCancelSessionRename={onCancelSessionRename}
onSaveSessionRename={onSaveSessionRename}
onDeleteWorkspace={onDeleteWorkspace}
onSessionSelect={onSessionSelect}
onSessionDelete={onSessionDelete}
onNewSession={onNewSession}
/>
))}
</>
)}
{unstarredWorkspaces.length > 0 && (
<>
<SectionHeading title="Projects" />
{unstarredWorkspaces.map((workspace) => (
<SidebarWorkspaceItem
key={workspace.workspaceOriginalPath}
workspace={workspace}
isExpanded={expandedWorkspaces.has(workspace.workspaceOriginalPath)}
selectedSessionId={selectedSessionId}
editingWorkspacePath={editingWorkspacePath}
editingWorkspaceName={editingWorkspaceName}
isSavingWorkspaceName={isSavingWorkspaceName}
editingSessionId={editingSessionId}
editingSessionName={editingSessionName}
isSavingSessionName={isSavingSessionName}
onEditingWorkspaceNameChange={onEditingWorkspaceNameChange}
onEditingSessionNameChange={onEditingSessionNameChange}
onToggleWorkspace={onToggleWorkspace}
onToggleWorkspaceStar={onToggleWorkspaceStar}
onStartWorkspaceRename={onStartWorkspaceRename}
onCancelWorkspaceRename={onCancelWorkspaceRename}
onSaveWorkspaceRename={onSaveWorkspaceRename}
onStartSessionRename={onStartSessionRename}
onCancelSessionRename={onCancelSessionRename}
onSaveSessionRename={onSaveSessionRename}
onDeleteWorkspace={onDeleteWorkspace}
onSessionSelect={onSessionSelect}
onSessionDelete={onSessionDelete}
onNewSession={onNewSession}
/>
))}
</>
)}
</div>
);
}