diff --git a/README.md b/README.md index 1c874aed..b9794905 100644 --- a/README.md +++ b/README.md @@ -14,7 +14,7 @@

- claude-devtools Demo + claude-devtools Demo

--- diff --git a/src/main/http/sessions.ts b/src/main/http/sessions.ts index da07d285..991aa09b 100644 --- a/src/main/http/sessions.ts +++ b/src/main/http/sessions.ts @@ -15,7 +15,7 @@ import { createLogger } from '@shared/utils/logger'; import { coercePageLimit, validateProjectId, validateSessionId } from '../ipc/guards'; import { DataCache } from '../services'; -import type { SessionsPaginationOptions } from '../types'; +import type { SessionsByIdsOptions, SessionsPaginationOptions } from '../types'; import type { HttpServices } from './index'; import type { FastifyInstance } from 'fastify'; @@ -100,6 +100,7 @@ export function registerSessionRoutes(app: FastifyInstance, services: HttpServic logger.error('POST sessions-by-ids rejected: sessionIds must be an array'); return []; } + const { metadataLevel } = request.body as SessionsByIdsOptions; // Cap at 50 IDs const capped = sessionIds.slice(0, 50); @@ -117,8 +118,14 @@ export function registerSessionRoutes(app: FastifyInstance, services: HttpServic return []; } + const fsType = services.projectScanner.getFileSystemProvider().type; + const effectiveMetadataLevel = metadataLevel ?? (fsType === 'ssh' ? 'light' : 'deep'); const results = await Promise.all( - validIds.map((id) => services.projectScanner.getSession(validated.value!, id)) + validIds.map((id) => + services.projectScanner.getSessionWithOptions(validated.value!, id, { + metadataLevel: effectiveMetadataLevel, + }) + ) ); return results.filter((s): s is NonNullable => s !== null); diff --git a/src/main/ipc/sessions.ts b/src/main/ipc/sessions.ts index 2538178a..8043f4d3 100644 --- a/src/main/ipc/sessions.ts +++ b/src/main/ipc/sessions.ts @@ -19,6 +19,7 @@ import { type Session, type SessionDetail, type SessionMetrics, + type SessionsByIdsOptions, type SessionsPaginationOptions, } from '../types'; @@ -140,7 +141,8 @@ async function handleGetSessionsPaginated( async function handleGetSessionsByIds( _event: IpcMainInvokeEvent, projectId: string, - sessionIds: string[] + sessionIds: string[], + options?: SessionsByIdsOptions ): Promise { try { const validatedProject = validateProjectId(projectId); @@ -173,8 +175,12 @@ async function handleGetSessionsByIds( } const { projectScanner } = registry.getActive(); + const fsType = projectScanner.getFileSystemProvider().type; + const metadataLevel = options?.metadataLevel ?? (fsType === 'ssh' ? 'light' : 'deep'); const results = await Promise.all( - validIds.map((id) => projectScanner.getSession(validatedProject.value!, id)) + validIds.map((id) => + projectScanner.getSessionWithOptions(validatedProject.value!, id, { metadataLevel }) + ) ); return results.filter((s): s is Session => s !== null); diff --git a/src/main/services/discovery/ProjectScanner.ts b/src/main/services/discovery/ProjectScanner.ts index 91686614..4fed0ca7 100644 --- a/src/main/services/discovery/ProjectScanner.ts +++ b/src/main/services/discovery/ProjectScanner.ts @@ -23,9 +23,14 @@ import { type Session, type SessionCursor, type SessionMetadataLevel, + type SessionsByIdsOptions, type SessionsPaginationOptions, } from '@main/types'; -import { analyzeSessionFileMetadata, extractCwd } from '@main/utils/jsonl'; +import { + analyzeSessionFileMetadata, + extractCwd, + extractFirstUserMessagePreview, +} from '@main/utils/jsonl'; import { buildSessionPath, buildSubagentsPath, @@ -42,16 +47,16 @@ import * as path from 'path'; import { LocalFileSystemProvider } from '../infrastructure/LocalFileSystemProvider'; +import { ProjectPathResolver } from './ProjectPathResolver'; import { SessionContentFilter } from './SessionContentFilter'; +import { SessionSearcher } from './SessionSearcher'; +import { SubagentLocator } from './SubagentLocator'; import { subprojectRegistry } from './SubprojectRegistry'; +import { WorktreeGrouper } from './WorktreeGrouper'; import type { FileSystemProvider, FsDirent } from '../infrastructure/FileSystemProvider'; const logger = createLogger('Discovery:ProjectScanner'); -import { ProjectPathResolver } from './ProjectPathResolver'; -import { SessionSearcher } from './SessionSearcher'; -import { SubagentLocator } from './SubagentLocator'; -import { WorktreeGrouper } from './WorktreeGrouper'; export class ProjectScanner { private readonly projectsDir: string; @@ -67,6 +72,10 @@ export class ProjectScanner { metadata: Awaited>; } >(); + private readonly sessionPreviewCache = new Map< + string, + { mtimeMs: number; preview: { text: string; timestamp: string } | null } + >(); // Delegated services private readonly fsProvider: FileSystemProvider; @@ -605,8 +614,10 @@ export class ProjectScanner { const needed = limit + 1 - sessions.length; const toBuild = withContent.slice(0, needed); - const builtSessions = await Promise.all( - toBuild.map(({ fileInfo }) => + const builtSessions = await this.collectFulfilledInBatches( + toBuild, + this.fsProvider.type === 'ssh' ? 4 : 16, + async ({ fileInfo }) => this.buildSessionForListing( metadataLevel, projectId, @@ -615,7 +626,6 @@ export class ProjectScanner { decodedPath, fileInfo.mtimeMs ) - ) ); sessions.push(...builtSessions); @@ -727,6 +737,14 @@ export class ProjectScanner { typeof prefetchedMtimeMs === 'number' ? { mtimeMs: prefetchedMtimeMs, birthtimeMs: prefetchedMtimeMs } : await this.resolveFileTimes(undefined, filePath); + const cachedPreview = this.sessionPreviewCache.get(filePath); + const preview = + cachedPreview?.mtimeMs === times.mtimeMs + ? cachedPreview.preview + : await this.extractLightPreviewWithRetry(filePath); + if (cachedPreview?.mtimeMs !== times.mtimeMs) { + this.sessionPreviewCache.set(filePath, { mtimeMs: times.mtimeMs, preview }); + } const metadataLevel: SessionMetadataLevel = 'light'; return { @@ -734,6 +752,8 @@ export class ProjectScanner { projectId, projectPath, createdAt: Math.floor(times.birthtimeMs), + firstMessage: preview?.text, + messageTimestamp: preview?.timestamp, hasSubagents: false, messageCount: 0, metadataLevel, @@ -797,8 +817,29 @@ export class ProjectScanner { return null; } + const metadataLevel: SessionMetadataLevel = 'deep'; const decodedPath = await this.resolveProjectPathForId(projectId); - return this.buildSessionMetadata(projectId, sessionId, filePath, decodedPath); + return this.buildSessionForListing(metadataLevel, projectId, sessionId, filePath, decodedPath); + } + + /** + * Gets a single session's metadata with optional depth override. + */ + async getSessionWithOptions( + projectId: string, + sessionId: string, + options?: SessionsByIdsOptions + ): Promise { + const filePath = this.getSessionPath(projectId, sessionId); + + if (!(await this.fsProvider.exists(filePath))) { + return null; + } + + const metadataLevel: SessionMetadataLevel = + options?.metadataLevel ?? (this.fsProvider.type === 'ssh' ? 'light' : 'deep'); + const decodedPath = await this.resolveProjectPathForId(projectId); + return this.buildSessionForListing(metadataLevel, projectId, sessionId, filePath, decodedPath); } // =========================================================================== @@ -997,6 +1038,61 @@ 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; + if (typeof code === 'number') { + return String(code); + } + if (typeof code === 'string') { + return code; + } + } + return ''; + } + + private isTransientFsError(error: unknown): boolean { + const code = this.getErrorCode(error); + return ( + code === '4' || + code === 'EAGAIN' || + code === 'ECONNRESET' || + code === 'ETIMEDOUT' || + code === 'EPIPE' + ); + } + + private async sleep(ms: number): Promise { + await new Promise((resolve) => { + setTimeout(resolve, ms); + }); + } + /** * Resolves the project path for a given project ID. * For composite IDs, uses the registry's cwd directly. diff --git a/src/main/types/domain.ts b/src/main/types/domain.ts index b80bc42a..bb179481 100644 --- a/src/main/types/domain.ts +++ b/src/main/types/domain.ts @@ -292,3 +292,16 @@ export interface SessionsPaginationOptions { */ metadataLevel?: SessionMetadataLevel; } + +/** + * Options for targeted session fetches by session ID. + */ +export interface SessionsByIdsOptions { + /** + * Metadata depth to return for each session. + * - light: fast preview fields suitable for list/sidebar + * - deep: full summary metadata (slower) + * @default provider-specific default (SSH=light, local=deep) + */ + metadataLevel?: SessionMetadataLevel; +} diff --git a/src/main/utils/jsonl.ts b/src/main/utils/jsonl.ts index fe4e880c..15b2cc1f 100644 --- a/src/main/utils/jsonl.ts +++ b/src/main/utils/jsonl.ts @@ -36,7 +36,7 @@ const logger = createLogger('Util:jsonl'); const defaultProvider = new LocalFileSystemProvider(); // Re-export for backwards compatibility -export { extractCwd } from './metadataExtraction'; +export { extractCwd, extractFirstUserMessagePreview } from './metadataExtraction'; export { checkMessagesOngoing } from './sessionStateDetection'; // ============================================================================= diff --git a/src/main/utils/metadataExtraction.ts b/src/main/utils/metadataExtraction.ts index 9e8224cd..8281d9bc 100644 --- a/src/main/utils/metadataExtraction.ts +++ b/src/main/utils/metadataExtraction.ts @@ -2,11 +2,12 @@ * Metadata extraction utilities for parsing first messages and session context from JSONL files. */ +import { isCommandOutputContent, sanitizeDisplayContent } from '@shared/utils/contentSanitizer'; import { createLogger } from '@shared/utils/logger'; import * as readline from 'readline'; import { LocalFileSystemProvider } from '../services/infrastructure/LocalFileSystemProvider'; -import { type ChatHistoryEntry } from '../types'; +import { type ChatHistoryEntry, isTextContent, type UserEntry } from '../types'; import type { FileSystemProvider } from '../services/infrastructure/FileSystemProvider'; @@ -14,6 +15,12 @@ const logger = createLogger('Util:metadataExtraction'); const defaultProvider = new LocalFileSystemProvider(); +interface MessagePreview { + text: string; + timestamp: string; + isCommand: boolean; +} + /** * Extract CWD (current working directory) from the first entry. * Used to get the actual project path from encoded directory names. @@ -53,3 +60,137 @@ export async function extractCwd( return null; } + +/** + * Extract a lightweight title preview from the first user message. + * For command-style sessions, falls back to a slash-command label. + */ +export async function extractFirstUserMessagePreview( + filePath: string, + fsProvider: FileSystemProvider = defaultProvider, + maxLines: number = 200 +): Promise<{ text: string; timestamp: string } | null> { + const safeMaxLines = Math.max(1, maxLines); + const fileStream = fsProvider.createReadStream(filePath, { encoding: 'utf8' }); + const rl = readline.createInterface({ + input: fileStream, + crlfDelay: Infinity, + }); + + let commandFallback: { text: string; timestamp: string } | null = null; + let linesRead = 0; + + try { + for await (const line of rl) { + if (linesRead++ >= safeMaxLines) { + break; + } + + const trimmed = line.trim(); + if (!trimmed) continue; + + let entry: ChatHistoryEntry; + try { + entry = JSON.parse(trimmed) as ChatHistoryEntry; + } catch { + continue; + } + + if (entry.type !== 'user') { + continue; + } + + const preview = extractPreviewFromUserEntry(entry); + if (!preview) { + continue; + } + + if (!preview.isCommand) { + return { text: preview.text, timestamp: preview.timestamp }; + } + + if (!commandFallback) { + commandFallback = { text: preview.text, timestamp: preview.timestamp }; + } + } + } catch (error) { + logger.debug(`Error extracting first user preview from ${filePath}:`, error); + throw error; + } finally { + rl.close(); + fileStream.destroy(); + } + + return commandFallback; +} + +function extractPreviewFromUserEntry(entry: UserEntry): MessagePreview | null { + const timestamp = entry.timestamp ?? new Date().toISOString(); + const message = entry.message; + if (!message) { + return null; + } + + const content = message.content; + if (typeof content === 'string') { + if (isCommandOutputContent(content) || content.startsWith('[Request interrupted by user')) { + return null; + } + + if (content.startsWith('')) { + return { + text: extractCommandName(content), + timestamp, + isCommand: true, + }; + } + + const sanitized = sanitizeDisplayContent(content).trim(); + if (!sanitized) { + return null; + } + + return { + text: sanitized.substring(0, 500), + timestamp, + isCommand: false, + }; + } + + if (!Array.isArray(content)) { + return null; + } + + const textContent = content + .filter(isTextContent) + .map((block) => block.text) + .join(' ') + .trim(); + if (!textContent || textContent.startsWith('[Request interrupted by user')) { + return null; + } + + if (textContent.startsWith('')) { + return { + text: extractCommandName(textContent), + timestamp, + isCommand: true, + }; + } + + const sanitized = sanitizeDisplayContent(textContent).trim(); + if (!sanitized) { + return null; + } + + return { + text: sanitized.substring(0, 500), + timestamp, + isCommand: false, + }; +} + +function extractCommandName(content: string): string { + const commandMatch = /\/([^<]+)<\/command-name>/.exec(content); + return commandMatch ? `/${commandMatch[1]}` : '/command'; +} diff --git a/src/preload/index.ts b/src/preload/index.ts index fe2a0794..834d44c3 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -49,6 +49,7 @@ import type { ElectronAPI, HttpServerStatus, NotificationTrigger, + SessionsByIdsOptions, SessionsPaginationOptions, SshConfigHostEntry, SshConnectionConfig, @@ -130,8 +131,11 @@ const electronAPI: ElectronAPI = { ipcRenderer.invoke('get-subagent-detail', projectId, sessionId, subagentId), getSessionGroups: (projectId: string, sessionId: string) => ipcRenderer.invoke('get-session-groups', projectId, sessionId), - getSessionsByIds: (projectId: string, sessionIds: string[]) => - ipcRenderer.invoke('get-sessions-by-ids', projectId, sessionIds), + getSessionsByIds: ( + projectId: string, + sessionIds: string[], + options?: SessionsByIdsOptions + ) => ipcRenderer.invoke('get-sessions-by-ids', projectId, sessionIds, options), // Repository grouping (worktree support) getRepositoryGroups: () => ipcRenderer.invoke('get-repository-groups'), @@ -344,7 +348,7 @@ const electronAPI: ElectronAPI = { return invokeIpcWithResult(SSH_DISCONNECT); }, getState: async (): Promise => { - return ipcRenderer.invoke(SSH_GET_STATE); + return invokeIpcWithResult(SSH_GET_STATE); }, test: async (config: SshConnectionConfig): Promise<{ success: boolean; error?: string }> => { return invokeIpcWithResult<{ success: boolean; error?: string }>(SSH_TEST, config); @@ -409,7 +413,7 @@ const electronAPI: ElectronAPI = { return invokeIpcWithResult(HTTP_SERVER_STOP); }, getStatus: async (): Promise => { - return ipcRenderer.invoke(HTTP_SERVER_GET_STATUS); + return invokeIpcWithResult(HTTP_SERVER_GET_STATUS); }, }, }; diff --git a/src/renderer/api/httpClient.ts b/src/renderer/api/httpClient.ts index 04e420fe..3a6921ae 100644 --- a/src/renderer/api/httpClient.ts +++ b/src/renderer/api/httpClient.ts @@ -26,6 +26,7 @@ import type { SessionAPI, SessionDetail, SessionMetrics, + SessionsByIdsOptions, SessionsPaginationOptions, SshAPI, SshConfigHostEntry, @@ -106,7 +107,7 @@ export class HttpAPIClient implements ElectronAPI { const parsed = JSON.parse(text) as { error?: string }; throw new Error(parsed.error ?? `HTTP ${res.status}`); } - return JSON.parse(text, HttpAPIClient.reviveDates) as T; + return JSON.parse(text, (key, value) => HttpAPIClient.reviveDates(key, value)) as T; } private async get(path: string): Promise { @@ -238,9 +239,14 @@ export class HttpAPIClient implements ElectronAPI { `/api/projects/${encodeURIComponent(projectId)}/sessions/${encodeURIComponent(sessionId)}/groups` ); - getSessionsByIds = (projectId: string, sessionIds: string[]): Promise => + getSessionsByIds = ( + projectId: string, + sessionIds: string[], + options?: SessionsByIdsOptions + ): Promise => this.post(`/api/projects/${encodeURIComponent(projectId)}/sessions-by-ids`, { sessionIds, + metadataLevel: options?.metadataLevel, }); // --------------------------------------------------------------------------- diff --git a/src/renderer/components/sidebar/SessionItem.tsx b/src/renderer/components/sidebar/SessionItem.tsx index 1dbe4cb1..84e34590 100644 --- a/src/renderer/components/sidebar/SessionItem.tsx +++ b/src/renderer/components/sidebar/SessionItem.tsx @@ -144,7 +144,7 @@ export const SessionItem = ({