From b42349fdbaba13891c4f83deb0e24128a3bbe547 Mon Sep 17 00:00:00 2001 From: matt Date: Thu, 12 Feb 2026 18:02:48 +0900 Subject: [PATCH] feat(sessions): introduce metadata level for session retrieval - Added a new `metadataLevel` option to the sessions pagination API, allowing clients to specify the depth of metadata returned (light or deep). - Updated the `ProjectScanner` to handle session metadata based on the specified level, improving performance and flexibility in session data retrieval. - Enhanced the HTTP client and session slice to support the new metadata level option, ensuring consistent behavior across the application. This commit enhances session management by providing users with the ability to choose the level of detail in session metadata, optimizing performance for different use cases. --- src/main/http/sessions.ts | 2 + src/main/services/discovery/ProjectScanner.ts | 129 ++++++++++-------- .../services/discovery/WorktreeGrouper.ts | 4 +- src/main/types/domain.ts | 7 + src/renderer/api/httpClient.ts | 1 + src/renderer/store/slices/sessionSlice.ts | 6 + 6 files changed, 94 insertions(+), 55 deletions(-) diff --git a/src/main/http/sessions.ts b/src/main/http/sessions.ts index 1f83c7d5..da07d285 100644 --- a/src/main/http/sessions.ts +++ b/src/main/http/sessions.ts @@ -50,6 +50,7 @@ export function registerSessionRoutes(app: FastifyInstance, services: HttpServic limit?: string; includeTotalCount?: string; prefilterAll?: string; + metadataLevel?: 'light' | 'deep'; }; }>('/api/projects/:projectId/sessions-paginated', async (request) => { try { @@ -67,6 +68,7 @@ export function registerSessionRoutes(app: FastifyInstance, services: HttpServic const options: SessionsPaginationOptions = { includeTotalCount: request.query.includeTotalCount !== 'false', prefilterAll: request.query.prefilterAll !== 'false', + metadataLevel: request.query.metadataLevel, }; const result = await services.projectScanner.listSessionsPaginated( diff --git a/src/main/services/discovery/ProjectScanner.ts b/src/main/services/discovery/ProjectScanner.ts index e5828392..91686614 100644 --- a/src/main/services/discovery/ProjectScanner.ts +++ b/src/main/services/discovery/ProjectScanner.ts @@ -116,7 +116,11 @@ export class ProjectScanner { ); // Process each project directory (may return multiple projects per dir) - const projectArrays = await Promise.all(projectDirs.map((dir) => this.scanProject(dir.name))); + const projectArrays = await this.collectFulfilledInBatches( + projectDirs, + this.fsProvider.type === 'ssh' ? 8 : 24, + async (dir) => this.scanProject(dir.name) + ); // Flatten and sort by most recent const validProjects = projectArrays.flat(); @@ -381,6 +385,7 @@ export class ProjectScanner { const projectPath = path.join(this.projectsDir, baseDir); const sessionFilter = subprojectRegistry.getSessionFilter(projectId); const shouldFilterNoise = this.fsProvider.type !== 'ssh'; + const metadataLevel: SessionMetadataLevel = this.fsProvider.type === 'ssh' ? 'light' : 'deep'; if (!(await this.fsProvider.exists(projectPath))) { return []; @@ -411,28 +416,14 @@ export class ProjectScanner { } } - try { - return await this.buildSessionMetadata( - projectId, - sessionId, - filePath, - decodedPath, - prefetchedMtimeMs - ); - } catch (error) { - if (this.fsProvider.type !== 'ssh') { - throw error; - } - - logger.debug(`SSH metadata parse failed for ${sessionId}, using light fallback`, error); - return this.buildLightSessionMetadata( - projectId, - sessionId, - filePath, - decodedPath, - prefetchedMtimeMs - ); - } + return this.buildSessionForListing( + metadataLevel, + projectId, + sessionId, + filePath, + decodedPath, + prefetchedMtimeMs + ); }) ); @@ -472,6 +463,8 @@ export class ProjectScanner { const projectPath = path.join(this.projectsDir, baseDir); const sessionFilter = subprojectRegistry.getSessionFilter(projectId); const shouldFilterNoise = this.fsProvider.type !== 'ssh'; + const metadataLevel: SessionMetadataLevel = + options?.metadataLevel ?? (this.fsProvider.type === 'ssh' ? 'light' : 'deep'); if (!(await this.fsProvider.exists(projectPath))) { return { sessions: [], nextCursor: null, hasMore: false, totalCount: 0 }; @@ -524,7 +517,7 @@ export class ProjectScanner { // This is slower but provides exact totalCount. let validSessionIds: Set | null = null; let totalCount = 0; - if (prefilterAll && shouldFilterNoise) { + if (prefilterAll && shouldFilterNoise && metadataLevel === 'deep') { const contentResults = await Promise.allSettled( fileInfos.map(async (fileInfo) => ({ sessionId: fileInfo.sessionId, @@ -613,34 +606,16 @@ export class ProjectScanner { const toBuild = withContent.slice(0, needed); const builtSessions = await Promise.all( - toBuild.map(async ({ fileInfo }) => { - try { - return await this.buildSessionMetadata( - projectId, - fileInfo.sessionId, - fileInfo.filePath, - decodedPath, - fileInfo.mtimeMs - ); - } catch (error) { - // In SSH mode, never drop a visible session row due to transient deep-parse failures. - if (this.fsProvider.type !== 'ssh') { - throw error; - } - - logger.debug( - `SSH page metadata parse failed for ${fileInfo.sessionId}, using light fallback`, - error - ); - return this.buildLightSessionMetadata( - projectId, - fileInfo.sessionId, - fileInfo.filePath, - decodedPath, - fileInfo.mtimeMs - ); - } - }) + toBuild.map(({ fileInfo }) => + this.buildSessionForListing( + metadataLevel, + projectId, + fileInfo.sessionId, + fileInfo.filePath, + decodedPath, + fileInfo.mtimeMs + ) + ) ); sessions.push(...builtSessions); @@ -701,8 +676,7 @@ export class ProjectScanner { projectPath: string, prefetchedMtimeMs?: number ): Promise { - const usePrefetchedTimes = - this.fsProvider.type === 'ssh' && typeof prefetchedMtimeMs === 'number'; + const usePrefetchedTimes = typeof prefetchedMtimeMs === 'number'; const stats = usePrefetchedTimes ? null : await this.fsProvider.stat(filePath); const effectiveMtime = prefetchedMtimeMs ?? stats?.mtimeMs ?? Date.now(); const birthtimeMs = stats?.birthtimeMs ?? effectiveMtime; @@ -766,6 +740,53 @@ export class ProjectScanner { }; } + /** + * Build session metadata according to requested listing depth. + * In SSH mode, deep parse failures degrade gracefully to light metadata. + */ + private async buildSessionForListing( + metadataLevel: SessionMetadataLevel, + projectId: string, + sessionId: string, + filePath: string, + projectPath: string, + prefetchedMtimeMs?: number + ): Promise { + if (metadataLevel === 'light') { + return this.buildLightSessionMetadata( + projectId, + sessionId, + filePath, + projectPath, + prefetchedMtimeMs + ); + } + + try { + return await this.buildSessionMetadata( + projectId, + sessionId, + filePath, + projectPath, + prefetchedMtimeMs + ); + } catch (error) { + // In SSH mode, never drop a visible session row due to transient deep-parse failures. + if (this.fsProvider.type !== 'ssh') { + throw error; + } + + logger.debug(`SSH metadata parse failed for ${sessionId}, using light fallback`, error); + return this.buildLightSessionMetadata( + projectId, + sessionId, + filePath, + projectPath, + prefetchedMtimeMs + ); + } + } + /** * Gets a single session's metadata. */ diff --git a/src/main/services/discovery/WorktreeGrouper.ts b/src/main/services/discovery/WorktreeGrouper.ts index daf627ce..685c92a7 100644 --- a/src/main/services/discovery/WorktreeGrouper.ts +++ b/src/main/services/discovery/WorktreeGrouper.ts @@ -71,7 +71,9 @@ export class WorktreeGrouper { // 2. Filter sessions for each project to only include non-noise sessions const projectFilteredSessions = new Map(); - const shouldFilterNoise = this.fsProvider.type !== 'ssh'; + // Fast-first default for both local and SSH: avoid full-file scans during dashboard load. + // Can be re-enabled for strict parity debugging. + const shouldFilterNoise = process.env.CLAUDE_DEVTOOLS_STRICT_SESSION_FILTER === '1'; await Promise.all( projects.map(async (project) => { const baseDir = extractBaseDir(project.id); diff --git a/src/main/types/domain.ts b/src/main/types/domain.ts index ac77c03e..b80bc42a 100644 --- a/src/main/types/domain.ts +++ b/src/main/types/domain.ts @@ -284,4 +284,11 @@ export interface SessionsPaginationOptions { * @default true */ prefilterAll?: boolean; + /** + * Metadata depth to return for listed sessions. + * - light: filesystem metadata only (fast) + * - deep: includes parsed session content summary fields (slower) + * @default 'deep' + */ + metadataLevel?: SessionMetadataLevel; } diff --git a/src/renderer/api/httpClient.ts b/src/renderer/api/httpClient.ts index 7fcdea5d..04e420fe 100644 --- a/src/renderer/api/httpClient.ts +++ b/src/renderer/api/httpClient.ts @@ -190,6 +190,7 @@ export class HttpAPIClient implements ElectronAPI { if (limit) params.set('limit', String(limit)); if (options?.includeTotalCount === false) params.set('includeTotalCount', 'false'); if (options?.prefilterAll === false) params.set('prefilterAll', 'false'); + if (options?.metadataLevel) params.set('metadataLevel', options.metadataLevel); const qs = params.toString(); const encodedId = encodeURIComponent(projectId); const path = `/api/projects/${encodedId}/sessions-paginated`; diff --git a/src/renderer/store/slices/sessionSlice.ts b/src/renderer/store/slices/sessionSlice.ts index 3336c0ea..27abda82 100644 --- a/src/renderer/store/slices/sessionSlice.ts +++ b/src/renderer/store/slices/sessionSlice.ts @@ -95,9 +95,11 @@ export const createSessionSlice: StateCreator = sessionsTotalCount: 0, }); try { + const { connectionMode } = get(); const result = await api.getSessionsPaginated(projectId, null, 20, { includeTotalCount: false, prefilterAll: false, + metadataLevel: connectionMode === 'ssh' ? 'light' : 'deep', }); set({ sessions: result.sessions, @@ -129,9 +131,11 @@ export const createSessionSlice: StateCreator = set({ sessionsLoadingMore: true }); try { + const { connectionMode } = get(); const result = await api.getSessionsPaginated(selectedProjectId, sessionsCursor, 20, { includeTotalCount: false, prefilterAll: false, + metadataLevel: connectionMode === 'ssh' ? 'light' : 'deep', }); set((prevState) => { // Deduplicate: pinned sessions fetched earlier may appear in paginated results @@ -209,9 +213,11 @@ export const createSessionSlice: StateCreator = projectRefreshGeneration.set(projectId, generation); try { + const { connectionMode } = get(); const result = await api.getSessionsPaginated(projectId, null, 20, { includeTotalCount: false, prefilterAll: false, + metadataLevel: connectionMode === 'ssh' ? 'light' : 'deep', }); // Drop stale responses from older in-flight refreshes