diff --git a/src/main/services/discovery/ProjectScanner.ts b/src/main/services/discovery/ProjectScanner.ts index 0b3ac09b..f1e9eb23 100644 --- a/src/main/services/discovery/ProjectScanner.ts +++ b/src/main/services/discovery/ProjectScanner.ts @@ -39,11 +39,7 @@ import { type SessionsPaginationOptions, type WorktreeSource, } from '@main/types'; -import { - analyzeSessionFileMetadata, - extractCwd, - extractFirstUserMessagePreview, -} from '@main/utils/jsonl'; +import { analyzeSessionFileMetadata, extractCwd } from '@main/utils/jsonl'; import { buildSessionPath, buildSubagentsPath, @@ -1035,20 +1031,30 @@ export class ProjectScanner { const effectiveMtime = prefetchedMtimeMs ?? stats?.mtimeMs ?? Date.now(); const effectiveSize = prefetchedSize ?? stats?.size ?? -1; const birthtimeMs = prefetchedBirthtimeMs ?? stats?.birthtimeMs ?? effectiveMtime; - const cachedPreview = this.sessionPreviewCache.get(filePath); - const preview = - cachedPreview?.mtimeMs === effectiveMtime && cachedPreview.size === effectiveSize - ? cachedPreview.preview - : await this.extractLightPreviewWithRetry(filePath); - if (cachedPreview?.mtimeMs !== effectiveMtime || cachedPreview.size !== effectiveSize) { - this.sessionPreviewCache.set(filePath, { - mtimeMs: effectiveMtime, - size: effectiveSize, - preview, - }); + let metadata: Awaited>; + const cachedMetadata = this.sessionMetadataCache.get(filePath); + if (cachedMetadata?.mtimeMs === effectiveMtime && cachedMetadata.size === effectiveSize) { + metadata = cachedMetadata.metadata; + } else { + try { + metadata = await analyzeSessionFileMetadata(filePath, this.fsProvider); + this.sessionMetadataCache.set(filePath, { + mtimeMs: effectiveMtime, + size: effectiveSize, + metadata, + }); + } catch (error) { + logger.debug(`Failed to analyze session metadata for ${filePath}:`, error); + metadata = { + firstUserMessage: null, + messageCount: 0, + isOngoing: false, + gitBranch: null, + }; + } } const metadataLevel: SessionMetadataLevel = 'light'; - const previewTimestampMs = this.parseTimestampMs(preview?.timestamp); + const previewTimestampMs = this.parseTimestampMs(metadata.firstUserMessage?.timestamp); const createdAt = previewTimestampMs !== null && Number.isFinite(previewTimestampMs) ? previewTimestampMs @@ -1059,10 +1065,10 @@ export class ProjectScanner { projectId, projectPath, createdAt: Math.floor(createdAt), - firstMessage: preview?.text, - messageTimestamp: preview?.timestamp, + firstMessage: metadata.firstUserMessage?.text, + messageTimestamp: metadata.firstUserMessage?.timestamp, hasSubagents: false, - messageCount: 0, + messageCount: metadata.messageCount, metadataLevel, }; } @@ -1459,31 +1465,6 @@ export class ProjectScanner { return results; } - private async extractLightPreviewWithRetry( - filePath: string - ): Promise<{ text: string; timestamp: string } | null> { - const maxAttempts = this.fsProvider.type === 'ssh' ? 3 : 1; - let lastError: unknown; - - for (let attempt = 1; attempt <= maxAttempts; attempt++) { - try { - return await extractFirstUserMessagePreview(filePath, this.fsProvider); - } catch (error) { - lastError = error; - if (attempt < maxAttempts && this.isTransientFsError(error)) { - await this.sleep(50 * attempt); - continue; - } - break; - } - } - - if (lastError) { - logger.debug(`Failed to extract light preview for ${filePath}:`, lastError); - } - return null; - } - private getErrorCode(error: unknown): string { if (typeof error === 'object' && error !== null && 'code' in error) { const code = (error as { code?: unknown }).code; diff --git a/test/main/services/discovery/ProjectScanner.lightMetadata.test.ts b/test/main/services/discovery/ProjectScanner.lightMetadata.test.ts new file mode 100644 index 00000000..2751627e --- /dev/null +++ b/test/main/services/discovery/ProjectScanner.lightMetadata.test.ts @@ -0,0 +1,72 @@ +import * as fs from 'fs'; +import * as os from 'os'; +import * as path from 'path'; +import { afterEach, describe, expect, it } from 'vitest'; + +import { ProjectScanner } from '../../../../src/main/services/discovery/ProjectScanner'; + +describe('ProjectScanner light metadata', () => { + const tempDirs: string[] = []; + + afterEach(() => { + for (const dir of tempDirs) { + try { + fs.rmSync(dir, { recursive: true, force: true, maxRetries: 5, retryDelay: 200 }); + } catch { + // Ignore cleanup failures + } + } + tempDirs.length = 0; + }); + + it('reuses analyzed metadata so light sessions expose the real message count', async () => { + const projectsDir = fs.mkdtempSync(path.join(os.tmpdir(), 'scanner-light-')); + tempDirs.push(projectsDir); + + const encodedName = '-Users-test-myproject'; + const projectDir = path.join(projectsDir, encodedName); + fs.mkdirSync(projectDir); + + const filePath = path.join(projectDir, 'session.jsonl'); + fs.writeFileSync( + filePath, + [ + JSON.stringify({ + type: 'user', + uuid: 'u1', + cwd: '/Users/test/myproject', + timestamp: '2026-01-01T00:00:00.000Z', + message: { role: 'user', content: 'hello world' }, + }), + JSON.stringify({ + type: 'assistant', + uuid: 'a1', + timestamp: '2026-01-01T00:00:01.000Z', + message: { role: 'assistant', content: 'hi there' }, + }), + ].join('\n') + '\n', + 'utf8' + ); + + const scanner = new ProjectScanner(projectsDir); + const session = await ( + scanner as unknown as { + buildLightSessionMetadata: ( + projectId: string, + sessionId: string, + filePath: string, + projectPath: string + ) => Promise<{ messageCount: number; firstMessage?: string; metadataLevel: string }>; + } + ).buildLightSessionMetadata( + encodedName, + 'session', + filePath, + '/Users/test/myproject' + ); + + expect(session.metadataLevel).toBe('light'); + expect(session.firstMessage).toBe('hello world'); + expect(session.messageCount).toBe(2); + }); +});