diff --git a/src/main/http/sessions.ts b/src/main/http/sessions.ts index 4e86ac75..1f83c7d5 100644 --- a/src/main/http/sessions.ts +++ b/src/main/http/sessions.ts @@ -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 => 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', diff --git a/src/main/ipc/sessions.ts b/src/main/ipc/sessions.ts index 4183baaa..2538178a 100644 --- a/src/main/ipc/sessions.ts +++ b/src/main/ipc/sessions.ts @@ -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 { + 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. diff --git a/src/main/services/discovery/ProjectPathResolver.ts b/src/main/services/discovery/ProjectPathResolver.ts index 1b206003..396a8415 100644 --- a/src/main/services/discovery/ProjectPathResolver.ts +++ b/src/main/services/discovery/ProjectPathResolver.ts @@ -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)) { diff --git a/src/main/services/discovery/ProjectScanner.ts b/src/main/services/discovery/ProjectScanner.ts index aaa1f0a5..e5828392 100644 --- a/src/main/services/discovery/ProjectScanner.ts +++ b/src/main/services/discovery/ProjectScanner.ts @@ -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 { + 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 { + 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 { - 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 { + 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. diff --git a/src/main/services/infrastructure/FileSystemProvider.ts b/src/main/services/infrastructure/FileSystemProvider.ts index fc23d2c9..ff10dd0b 100644 --- a/src/main/services/infrastructure/FileSystemProvider.ts +++ b/src/main/services/infrastructure/FileSystemProvider.ts @@ -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; } /** diff --git a/src/main/services/infrastructure/FileWatcher.ts b/src/main/services/infrastructure/FileWatcher.ts index 06dd7167..65da0972 100644 --- a/src/main/services/infrastructure/FileWatcher.ts +++ b/src/main/services/infrastructure/FileWatcher.ts @@ -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 { diff --git a/src/main/services/infrastructure/SshFileSystemProvider.ts b/src/main/services/infrastructure/SshFileSystemProvider.ts index 1b9f5111..8c0dc194 100644 --- a/src/main/services/infrastructure/SshFileSystemProvider.ts +++ b/src/main/services/infrastructure/SshFileSystemProvider.ts @@ -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, }; } } diff --git a/src/main/types/domain.ts b/src/main/types/domain.ts index 05657de4..ac77c03e 100644 --- a/src/main/types/domain.ts +++ b/src/main/types/domain.ts @@ -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; } /** diff --git a/src/preload/index.ts b/src/preload/index.ts index 75aacbf4..fe2a0794 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -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'), diff --git a/src/renderer/api/httpClient.ts b/src/renderer/api/httpClient.ts index 2adf1a09..7fcdea5d 100644 --- a/src/renderer/api/httpClient.ts +++ b/src/renderer/api/httpClient.ts @@ -237,6 +237,11 @@ export class HttpAPIClient implements ElectronAPI { `/api/projects/${encodeURIComponent(projectId)}/sessions/${encodeURIComponent(sessionId)}/groups` ); + getSessionsByIds = (projectId: string, sessionIds: string[]): Promise => + this.post(`/api/projects/${encodeURIComponent(projectId)}/sessions-by-ids`, { + sessionIds, + }); + // --------------------------------------------------------------------------- // Repository grouping // --------------------------------------------------------------------------- diff --git a/src/renderer/store/slices/sessionSlice.ts b/src/renderer/store/slices/sessionSlice.ts index dd5a2309..3336c0ea 100644 --- a/src/renderer/store/slices/sessionSlice.ts +++ b/src/renderer/store/slices/sessionSlice.ts @@ -133,12 +133,17 @@ export const createSessionSlice: StateCreator = 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 = 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 = }, // 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 = 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: [] }); diff --git a/src/shared/types/api.ts b/src/shared/types/api.ts index a1e0336d..c06b92b6 100644 --- a/src/shared/types/api.ts +++ b/src/shared/types/api.ts @@ -294,6 +294,7 @@ export interface ElectronAPI { subagentId: string ) => Promise; getSessionGroups: (projectId: string, sessionId: string) => Promise; + getSessionsByIds: (projectId: string, sessionIds: string[]) => Promise; // Repository grouping (worktree support) getRepositoryGroups: () => Promise;