mirror of
https://github.com/siteboon/claudecodeui.git
synced 2026-06-28 15:25:27 +08:00
refactor(projects/sidebar): remove temp snapshot side-effects and simplify session metadata UX
Why this change was needed: - Project listing had an implicit side effect: every fetch wrote a debug snapshot under `.tmp/project-dumps`. That added unnecessary disk I/O to a hot path, introduced hidden runtime behavior, and created maintenance overhead for code that was not part of product functionality. - Keeping snapshot-specific exports/tests around made the projects module API broader than needed and coupled tests to temporary/debug behavior instead of user-visible behavior. - Codex sessions could remain stuck with a placeholder name (`Untitled Codex Session`) even after a real title became available from newer sync data, which degraded session discoverability in the UI. - Sidebar session rows showed duplicated provider branding and long-form relative times, which added visual noise and reduced scan speed when many sessions are listed. What changed: - Removed temporary projects snapshot dumping from `projects-with-sessions-fetch.service.ts`: - deleted snapshot types/helpers and file-write flow - removed the write call from `getProjectsWithSessions` - Removed snapshot-related surface area from `projects/index.ts`. - Removed the snapshot-focused test `projects.service.test.ts` that only validated removed debug behavior. - Updated `codex-session-synchronizer.provider.ts` to upgrade session names when an existing session still has the placeholder title but a real parsed name is now available. - Updated `SidebarSessionItem.tsx`: - removed duplicate provider logo rendering in each session row - moved age indicator to the right side - made age indicator fade on hover to prioritize action controls - switched to compact relative time format (`<1m`, `Xm`, `Xhr`, `Xd`) for faster list scanning Outcome: - Lower overhead and fewer hidden side effects in project fetches. - Cleaner module boundaries in projects. - Better Codex session naming consistency after sync. - Cleaner sidebar density and clearer hover/action behavior.
This commit is contained in:
@@ -1,8 +1,6 @@
|
|||||||
export {
|
export {
|
||||||
createProjectsSnapshot,
|
|
||||||
generateDisplayName,
|
generateDisplayName,
|
||||||
getProjectsWithSessions,
|
getProjectsWithSessions,
|
||||||
writeSnapshot,
|
|
||||||
} from './services/projects-with-sessions-fetch.service.js';
|
} from './services/projects-with-sessions-fetch.service.js';
|
||||||
export { updateProjectDisplayName } from './services/project-management.service.js';
|
export { updateProjectDisplayName } from './services/project-management.service.js';
|
||||||
export { deleteOrArchiveProject, deleteSessionJsonlFilesForProjectPath } from './services/project-delete.service.js';
|
export { deleteOrArchiveProject, deleteSessionJsonlFilesForProjectPath } from './services/project-delete.service.js';
|
||||||
|
|||||||
@@ -5,7 +5,6 @@ import { projectsDb, sessionsDb } from '@/modules/database/index.js';
|
|||||||
import { sessionSynchronizerService } from '@/modules/providers/index.js';
|
import { sessionSynchronizerService } from '@/modules/providers/index.js';
|
||||||
import { WS_OPEN_STATE, connectedClients } from '@/modules/websocket/index.js';
|
import { WS_OPEN_STATE, connectedClients } from '@/modules/websocket/index.js';
|
||||||
import type { RealtimeClientConnection } from '@/shared/types.js';
|
import type { RealtimeClientConnection } from '@/shared/types.js';
|
||||||
import { findAppRoot, getModuleDir } from '@/utils/runtime-paths.js';
|
|
||||||
|
|
||||||
type SessionSummary = {
|
type SessionSummary = {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -32,12 +31,6 @@ export type ProjectListItem = {
|
|||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
export type ProjectsSnapshot = {
|
|
||||||
generatedAt: string;
|
|
||||||
projectCount: number;
|
|
||||||
projects: ProjectListItem[];
|
|
||||||
};
|
|
||||||
|
|
||||||
type ProgressUpdate = {
|
type ProgressUpdate = {
|
||||||
phase: 'loading' | 'complete';
|
phase: 'loading' | 'complete';
|
||||||
current: number;
|
current: number;
|
||||||
@@ -49,12 +42,6 @@ type GetProjectsWithSessionsOptions = {
|
|||||||
skipSynchronization?: boolean;
|
skipSynchronization?: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
const __dirname = getModuleDir(import.meta.url);
|
|
||||||
const APP_ROOT = findAppRoot(__dirname);
|
|
||||||
const PROJECTS_DUMP_DIR = path.join(APP_ROOT, '.tmp', 'project-dumps');
|
|
||||||
|
|
||||||
let projectsSnapshotCounter: number | null = null;
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Generate better display name from path.
|
* Generate better display name from path.
|
||||||
*/
|
*/
|
||||||
@@ -126,62 +113,6 @@ function buildSessionsByProviderFromDb(projectPath: string): SessionsByProvider
|
|||||||
return byProvider;
|
return byProvider;
|
||||||
}
|
}
|
||||||
|
|
||||||
async function getNextProjectsSnapshotPath(): Promise<string> {
|
|
||||||
await fs.mkdir(PROJECTS_DUMP_DIR, { recursive: true });
|
|
||||||
|
|
||||||
if (projectsSnapshotCounter === null) {
|
|
||||||
const entries = await fs.readdir(PROJECTS_DUMP_DIR).catch(() => []);
|
|
||||||
projectsSnapshotCounter = entries.reduce((max, entry) => {
|
|
||||||
const match = entry.match(/^projects-(\d+)\.json$/);
|
|
||||||
if (!match) {
|
|
||||||
return max;
|
|
||||||
}
|
|
||||||
|
|
||||||
return Math.max(max, Number(match[1]));
|
|
||||||
}, 0);
|
|
||||||
}
|
|
||||||
|
|
||||||
projectsSnapshotCounter += 1;
|
|
||||||
const suffix = String(projectsSnapshotCounter).padStart(4, '0');
|
|
||||||
return path.join(PROJECTS_DUMP_DIR, `projects-${suffix}.json`);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Builds a typed snapshot payload for project dumps.
|
|
||||||
*/
|
|
||||||
export function createProjectsSnapshot(projects: ProjectListItem[]): ProjectsSnapshot {
|
|
||||||
return {
|
|
||||||
generatedAt: new Date().toISOString(),
|
|
||||||
projectCount: projects.length,
|
|
||||||
projects,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Writes a projects snapshot file as an incrementing artifact.
|
|
||||||
*/
|
|
||||||
export async function writeSnapshot(projects: ProjectListItem[]): Promise<void> {
|
|
||||||
try {
|
|
||||||
const snapshot = createProjectsSnapshot(projects);
|
|
||||||
const snapshotJson = JSON.stringify(snapshot, (_, value) => (typeof value === 'bigint' ? value.toString() : value), 2);
|
|
||||||
|
|
||||||
while (true) {
|
|
||||||
const snapshotPath = await getNextProjectsSnapshotPath();
|
|
||||||
try {
|
|
||||||
await fs.writeFile(snapshotPath, snapshotJson, { encoding: 'utf8', flag: 'wx' });
|
|
||||||
break;
|
|
||||||
} catch (error) {
|
|
||||||
if ((error as NodeJS.ErrnoException).code === 'EEXIST') {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
throw error;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
console.warn('Could not write projects snapshot:', (error as Error).message);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Broadcast progress to all connected WebSocket clients
|
// Broadcast progress to all connected WebSocket clients
|
||||||
function broadcastProgress(progress: ProgressUpdate) {
|
function broadcastProgress(progress: ProgressUpdate) {
|
||||||
const message = JSON.stringify({
|
const message = JSON.stringify({
|
||||||
@@ -261,6 +192,5 @@ export async function getProjectsWithSessions(
|
|||||||
total: totalProjects,
|
total: totalProjects,
|
||||||
});
|
});
|
||||||
|
|
||||||
await writeSnapshot(projects);
|
|
||||||
return projects;
|
return projects;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,33 +0,0 @@
|
|||||||
import assert from 'node:assert/strict';
|
|
||||||
import test from 'node:test';
|
|
||||||
|
|
||||||
import {
|
|
||||||
createProjectsSnapshot,
|
|
||||||
} from '@/modules/projects/index.js';
|
|
||||||
import { ProjectListItem, ProjectsSnapshot } from '@/modules/projects/services/projects-with-sessions-fetch.service.js';
|
|
||||||
|
|
||||||
test('createProjectsSnapshot returns an object matching the predefined snapshot type', () => {
|
|
||||||
const projects: ProjectListItem[] = [
|
|
||||||
{
|
|
||||||
projectId: 'project-1',
|
|
||||||
path: '/tmp/project-1',
|
|
||||||
displayName: 'project-1',
|
|
||||||
fullPath: '/tmp/project-1',
|
|
||||||
isStarred: false,
|
|
||||||
sessions: [],
|
|
||||||
cursorSessions: [],
|
|
||||||
codexSessions: [],
|
|
||||||
geminiSessions: [],
|
|
||||||
sessionMeta: {
|
|
||||||
hasMore: false,
|
|
||||||
total: 0,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
const snapshot: ProjectsSnapshot = createProjectsSnapshot(projects);
|
|
||||||
|
|
||||||
assert.equal(typeof snapshot.generatedAt, 'string');
|
|
||||||
assert.equal(snapshot.projectCount, 1);
|
|
||||||
assert.deepEqual(snapshot.projects, projects);
|
|
||||||
});
|
|
||||||
@@ -42,6 +42,14 @@ export class CodexSessionSynchronizer implements IProviderSessionSynchronizer {
|
|||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const existingSession = sessionsDb.getSessionById(parsed.sessionId);
|
||||||
|
if (existingSession) {
|
||||||
|
// If session name is untitled and we now have a name, update it
|
||||||
|
if (existingSession.custom_name === 'Untitled Codex Session' && parsed.sessionName && parsed.sessionName !== 'Untitled Codex Session') {
|
||||||
|
sessionsDb.updateSessionCustomName(parsed.sessionId, parsed.sessionName);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const timestamps = await readFileTimestamps(filePath);
|
const timestamps = await readFileTimestamps(filePath);
|
||||||
sessionsDb.createSession(
|
sessionsDb.createSession(
|
||||||
parsed.sessionId,
|
parsed.sessionId,
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
import { Check, Clock, Edit2, Trash2, X } from 'lucide-react';
|
import { Check, Edit2, Trash2, X } from 'lucide-react';
|
||||||
import type { TFunction } from 'i18next';
|
import type { TFunction } from 'i18next';
|
||||||
|
|
||||||
import { Badge, Button } from '../../../../shared/view/ui';
|
import { Badge, Button } from '../../../../shared/view/ui';
|
||||||
import { cn } from '../../../../lib/utils';
|
import { cn } from '../../../../lib/utils';
|
||||||
import { formatTimeAgo } from '../../../../utils/dateUtils';
|
|
||||||
import type { Project, ProjectSession, LLMProvider } from '../../../../types/app';
|
import type { Project, ProjectSession, LLMProvider } from '../../../../types/app';
|
||||||
import type { SessionWithProvider } from '../../types/types';
|
import type { SessionWithProvider } from '../../types/types';
|
||||||
import { createSessionViewModel } from '../../utils/utils';
|
import { createSessionViewModel } from '../../utils/utils';
|
||||||
@@ -30,6 +30,34 @@ type SidebarSessionItemProps = {
|
|||||||
t: TFunction;
|
t: TFunction;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Compact relative time for sidebar rows:
|
||||||
|
* <1m, Xm, Xhr, Xd.
|
||||||
|
*/
|
||||||
|
const formatCompactSessionAge = (dateString: string, currentTime: Date): string => {
|
||||||
|
const date = new Date(dateString);
|
||||||
|
if (Number.isNaN(date.getTime())) {
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
|
||||||
|
const diffInMinutes = Math.floor(Math.max(0, currentTime.getTime() - date.getTime()) / (1000 * 60));
|
||||||
|
if (diffInMinutes < 1) {
|
||||||
|
return '<1m';
|
||||||
|
}
|
||||||
|
|
||||||
|
if (diffInMinutes < 60) {
|
||||||
|
return `${diffInMinutes}m`;
|
||||||
|
}
|
||||||
|
|
||||||
|
const diffInHours = Math.floor(diffInMinutes / 60);
|
||||||
|
if (diffInHours < 24) {
|
||||||
|
return `${diffInHours}hr`;
|
||||||
|
}
|
||||||
|
|
||||||
|
const diffInDays = Math.floor(diffInHours / 24);
|
||||||
|
return `${diffInDays}d`;
|
||||||
|
};
|
||||||
|
|
||||||
export default function SidebarSessionItem({
|
export default function SidebarSessionItem({
|
||||||
project,
|
project,
|
||||||
session,
|
session,
|
||||||
@@ -48,6 +76,7 @@ export default function SidebarSessionItem({
|
|||||||
}: SidebarSessionItemProps) {
|
}: SidebarSessionItemProps) {
|
||||||
const sessionView = createSessionViewModel(session, currentTime, t);
|
const sessionView = createSessionViewModel(session, currentTime, t);
|
||||||
const isSelected = selectedSession?.id === session.id;
|
const isSelected = selectedSession?.id === session.id;
|
||||||
|
const compactSessionAge = formatCompactSessionAge(sessionView.sessionTime, currentTime);
|
||||||
|
|
||||||
// Sessions are owned by a project identified by `projectId` (DB primary key)
|
// Sessions are owned by a project identified by `projectId` (DB primary key)
|
||||||
// after the projectName → projectId migration.
|
// after the projectName → projectId migration.
|
||||||
@@ -94,20 +123,18 @@ export default function SidebarSessionItem({
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="min-w-0 flex-1">
|
<div className="min-w-0 flex-1">
|
||||||
<div className="truncate text-xs font-medium text-foreground">{sessionView.sessionName}</div>
|
<div className="flex items-center gap-2">
|
||||||
<div className="mt-0.5 flex items-center gap-1">
|
<div className="truncate text-xs font-medium text-foreground">{sessionView.sessionName}</div>
|
||||||
<Clock className="h-2.5 w-2.5 text-muted-foreground" />
|
{compactSessionAge && (
|
||||||
<span className="text-xs text-muted-foreground">
|
<span className="ml-auto flex-shrink-0 text-[11px] text-muted-foreground">{compactSessionAge}</span>
|
||||||
{formatTimeAgo(sessionView.sessionTime, currentTime, t)}
|
)}
|
||||||
</span>
|
</div>
|
||||||
|
<div className="mt-0.5 flex items-center">
|
||||||
{sessionView.messageCount > 0 && (
|
{sessionView.messageCount > 0 && (
|
||||||
<Badge variant="secondary" className="ml-auto px-1 py-0 text-xs">
|
<Badge variant="secondary" className="px-1 py-0 text-xs">
|
||||||
{sessionView.messageCount}
|
{sessionView.messageCount}
|
||||||
</Badge>
|
</Badge>
|
||||||
)}
|
)}
|
||||||
<span className="ml-1 opacity-70">
|
|
||||||
<SessionProviderLogo provider={session.__provider} className="h-3 w-3" />
|
|
||||||
</span>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -138,23 +165,16 @@ export default function SidebarSessionItem({
|
|||||||
<div className="flex w-full min-w-0 items-start gap-2">
|
<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" />
|
<SessionProviderLogo provider={session.__provider} className="mt-0.5 h-3 w-3 flex-shrink-0" />
|
||||||
<div className="min-w-0 flex-1">
|
<div className="min-w-0 flex-1">
|
||||||
<div className="truncate text-xs font-medium text-foreground">{sessionView.sessionName}</div>
|
<div className="flex items-center gap-2">
|
||||||
<div className="mt-0.5 flex items-center gap-1">
|
<div className="truncate text-xs font-medium text-foreground">{sessionView.sessionName}</div>
|
||||||
<Clock className="h-2.5 w-2.5 text-muted-foreground" />
|
{compactSessionAge && (
|
||||||
<span className="text-xs text-muted-foreground">
|
<span className="ml-auto flex-shrink-0 text-[11px] text-muted-foreground transition-opacity duration-200 group-hover:opacity-0">
|
||||||
{formatTimeAgo(sessionView.sessionTime, currentTime, t)}
|
{compactSessionAge}
|
||||||
</span>
|
</span>
|
||||||
{sessionView.messageCount > 0 && (
|
|
||||||
<Badge
|
|
||||||
variant="secondary"
|
|
||||||
className="ml-auto px-1 py-0 text-xs transition-opacity group-hover:opacity-0"
|
|
||||||
>
|
|
||||||
{sessionView.messageCount}
|
|
||||||
</Badge>
|
|
||||||
)}
|
)}
|
||||||
<span className="ml-1 opacity-70 transition-opacity group-hover:opacity-0">
|
</div>
|
||||||
<SessionProviderLogo provider={session.__provider} className="h-3 w-3" />
|
<div className="mt-0.5 flex items-center">
|
||||||
</span>
|
{sessionView.messageCount > 0 && <Badge variant="secondary" className="px-1 py-0 text-xs">{sessionView.messageCount}</Badge>}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
Reference in New Issue
Block a user