From b1e37470cbb94bba7c85a2752b4291506cd39de4 Mon Sep 17 00:00:00 2001 From: matt Date: Fri, 13 Feb 2026 05:06:18 +0900 Subject: [PATCH] refactor(ProjectScanner): replace session filter retrieval with async method - Updated the ProjectScanner class to use an asynchronous method for retrieving session filters, ensuring up-to-date session data. - Introduced a new private method, getSessionFilterForProject, which refreshes composite IDs from disk in local mode to prevent stale entries. - Enhanced notification listeners to clear existing timers for session and project refreshes, improving performance during rapid file changes. - Implemented a mechanism to track refresh generations for sessions and projects, preventing stale overwrites during concurrent updates. This commit improves the accuracy and efficiency of session and project data handling in the application. --- src/main/services/discovery/ProjectScanner.ts | 19 +++++++-- src/renderer/store/index.ts | 27 ++++++++----- .../store/slices/sessionDetailSlice.ts | 18 ++++++--- src/renderer/store/slices/sessionSlice.ts | 39 +++++++------------ 4 files changed, 59 insertions(+), 44 deletions(-) diff --git a/src/main/services/discovery/ProjectScanner.ts b/src/main/services/discovery/ProjectScanner.ts index 29678468..ff9702b9 100644 --- a/src/main/services/discovery/ProjectScanner.ts +++ b/src/main/services/discovery/ProjectScanner.ts @@ -393,7 +393,7 @@ export class ProjectScanner { try { const baseDir = extractBaseDir(projectId); const projectPath = path.join(this.projectsDir, baseDir); - const sessionFilter = subprojectRegistry.getSessionFilter(projectId); + const sessionFilter = await this.getSessionFilterForProject(projectId); const shouldFilterNoise = this.fsProvider.type !== 'ssh'; const metadataLevel: SessionMetadataLevel = this.fsProvider.type === 'ssh' ? 'light' : 'deep'; @@ -477,7 +477,7 @@ export class ProjectScanner { const prefilterAll = options?.prefilterAll ?? false; const baseDir = extractBaseDir(projectId); const projectPath = path.join(this.projectsDir, baseDir); - const sessionFilter = subprojectRegistry.getSessionFilter(projectId); + const sessionFilter = await this.getSessionFilterForProject(projectId); const shouldFilterNoise = this.fsProvider.type !== 'ssh'; const metadataLevel: SessionMetadataLevel = options?.metadataLevel ?? (this.fsProvider.type === 'ssh' ? 'light' : 'deep'); @@ -927,7 +927,7 @@ export class ProjectScanner { try { const baseDir = extractBaseDir(projectId); const projectPath = path.join(this.projectsDir, baseDir); - const sessionFilter = subprojectRegistry.getSessionFilter(projectId); + const sessionFilter = await this.getSessionFilterForProject(projectId); if (!(await this.fsProvider.exists(projectPath))) { return []; @@ -948,6 +948,19 @@ export class ProjectScanner { } } + /** + * Returns the session filter set for a project. + * In local mode, composite IDs are refreshed from disk first so newly created + * sessions are not hidden by stale registry entries. + */ + private async getSessionFilterForProject(projectId: string): Promise | null> { + if (this.fsProvider.type === 'local' && subprojectRegistry.isComposite(projectId)) { + const baseDir = extractBaseDir(projectId); + await this.scanProject(baseDir); + } + return subprojectRegistry.getSessionFilter(projectId); + } + // =========================================================================== // Subagent Detection (delegated to SubagentLocator) // =========================================================================== diff --git a/src/renderer/store/index.ts b/src/renderer/store/index.ts index 4c45e1b7..ede5774c 100644 --- a/src/renderer/store/index.ts +++ b/src/renderer/store/index.ts @@ -73,10 +73,9 @@ export function initializeNotificationListeners(): () => void { const scheduleSessionRefresh = (projectId: string, sessionId: string): void => { const key = `${projectId}/${sessionId}`; - // Throttle (not trailing debounce): keep at most one pending refresh per session. - // Debounce can starve under continuous writes and delay UI updates indefinitely. - if (pendingSessionRefreshTimers.has(key)) { - return; + const existingTimer = pendingSessionRefreshTimers.get(key); + if (existingTimer) { + clearTimeout(existingTimer); } const timer = setTimeout(() => { pendingSessionRefreshTimers.delete(key); @@ -87,9 +86,9 @@ export function initializeNotificationListeners(): () => void { }; const scheduleProjectRefresh = (projectId: string): void => { - // Throttle (not trailing debounce): keep at most one pending refresh per project. - if (pendingProjectRefreshTimers.has(projectId)) { - return; + const existingTimer = pendingProjectRefreshTimers.get(projectId); + if (existingTimer) { + clearTimeout(existingTimer); } const timer = setTimeout(() => { pendingProjectRefreshTimers.delete(projectId); @@ -219,10 +218,18 @@ export function initializeNotificationListeners(): () => void { const matchesSelectedProject = !!selectedProjectId && (eventProjectBaseId == null || selectedProjectBaseId === eventProjectBaseId); + const isUnknownSessionInSidebar = + event.sessionId != null && !state.sessions.some((session) => session.id === event.sessionId); + const shouldRefreshForPotentialNewSession = + event.type === 'change' && + !event.isSubagent && + matchesSelectedProject && + state.connectionMode === 'local' && + isUnknownSessionInSidebar; - // Refresh sidebar session list only when a new top-level session file is added. - // Refreshing on every "change" causes excessive list churn while Claude is writing. - if (event.type === 'add' && !event.isSubagent) { + // Refresh sidebar session list when a new top-level session is detected. + // In local mode, some files can be observed as "change" before/without "add". + if ((event.type === 'add' && !event.isSubagent) || shouldRefreshForPotentialNewSession) { if (matchesSelectedProject && selectedProjectId) { scheduleProjectRefresh(selectedProjectId); } diff --git a/src/renderer/store/slices/sessionDetailSlice.ts b/src/renderer/store/slices/sessionDetailSlice.ts index c4b9715b..b5e85eb7 100644 --- a/src/renderer/store/slices/sessionDetailSlice.ts +++ b/src/renderer/store/slices/sessionDetailSlice.ts @@ -17,6 +17,11 @@ import { resolveFilePath } from '../utils/pathResolution'; const logger = createLogger('Store:sessionDetail'); +/** + * Tracks latest refresh generation per session to avoid stale overwrites when + * many file-change events trigger concurrent in-place refreshes. + */ +const sessionRefreshGeneration = new Map(); const sessionRefreshInFlight = new Set(); const sessionRefreshQueued = new Set(); let sessionDetailFetchGeneration = 0; @@ -457,6 +462,8 @@ export const createSessionDetailSlice: StateCreator t.type === 'session' && t.sessionId === sessionId - ); const stillViewingSession = latestState.selectedSessionId === sessionId || - latestTabsViewingSession.length > 0 || (latestActiveTab?.type === 'session' && latestActiveTab.sessionId === sessionId); if (!stillViewingSession) { return; @@ -526,7 +534,7 @@ export const createSessionDetailSlice: StateCreator + const updatedSessions = currentState.sessions.map((s) => s.id === sessionId ? { ...s, isOngoing: detail.session?.isOngoing ?? false } : s ); diff --git a/src/renderer/store/slices/sessionSlice.ts b/src/renderer/store/slices/sessionSlice.ts index 4c9fd169..489e8452 100644 --- a/src/renderer/store/slices/sessionSlice.ts +++ b/src/renderer/store/slices/sessionSlice.ts @@ -11,8 +11,11 @@ import type { StateCreator } from 'zustand'; const logger = createLogger('Store:session'); -const projectRefreshInFlight = new Set(); -const projectRefreshQueued = new Set(); +/** + * Tracks the latest in-place refresh generation per project. + * Used to guarantee last-write-wins under rapid file change events. + */ +const projectRefreshGeneration = new Map(); // ============================================================================= // Slice Interface @@ -214,14 +217,8 @@ export const createSessionSlice: StateCreator = return; } - // Coalesce duplicate in-flight refreshes for the same project. - // Without this, frequent file-change events can keep invalidating responses - // before they commit, making the sidebar look stale until writes stop. - if (projectRefreshInFlight.has(projectId)) { - projectRefreshQueued.add(projectId); - return; - } - projectRefreshInFlight.add(projectId); + const generation = (projectRefreshGeneration.get(projectId) ?? 0) + 1; + projectRefreshGeneration.set(projectId, generation); try { const { connectionMode } = get(); @@ -231,32 +228,22 @@ export const createSessionSlice: StateCreator = metadataLevel: connectionMode === 'ssh' ? 'light' : 'deep', }); - const { sessions: prevSessions, sessionsTotalCount: prevTotalCount } = get(); - const refreshedIds = new Set(result.sessions.map((s) => s.id)); - // Keep previously loaded tail sessions so the sidebar does not collapse - // from N loaded rows back to page-1 rows on every in-place refresh. - const retainedTail = prevSessions.filter((s) => !refreshedIds.has(s.id)); - const mergedSessions = [...result.sessions, ...retainedTail]; - const inferredTotalLowerBound = mergedSessions.length + (result.hasMore ? 1 : 0); - const stableTotalCount = Math.max(prevTotalCount, result.totalCount, inferredTotalLowerBound); + // Drop stale responses from older in-flight refreshes + if (projectRefreshGeneration.get(projectId) !== generation) { + return; + } // Update sessions without loading state set({ - sessions: mergedSessions, + sessions: result.sessions, sessionsCursor: result.nextCursor, sessionsHasMore: result.hasMore, - sessionsTotalCount: stableTotalCount, + sessionsTotalCount: result.totalCount, // Don't touch sessionsLoading - keep it as-is }); } catch (error) { logger.error('refreshSessionsInPlace error:', error); // Don't set error state - this is a background refresh - } finally { - projectRefreshInFlight.delete(projectId); - if (projectRefreshQueued.has(projectId)) { - projectRefreshQueued.delete(projectId); - void get().refreshSessionsInPlace(projectId); - } } },