feat(sessions): add support for fetching sessions by IDs

- Implemented a new HTTP endpoint and IPC handler for retrieving session metadata based on an array of session IDs, allowing for the loading of pinned sessions that may not be included in paginated results.
- Enhanced session management by validating session IDs and capping the number of IDs processed to improve performance and reliability.
- Updated the renderer and store to handle the new functionality, ensuring that pinned sessions are preserved and loaded correctly.

This commit enhances the application's session handling capabilities, providing users with better access to their pinned sessions.
This commit is contained in:
matt 2026-02-12 17:53:19 +09:00
parent e057f05ce4
commit 2193b2ed8a
12 changed files with 331 additions and 58 deletions

View file

@ -82,6 +82,51 @@ export function registerSessionRoutes(app: FastifyInstance, services: HttpServic
}
});
// Fetch sessions by IDs (for pinned sessions beyond paginated page)
app.post<{ Params: { projectId: string } }>(
'/api/projects/:projectId/sessions-by-ids',
async (request) => {
try {
const validated = validateProjectId(request.params.projectId);
if (!validated.valid) {
logger.error(`POST sessions-by-ids rejected: ${validated.error ?? 'unknown'}`);
return [];
}
const { sessionIds } = request.body as { sessionIds?: string[] };
if (!Array.isArray(sessionIds)) {
logger.error('POST sessions-by-ids rejected: sessionIds must be an array');
return [];
}
// Cap at 50 IDs
const capped = sessionIds.slice(0, 50);
// Validate each session ID
const validIds: string[] = [];
for (const id of capped) {
const result = validateSessionId(id);
if (result.valid) {
validIds.push(result.value!);
}
}
if (validIds.length === 0) {
return [];
}
const results = await Promise.all(
validIds.map((id) => services.projectScanner.getSession(validated.value!, id))
);
return results.filter((s): s is NonNullable<typeof s> => s !== null);
} catch (error) {
logger.error(`Error in POST sessions-by-ids for ${request.params.projectId}:`, error);
return [];
}
}
);
// Session detail
app.get<{ Params: { projectId: string; sessionId: string } }>(
'/api/projects/:projectId/sessions/:sessionId',

View file

@ -45,6 +45,7 @@ export function initializeSessionHandlers(contextRegistry: ServiceContextRegistr
export function registerSessionHandlers(ipcMain: IpcMain): void {
ipcMain.handle('get-sessions', handleGetSessions);
ipcMain.handle('get-sessions-paginated', handleGetSessionsPaginated);
ipcMain.handle('get-sessions-by-ids', handleGetSessionsByIds);
ipcMain.handle('get-session-detail', handleGetSessionDetail);
ipcMain.handle('get-session-groups', handleGetSessionGroups);
ipcMain.handle('get-session-metrics', handleGetSessionMetrics);
@ -59,6 +60,7 @@ export function registerSessionHandlers(ipcMain: IpcMain): void {
export function removeSessionHandlers(ipcMain: IpcMain): void {
ipcMain.removeHandler('get-sessions');
ipcMain.removeHandler('get-sessions-paginated');
ipcMain.removeHandler('get-sessions-by-ids');
ipcMain.removeHandler('get-session-detail');
ipcMain.removeHandler('get-session-groups');
ipcMain.removeHandler('get-session-metrics');
@ -130,6 +132,58 @@ async function handleGetSessionsPaginated(
}
}
/**
* Handler for 'get-sessions-by-ids' IPC call.
* Fetches lightweight session metadata for specific session IDs.
* Used to load pinned sessions that may not be in the paginated list.
*/
async function handleGetSessionsByIds(
_event: IpcMainInvokeEvent,
projectId: string,
sessionIds: string[]
): Promise<Session[]> {
try {
const validatedProject = validateProjectId(projectId);
if (!validatedProject.valid) {
logger.error(
`get-sessions-by-ids rejected: ${validatedProject.error ?? 'Invalid projectId'}`
);
return [];
}
if (!Array.isArray(sessionIds)) {
logger.error('get-sessions-by-ids rejected: sessionIds must be an array');
return [];
}
// Cap at 50 IDs
const capped = sessionIds.slice(0, 50);
// Validate each session ID
const validIds: string[] = [];
for (const id of capped) {
const validated = validateSessionId(id);
if (validated.valid) {
validIds.push(validated.value!);
}
}
if (validIds.length === 0) {
return [];
}
const { projectScanner } = registry.getActive();
const results = await Promise.all(
validIds.map((id) => projectScanner.getSession(validatedProject.value!, id))
);
return results.filter((s): s is Session => s !== null);
} catch (error) {
logger.error(`Error in get-sessions-by-ids for project ${projectId}:`, error);
return [];
}
}
/**
* Handler for 'get-session-detail' IPC call.
* Gets full session detail including parsed chunks and subagents.

View file

@ -70,7 +70,10 @@ export class ProjectPathResolver {
? opts.sessionPaths
: await this.listSessionPaths(projectId);
for (const sessionPath of sessionPaths) {
// In SSH mode, avoid scanning every remote session file just to resolve display path.
// One successful cwd extraction is sufficient.
const maxPathsToInspect = this.fsProvider.type === 'ssh' ? 1 : sessionPaths.length;
for (const sessionPath of sessionPaths.slice(0, maxPathsToInspect)) {
try {
const cwd = await extractCwd(sessionPath, this.fsProvider);
if (cwd && path.isAbsolute(cwd)) {

View file

@ -22,6 +22,7 @@ import {
type SearchSessionsResult,
type Session,
type SessionCursor,
type SessionMetadataLevel,
type SessionsPaginationOptions,
} from '@main/types';
import { analyzeSessionFileMetadata, extractCwd } from '@main/utils/jsonl';
@ -44,7 +45,7 @@ import { LocalFileSystemProvider } from '../infrastructure/LocalFileSystemProvid
import { SessionContentFilter } from './SessionContentFilter';
import { subprojectRegistry } from './SubprojectRegistry';
import type { FileSystemProvider } from '../infrastructure/FileSystemProvider';
import type { FileSystemProvider, FsDirent } from '../infrastructure/FileSystemProvider';
const logger = createLogger('Discovery:ProjectScanner');
import { ProjectPathResolver } from './ProjectPathResolver';
@ -97,6 +98,7 @@ export class ProjectScanner {
* @returns Promise resolving to projects sorted by most recent activity
*/
async scan(): Promise<Project[]> {
const startedAt = Date.now();
try {
if (!(await this.fsProvider.exists(this.projectsDir))) {
logger.warn(`Projects directory does not exist: ${this.projectsDir}`);
@ -120,6 +122,12 @@ export class ProjectScanner {
const validProjects = projectArrays.flat();
validProjects.sort((a, b) => (b.mostRecentSession ?? 0) - (a.mostRecentSession ?? 0));
if (this.fsProvider.type === 'ssh') {
logger.debug(
`SSH scan completed: ${validProjects.length} projects in ${Date.now() - startedAt}ms`
);
}
return validProjects;
} catch (error) {
logger.error('Error scanning projects directory:', error);
@ -207,7 +215,7 @@ export class ProjectScanner {
this.fsProvider.type === 'ssh' ? 32 : 128,
async (file) => {
const filePath = path.join(projectPath, file.name);
const stats = await this.fsProvider.stat(filePath);
const { mtimeMs, birthtimeMs } = await this.resolveFileTimes(file, filePath);
let cwd: string | null = null;
// Over SSH, avoid reading every file body during project discovery.
@ -222,8 +230,8 @@ export class ProjectScanner {
return {
sessionId: extractSessionId(file.name),
filePath,
mtimeMs: stats.mtimeMs,
birthtimeMs: stats.birthtimeMs,
mtimeMs,
birthtimeMs,
cwd,
} satisfies SessionInfo;
}
@ -393,16 +401,38 @@ export class ProjectScanner {
sessionFiles.map(async (file) => {
const sessionId = extractSessionId(file.name);
const filePath = path.join(projectPath, file.name);
const prefetchedMtimeMs = file.mtimeMs;
if (shouldFilterNoise) {
// Check if session has non-noise messages (delegated to SessionContentFilter)
const hasContent = await this.hasDisplayableContent(filePath);
const hasContent = await this.hasDisplayableContent(filePath, prefetchedMtimeMs);
if (!hasContent) {
return null; // Filter out noise-only sessions
}
}
return this.buildSessionMetadata(projectId, sessionId, filePath, decodedPath);
try {
return await this.buildSessionMetadata(
projectId,
sessionId,
filePath,
decodedPath,
prefetchedMtimeMs
);
} catch (error) {
if (this.fsProvider.type !== 'ssh') {
throw error;
}
logger.debug(`SSH metadata parse failed for ${sessionId}, using light fallback`, error);
return this.buildLightSessionMetadata(
projectId,
sessionId,
filePath,
decodedPath,
prefetchedMtimeMs
);
}
})
);
@ -434,6 +464,7 @@ export class ProjectScanner {
limit: number = 20,
options?: SessionsPaginationOptions
): Promise<PaginatedSessionsResult> {
const startedAt = Date.now();
try {
const includeTotalCount = options?.includeTotalCount ?? false;
const prefilterAll = options?.prefilterAll ?? false;
@ -469,13 +500,13 @@ export class ProjectScanner {
this.fsProvider.type === 'ssh' ? 48 : 200,
async (file) => {
const filePath = path.join(projectPath, file.name);
const stats = await this.fsProvider.stat(filePath);
const { mtimeMs } = await this.resolveFileTimes(file, filePath);
return {
name: file.name,
sessionId: extractSessionId(file.name),
timestamp: stats.mtimeMs,
timestamp: mtimeMs,
filePath,
mtimeMs: stats.mtimeMs,
mtimeMs,
} satisfies SessionFileInfo;
}
);
@ -581,23 +612,37 @@ export class ProjectScanner {
const needed = limit + 1 - sessions.length;
const toBuild = withContent.slice(0, needed);
const metadataResults = await Promise.allSettled(
toBuild.map(({ fileInfo }) =>
this.buildSessionMetadata(
projectId,
fileInfo.sessionId,
fileInfo.filePath,
decodedPath,
fileInfo.mtimeMs
)
)
);
const builtSessions = await Promise.all(
toBuild.map(async ({ fileInfo }) => {
try {
return await this.buildSessionMetadata(
projectId,
fileInfo.sessionId,
fileInfo.filePath,
decodedPath,
fileInfo.mtimeMs
);
} catch (error) {
// In SSH mode, never drop a visible session row due to transient deep-parse failures.
if (this.fsProvider.type !== 'ssh') {
throw error;
}
for (const result of metadataResults) {
if (result.status === 'fulfilled') {
sessions.push(result.value);
}
}
logger.debug(
`SSH page metadata parse failed for ${fileInfo.sessionId}, using light fallback`,
error
);
return this.buildLightSessionMetadata(
projectId,
fileInfo.sessionId,
fileInfo.filePath,
decodedPath,
fileInfo.mtimeMs
);
}
})
);
sessions.push(...builtSessions);
batchStart = batchEnd;
}
@ -626,12 +671,20 @@ export class ProjectScanner {
}
}
return {
const result: PaginatedSessionsResult = {
sessions: pageSessions,
nextCursor,
hasMore: nextCursor !== null,
totalCount,
};
if (this.fsProvider.type === 'ssh') {
logger.debug(
`SSH listSessionsPaginated(${projectId}) returned ${result.sessions.length} sessions in ${Date.now() - startedAt}ms (hasMore=${result.hasMore})`
);
}
return result;
} catch (error) {
logger.error(`Error listing paginated sessions for project ${projectId}:`, error);
return { sessions: [], nextCursor: null, hasMore: false, totalCount: 0 };
@ -648,8 +701,11 @@ export class ProjectScanner {
projectPath: string,
prefetchedMtimeMs?: number
): Promise<Session> {
const stats = await this.fsProvider.stat(filePath);
const effectiveMtime = prefetchedMtimeMs ?? stats.mtimeMs;
const usePrefetchedTimes =
this.fsProvider.type === 'ssh' && typeof prefetchedMtimeMs === 'number';
const stats = usePrefetchedTimes ? null : await this.fsProvider.stat(filePath);
const effectiveMtime = prefetchedMtimeMs ?? stats?.mtimeMs ?? Date.now();
const birthtimeMs = stats?.birthtimeMs ?? effectiveMtime;
const cachedMetadata = this.sessionMetadataCache.get(filePath);
const metadata =
cachedMetadata?.mtimeMs === effectiveMtime
@ -664,19 +720,49 @@ export class ProjectScanner {
this.subagentLocator.hasSubagents(projectId, sessionId),
this.loadTodoData(sessionId),
]);
const metadataLevel: SessionMetadataLevel = 'deep';
return {
id: sessionId,
projectId,
projectPath,
todoData,
createdAt: Math.floor(stats.birthtimeMs),
createdAt: Math.floor(birthtimeMs),
firstMessage: metadata.firstUserMessage?.text,
messageTimestamp: metadata.firstUserMessage?.timestamp,
hasSubagents,
messageCount: metadata.messageCount,
isOngoing: metadata.isOngoing,
gitBranch: metadata.gitBranch ?? undefined,
metadataLevel,
};
}
/**
* Build a lightweight session record using filesystem metadata only.
* Used as SSH fallback when deep parsing fails transiently.
*/
private async buildLightSessionMetadata(
projectId: string,
sessionId: string,
filePath: string,
projectPath: string,
prefetchedMtimeMs?: number
): Promise<Session> {
const times =
typeof prefetchedMtimeMs === 'number'
? { mtimeMs: prefetchedMtimeMs, birthtimeMs: prefetchedMtimeMs }
: await this.resolveFileTimes(undefined, filePath);
const metadataLevel: SessionMetadataLevel = 'light';
return {
id: sessionId,
projectId,
projectPath,
createdAt: Math.floor(times.birthtimeMs),
hasSubagents: false,
messageCount: 0,
metadataLevel,
};
}
@ -844,6 +930,27 @@ export class ProjectScanner {
return this.sessionSearcher.searchSessions(projectId, query, maxResults);
}
/**
* Resolve best-available file timestamps from directory entry metadata or stat fallback.
*/
private async resolveFileTimes(
entry: FsDirent | undefined,
filePath: string
): Promise<{ mtimeMs: number; birthtimeMs: number }> {
if (entry && typeof entry.mtimeMs === 'number') {
return {
mtimeMs: entry.mtimeMs,
birthtimeMs: entry.birthtimeMs ?? entry.mtimeMs,
};
}
const stats = await this.fsProvider.stat(filePath);
return {
mtimeMs: stats.mtimeMs,
birthtimeMs: stats.birthtimeMs,
};
}
/**
* Runs async mapping in bounded batches and returns only fulfilled results.
* This prevents overwhelming SFTP servers with unbounded parallel requests.

View file

@ -33,6 +33,12 @@ export interface FsDirent {
name: string;
isFile(): boolean;
isDirectory(): boolean;
/** Optional metadata provided by some providers (e.g., SFTP readdir attrs) */
size?: number;
/** Optional mtime in milliseconds since epoch */
mtimeMs?: number;
/** Optional birthtime/ctime fallback in milliseconds since epoch */
birthtimeMs?: number;
}
/**

View file

@ -412,15 +412,16 @@ export class FileWatcher extends EventEmitter {
const fullPath = path.join(projectPath, entry.name);
try {
const stats = await this.fsProvider.stat(fullPath);
const observedSize =
typeof entry.size === 'number' ? entry.size : (await this.fsProvider.stat(fullPath)).size;
const lastSize = this.polledFileSizes.get(fullPath);
if (lastSize === undefined) {
// First time seeing this file
this.polledFileSizes.set(fullPath, stats.size);
} else if (stats.size !== lastSize) {
this.polledFileSizes.set(fullPath, observedSize);
} else if (observedSize !== lastSize) {
// File changed
this.polledFileSizes.set(fullPath, stats.size);
this.polledFileSizes.set(fullPath, observedSize);
this.handleProjectsChange('change', path.join(dir.name, entry.name));
}
} catch {

View file

@ -18,6 +18,8 @@ import type { SFTPWrapper } from 'ssh2';
const logger = createLogger('Infrastructure:SshFileSystemProvider');
export type SftpErrorKind = 'not_found' | 'transient' | 'permanent';
export class SshFileSystemProvider implements FileSystemProvider {
private static readonly MAX_RETRIES = 3;
private static readonly RETRY_BASE_DELAY_MS = 75;
@ -34,12 +36,13 @@ export class SshFileSystemProvider implements FileSystemProvider {
await this.stat(filePath);
return true;
} catch (error) {
if (this.isNotFoundError(error)) {
const errorKind = this.classifySftpError(error);
if (errorKind === 'not_found') {
return false;
}
// For transient SFTP failures (e.g. code=4), avoid false negatives.
if (this.isRetryableError(error)) {
if (errorKind === 'transient') {
const code = this.getErrorCode(error);
logger.debug(
`exists(${filePath}) got retryable SFTP error (${String(code)}); treating path as potentially present`
@ -66,10 +69,7 @@ export class SshFileSystemProvider implements FileSystemProvider {
});
} catch (error) {
lastError = error;
if (
this.isRetryableError(error) &&
attempt < SshFileSystemProvider.MAX_RETRIES
) {
if (this.classifySftpError(error) === 'transient' && attempt < SshFileSystemProvider.MAX_RETRIES) {
await this.sleep(SshFileSystemProvider.RETRY_BASE_DELAY_MS * attempt);
continue;
}
@ -108,10 +108,7 @@ export class SshFileSystemProvider implements FileSystemProvider {
});
} catch (error) {
lastError = error;
if (
this.isRetryableError(error) &&
attempt < SshFileSystemProvider.MAX_RETRIES
) {
if (this.classifySftpError(error) === 'transient' && attempt < SshFileSystemProvider.MAX_RETRIES) {
await this.sleep(SshFileSystemProvider.RETRY_BASE_DELAY_MS * attempt);
continue;
}
@ -139,17 +136,16 @@ export class SshFileSystemProvider implements FileSystemProvider {
const entries: FsDirent[] = [];
for (const item of list) {
const mode = item.attrs.mode;
entries.push(this.buildDirent(item.filename, mode, S_IFMT, S_IFREG, S_IFDIR));
entries.push(
this.buildDirent(item.filename, mode, S_IFMT, S_IFREG, S_IFDIR, item.attrs.size, item.attrs.mtime)
);
}
resolve(entries);
});
});
} catch (error) {
lastError = error;
if (
this.isRetryableError(error) &&
attempt < SshFileSystemProvider.MAX_RETRIES
) {
if (this.classifySftpError(error) === 'transient' && attempt < SshFileSystemProvider.MAX_RETRIES) {
await this.sleep(SshFileSystemProvider.RETRY_BASE_DELAY_MS * attempt);
continue;
}
@ -227,17 +223,33 @@ export class SshFileSystemProvider implements FileSystemProvider {
);
}
private classifySftpError(error: unknown): SftpErrorKind {
if (this.isNotFoundError(error)) {
return 'not_found';
}
if (this.isRetryableError(error)) {
return 'transient';
}
return 'permanent';
}
private buildDirent(
filename: string,
mode: number,
sifmt: number,
sifreg: number,
sifdir: number
sifdir: number,
size?: number,
mtimeSeconds?: number
): FsDirent {
const mtimeMs = typeof mtimeSeconds === 'number' ? mtimeSeconds * 1000 : undefined;
return {
name: filename,
isFile: () => (mode & sifmt) === sifreg,
isDirectory: () => (mode & sifmt) === sifdir,
size,
mtimeMs,
birthtimeMs: mtimeMs,
};
}
}

View file

@ -62,6 +62,8 @@ export interface Project {
/**
* Session metadata and summary.
*/
export type SessionMetadataLevel = 'light' | 'deep';
export interface Session {
/** Session UUID (JSONL filename without extension) */
id: string;
@ -85,6 +87,8 @@ export interface Session {
isOngoing?: boolean;
/** Git branch name if available */
gitBranch?: string;
/** Metadata completeness level */
metadataLevel?: SessionMetadataLevel;
}
/**

View file

@ -130,6 +130,8 @@ 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),
// Repository grouping (worktree support)
getRepositoryGroups: () => ipcRenderer.invoke('get-repository-groups'),

View file

@ -237,6 +237,11 @@ export class HttpAPIClient implements ElectronAPI {
`/api/projects/${encodeURIComponent(projectId)}/sessions/${encodeURIComponent(sessionId)}/groups`
);
getSessionsByIds = (projectId: string, sessionIds: string[]): Promise<Session[]> =>
this.post<Session[]>(`/api/projects/${encodeURIComponent(projectId)}/sessions-by-ids`, {
sessionIds,
});
// ---------------------------------------------------------------------------
// Repository grouping
// ---------------------------------------------------------------------------

View file

@ -133,12 +133,17 @@ export const createSessionSlice: StateCreator<AppState, [], [], SessionSlice> =
includeTotalCount: false,
prefilterAll: false,
});
set((prevState) => ({
sessions: [...prevState.sessions, ...result.sessions],
sessionsCursor: result.nextCursor,
sessionsHasMore: result.hasMore,
sessionsLoadingMore: false,
}));
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));
return {
sessions: [...prevState.sessions, ...newSessions],
sessionsCursor: result.nextCursor,
sessionsHasMore: result.hasMore,
sessionsLoadingMore: false,
};
});
} catch (error) {
set({
sessionsError: error instanceof Error ? error.message : 'Failed to fetch more sessions',
@ -214,9 +219,17 @@ export const createSessionSlice: StateCreator<AppState, [], [], SessionSlice> =
return;
}
// Preserve pinned sessions that are beyond page 1
const { pinnedSessionIds, sessions: prevSessions } = get();
const newPageIds = new Set(result.sessions.map((s) => s.id));
const pinnedSet = new Set(pinnedSessionIds);
const pinnedToRetain = prevSessions.filter(
(s) => pinnedSet.has(s.id) && !newPageIds.has(s.id)
);
// Update sessions without loading state
set({
sessions: result.sessions,
sessions: [...result.sessions, ...pinnedToRetain],
sessionsCursor: result.nextCursor,
sessionsHasMore: result.hasMore,
sessionsTotalCount: result.totalCount,
@ -258,6 +271,7 @@ export const createSessionSlice: StateCreator<AppState, [], [], SessionSlice> =
},
// Load pinned sessions from config for current project
// Fetches missing pinned session data that may be beyond the paginated page
loadPinnedSessions: async () => {
const state = get();
const projectId = state.selectedProjectId;
@ -269,7 +283,26 @@ export const createSessionSlice: StateCreator<AppState, [], [], SessionSlice> =
try {
const config = await api.config.get();
const pins = config.sessions?.pinnedSessions?.[projectId] ?? [];
set({ pinnedSessionIds: pins.map((p) => p.sessionId) });
const pinnedIds = pins.map((p) => p.sessionId);
set({ pinnedSessionIds: pinnedIds });
// Determine which pinned sessions are missing from the loaded sessions array
const currentSessions = get().sessions;
const loadedIds = new Set(currentSessions.map((s) => s.id));
const missingIds = pinnedIds.filter((id) => !loadedIds.has(id));
if (missingIds.length > 0) {
const missingSessions = await api.getSessionsByIds(projectId, missingIds);
if (missingSessions.length > 0) {
// Re-read sessions in case they changed during the async call
const latestSessions = get().sessions;
const latestIds = new Set(latestSessions.map((s) => s.id));
const toAppend = missingSessions.filter((s) => !latestIds.has(s.id));
if (toAppend.length > 0) {
set({ sessions: [...latestSessions, ...toAppend] });
}
}
}
} catch (error) {
logger.error('loadPinnedSessions error:', error);
set({ pinnedSessionIds: [] });

View file

@ -294,6 +294,7 @@ export interface ElectronAPI {
subagentId: string
) => Promise<SubagentDetail | null>;
getSessionGroups: (projectId: string, sessionId: string) => Promise<ConversationGroup[]>;
getSessionsByIds: (projectId: string, sessionIds: string[]) => Promise<Session[]>;
// Repository grouping (worktree support)
getRepositoryGroups: () => Promise<RepositoryGroup[]>;