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); - } } },