From cf61a78beaed60150fb72e47b73d16e00981eea4 Mon Sep 17 00:00:00 2001 From: matt Date: Thu, 12 Feb 2026 22:42:06 +0900 Subject: [PATCH] feat(sessions): enhance session retrieval with metadata options - Updated the session retrieval API to include an optional `SessionsByIdsOptions` parameter, allowing clients to specify the desired metadata depth (light or deep) when fetching sessions by IDs. - Modified the `ProjectScanner` and related services to support this new option, improving flexibility in session data retrieval. - Enhanced the HTTP client and renderer components to accommodate the new options, ensuring consistent behavior across the application. This commit improves session management by providing users with more control over the metadata returned, optimizing performance for various use cases. --- README.md | 2 +- src/main/http/sessions.ts | 11 +- src/main/ipc/sessions.ts | 10 +- src/main/services/discovery/ProjectScanner.ts | 114 ++++++++++++-- src/main/types/domain.ts | 13 ++ src/main/utils/jsonl.ts | 2 +- src/main/utils/metadataExtraction.ts | 143 +++++++++++++++++- src/preload/index.ts | 12 +- src/renderer/api/httpClient.ts | 10 +- .../components/sidebar/SessionItem.tsx | 4 +- src/renderer/store/slices/sessionSlice.ts | 11 +- src/shared/types/api.ts | 7 +- 12 files changed, 310 insertions(+), 29 deletions(-) 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 = ({