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.
This commit is contained in:
matt 2026-02-12 22:42:06 +09:00
parent b42349fdba
commit cf61a78bea
12 changed files with 310 additions and 29 deletions

View file

@ -14,7 +14,7 @@
<p align="center">
<!-- TODO: Replace with actual demo GIF/video -->
<img src="docs/assets/demo.gif" alt="claude-devtools Demo" width="900" />
<img src="resources/demo.gif" alt="claude-devtools Demo" width="900" />
</p>
---

View file

@ -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<typeof s> => s !== null);

View file

@ -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<Session[]> {
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);

View file

@ -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<ReturnType<typeof analyzeSessionFileMetadata>>;
}
>();
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<Session | null> {
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<void> {
await new Promise<void>((resolve) => {
setTimeout(resolve, ms);
});
}
/**
* Resolves the project path for a given project ID.
* For composite IDs, uses the registry's cwd directly.

View file

@ -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;
}

View file

@ -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';
// =============================================================================

View file

@ -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('<command-name>')) {
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('<command-name>')) {
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>\/([^<]+)<\/command-name>/.exec(content);
return commandMatch ? `/${commandMatch[1]}` : '/command';
}

View file

@ -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<SshConnectionStatus>(SSH_DISCONNECT);
},
getState: async (): Promise<SshConnectionStatus> => {
return ipcRenderer.invoke(SSH_GET_STATE);
return invokeIpcWithResult<SshConnectionStatus>(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<HttpServerStatus>(HTTP_SERVER_STOP);
},
getStatus: async (): Promise<HttpServerStatus> => {
return ipcRenderer.invoke(HTTP_SERVER_GET_STATUS);
return invokeIpcWithResult<HttpServerStatus>(HTTP_SERVER_GET_STATUS);
},
},
};

View file

@ -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<T>(path: string): Promise<T> {
@ -238,9 +239,14 @@ export class HttpAPIClient implements ElectronAPI {
`/api/projects/${encodeURIComponent(projectId)}/sessions/${encodeURIComponent(sessionId)}/groups`
);
getSessionsByIds = (projectId: string, sessionIds: string[]): Promise<Session[]> =>
getSessionsByIds = (
projectId: string,
sessionIds: string[],
options?: SessionsByIdsOptions
): Promise<Session[]> =>
this.post<Session[]>(`/api/projects/${encodeURIComponent(projectId)}/sessions-by-ids`, {
sessionIds,
metadataLevel: options?.metadataLevel,
});
// ---------------------------------------------------------------------------

View file

@ -144,7 +144,7 @@ export const SessionItem = ({
<button
onClick={handleClick}
onContextMenu={handleContextMenu}
className={`h-[48px] w-full border-b px-3 py-2.5 text-left transition-all duration-150 ${isActive ? '' : 'bg-transparent hover:opacity-80'} `}
className={`h-[48px] w-full overflow-hidden border-b px-3 py-2 text-left transition-all duration-150 ${isActive ? '' : 'bg-transparent hover:opacity-80'} `}
style={{
borderColor: 'var(--color-border)',
...(isActive ? { backgroundColor: 'var(--color-surface-raised)' } : {}),
@ -164,7 +164,7 @@ export const SessionItem = ({
{/* Second line: message count + time */}
<div
className="mt-1 flex items-center gap-2 text-[10px]"
className="mt-0.5 flex items-center gap-2 text-[10px] leading-tight"
style={{ color: 'var(--color-text-muted)' }}
>
<span className="flex items-center gap-0.5">

View file

@ -137,10 +137,10 @@ export const createSessionSlice: StateCreator<AppState, [], [], SessionSlice> =
prefilterAll: false,
metadataLevel: connectionMode === 'ssh' ? 'light' : 'deep',
});
const existingIds = new Set(get().sessions.map((s) => s.id));
const newSessions = result.sessions.filter((s) => !existingIds.has(s.id));
set((prevState) => {
// Deduplicate: pinned sessions fetched earlier may appear in paginated results
const existingIds = new Set(prevState.sessions.map((s) => s.id));
const newSessions = result.sessions.filter((s) => !existingIds.has(s.id));
// Deduplicate: pinned sessions fetched earlier may appear in paginated results.
return {
sessions: [...prevState.sessions, ...newSessions],
sessionsCursor: result.nextCursor,
@ -287,6 +287,7 @@ export const createSessionSlice: StateCreator<AppState, [], [], SessionSlice> =
}
try {
const { connectionMode } = get();
const config = await api.config.get();
const pins = config.sessions?.pinnedSessions?.[projectId] ?? [];
const pinnedIds = pins.map((p) => p.sessionId);
@ -298,7 +299,9 @@ export const createSessionSlice: StateCreator<AppState, [], [], SessionSlice> =
const missingIds = pinnedIds.filter((id) => !loadedIds.has(id));
if (missingIds.length > 0) {
const missingSessions = await api.getSessionsByIds(projectId, missingIds);
const missingSessions = await api.getSessionsByIds(projectId, missingIds, {
metadataLevel: connectionMode === 'ssh' ? 'light' : 'deep',
});
if (missingSessions.length > 0) {
// Re-read sessions in case they changed during the async call
const latestSessions = get().sessions;

View file

@ -24,6 +24,7 @@ import type {
Session,
SessionDetail,
SessionMetrics,
SessionsByIdsOptions,
SessionsPaginationOptions,
SubagentDetail,
} from '@main/types';
@ -294,7 +295,11 @@ export interface ElectronAPI {
subagentId: string
) => Promise<SubagentDetail | null>;
getSessionGroups: (projectId: string, sessionId: string) => Promise<ConversationGroup[]>;
getSessionsByIds: (projectId: string, sessionIds: string[]) => Promise<Session[]>;
getSessionsByIds: (
projectId: string,
sessionIds: string[],
options?: SessionsByIdsOptions
) => Promise<Session[]>;
// Repository grouping (worktree support)
getRepositoryGroups: () => Promise<RepositoryGroup[]>;