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