diff --git a/README.md b/README.md
index 1c874aed..b9794905 100644
--- a/README.md
+++ b/README.md
@@ -14,7 +14,7 @@
-
+
---
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 = ({