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.
This commit is contained in:
matt 2026-02-13 05:06:18 +09:00
parent 49fd2b592f
commit b1e37470cb
4 changed files with 59 additions and 44 deletions

View file

@ -393,7 +393,7 @@ export class ProjectScanner {
try { try {
const baseDir = extractBaseDir(projectId); const baseDir = extractBaseDir(projectId);
const projectPath = path.join(this.projectsDir, baseDir); 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 shouldFilterNoise = this.fsProvider.type !== 'ssh';
const metadataLevel: SessionMetadataLevel = this.fsProvider.type === 'ssh' ? 'light' : 'deep'; const metadataLevel: SessionMetadataLevel = this.fsProvider.type === 'ssh' ? 'light' : 'deep';
@ -477,7 +477,7 @@ export class ProjectScanner {
const prefilterAll = options?.prefilterAll ?? false; const prefilterAll = options?.prefilterAll ?? false;
const baseDir = extractBaseDir(projectId); const baseDir = extractBaseDir(projectId);
const projectPath = path.join(this.projectsDir, baseDir); 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 shouldFilterNoise = this.fsProvider.type !== 'ssh';
const metadataLevel: SessionMetadataLevel = const metadataLevel: SessionMetadataLevel =
options?.metadataLevel ?? (this.fsProvider.type === 'ssh' ? 'light' : 'deep'); options?.metadataLevel ?? (this.fsProvider.type === 'ssh' ? 'light' : 'deep');
@ -927,7 +927,7 @@ export class ProjectScanner {
try { try {
const baseDir = extractBaseDir(projectId); const baseDir = extractBaseDir(projectId);
const projectPath = path.join(this.projectsDir, baseDir); const projectPath = path.join(this.projectsDir, baseDir);
const sessionFilter = subprojectRegistry.getSessionFilter(projectId); const sessionFilter = await this.getSessionFilterForProject(projectId);
if (!(await this.fsProvider.exists(projectPath))) { if (!(await this.fsProvider.exists(projectPath))) {
return []; 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<Set<string> | 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) // Subagent Detection (delegated to SubagentLocator)
// =========================================================================== // ===========================================================================

View file

@ -73,10 +73,9 @@ export function initializeNotificationListeners(): () => void {
const scheduleSessionRefresh = (projectId: string, sessionId: string): void => { const scheduleSessionRefresh = (projectId: string, sessionId: string): void => {
const key = `${projectId}/${sessionId}`; const key = `${projectId}/${sessionId}`;
// Throttle (not trailing debounce): keep at most one pending refresh per session. const existingTimer = pendingSessionRefreshTimers.get(key);
// Debounce can starve under continuous writes and delay UI updates indefinitely. if (existingTimer) {
if (pendingSessionRefreshTimers.has(key)) { clearTimeout(existingTimer);
return;
} }
const timer = setTimeout(() => { const timer = setTimeout(() => {
pendingSessionRefreshTimers.delete(key); pendingSessionRefreshTimers.delete(key);
@ -87,9 +86,9 @@ export function initializeNotificationListeners(): () => void {
}; };
const scheduleProjectRefresh = (projectId: string): void => { const scheduleProjectRefresh = (projectId: string): void => {
// Throttle (not trailing debounce): keep at most one pending refresh per project. const existingTimer = pendingProjectRefreshTimers.get(projectId);
if (pendingProjectRefreshTimers.has(projectId)) { if (existingTimer) {
return; clearTimeout(existingTimer);
} }
const timer = setTimeout(() => { const timer = setTimeout(() => {
pendingProjectRefreshTimers.delete(projectId); pendingProjectRefreshTimers.delete(projectId);
@ -219,10 +218,18 @@ export function initializeNotificationListeners(): () => void {
const matchesSelectedProject = const matchesSelectedProject =
!!selectedProjectId && !!selectedProjectId &&
(eventProjectBaseId == null || selectedProjectBaseId === eventProjectBaseId); (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. // Refresh sidebar session list when a new top-level session is detected.
// Refreshing on every "change" causes excessive list churn while Claude is writing. // In local mode, some files can be observed as "change" before/without "add".
if (event.type === 'add' && !event.isSubagent) { if ((event.type === 'add' && !event.isSubagent) || shouldRefreshForPotentialNewSession) {
if (matchesSelectedProject && selectedProjectId) { if (matchesSelectedProject && selectedProjectId) {
scheduleProjectRefresh(selectedProjectId); scheduleProjectRefresh(selectedProjectId);
} }

View file

@ -17,6 +17,11 @@ import { resolveFilePath } from '../utils/pathResolution';
const logger = createLogger('Store:sessionDetail'); 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<string, number>();
const sessionRefreshInFlight = new Set<string>(); const sessionRefreshInFlight = new Set<string>();
const sessionRefreshQueued = new Set<string>(); const sessionRefreshQueued = new Set<string>();
let sessionDetailFetchGeneration = 0; let sessionDetailFetchGeneration = 0;
@ -457,6 +462,8 @@ export const createSessionDetailSlice: StateCreator<AppState, [], [], SessionDet
} }
const refreshKey = `${projectId}/${sessionId}`; const refreshKey = `${projectId}/${sessionId}`;
const generation = (sessionRefreshGeneration.get(refreshKey) ?? 0) + 1;
sessionRefreshGeneration.set(refreshKey, generation);
// Coalesce duplicate in-flight refreshes for the same session. // Coalesce duplicate in-flight refreshes for the same session.
if (sessionRefreshInFlight.has(refreshKey)) { if (sessionRefreshInFlight.has(refreshKey)) {
@ -468,6 +475,11 @@ export const createSessionDetailSlice: StateCreator<AppState, [], [], SessionDet
try { try {
const detail = await api.getSessionDetail(projectId, sessionId); const detail = await api.getSessionDetail(projectId, sessionId);
// Drop stale responses if a newer refresh started while this one was in flight.
if (sessionRefreshGeneration.get(refreshKey) !== generation) {
return;
}
if (!detail) { if (!detail) {
return; return;
} }
@ -490,12 +502,8 @@ export const createSessionDetailSlice: StateCreator<AppState, [], [], SessionDet
const latestState = get(); const latestState = get();
const latestActiveTab = latestState.getActiveTab(); const latestActiveTab = latestState.getActiveTab();
const latestTabsViewingSession = getAllTabs(latestState.paneLayout).filter(
(t) => t.type === 'session' && t.sessionId === sessionId
);
const stillViewingSession = const stillViewingSession =
latestState.selectedSessionId === sessionId || latestState.selectedSessionId === sessionId ||
latestTabsViewingSession.length > 0 ||
(latestActiveTab?.type === 'session' && latestActiveTab.sessionId === sessionId); (latestActiveTab?.type === 'session' && latestActiveTab.sessionId === sessionId);
if (!stillViewingSession) { if (!stillViewingSession) {
return; return;
@ -526,7 +534,7 @@ export const createSessionDetailSlice: StateCreator<AppState, [], [], SessionDet
// Also update the session's isOngoing in the sessions array // Also update the session's isOngoing in the sessions array
// This keeps the sidebar in sync with the chat view // This keeps the sidebar in sync with the chat view
const updatedSessions = latestState.sessions.map((s) => const updatedSessions = currentState.sessions.map((s) =>
s.id === sessionId ? { ...s, isOngoing: detail.session?.isOngoing ?? false } : s s.id === sessionId ? { ...s, isOngoing: detail.session?.isOngoing ?? false } : s
); );

View file

@ -11,8 +11,11 @@ import type { StateCreator } from 'zustand';
const logger = createLogger('Store:session'); const logger = createLogger('Store:session');
const projectRefreshInFlight = new Set<string>(); /**
const projectRefreshQueued = new Set<string>(); * Tracks the latest in-place refresh generation per project.
* Used to guarantee last-write-wins under rapid file change events.
*/
const projectRefreshGeneration = new Map<string, number>();
// ============================================================================= // =============================================================================
// Slice Interface // Slice Interface
@ -214,14 +217,8 @@ export const createSessionSlice: StateCreator<AppState, [], [], SessionSlice> =
return; return;
} }
// Coalesce duplicate in-flight refreshes for the same project. const generation = (projectRefreshGeneration.get(projectId) ?? 0) + 1;
// Without this, frequent file-change events can keep invalidating responses projectRefreshGeneration.set(projectId, generation);
// before they commit, making the sidebar look stale until writes stop.
if (projectRefreshInFlight.has(projectId)) {
projectRefreshQueued.add(projectId);
return;
}
projectRefreshInFlight.add(projectId);
try { try {
const { connectionMode } = get(); const { connectionMode } = get();
@ -231,32 +228,22 @@ export const createSessionSlice: StateCreator<AppState, [], [], SessionSlice> =
metadataLevel: connectionMode === 'ssh' ? 'light' : 'deep', metadataLevel: connectionMode === 'ssh' ? 'light' : 'deep',
}); });
const { sessions: prevSessions, sessionsTotalCount: prevTotalCount } = get(); // Drop stale responses from older in-flight refreshes
const refreshedIds = new Set(result.sessions.map((s) => s.id)); if (projectRefreshGeneration.get(projectId) !== generation) {
// Keep previously loaded tail sessions so the sidebar does not collapse return;
// 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);
// Update sessions without loading state // Update sessions without loading state
set({ set({
sessions: mergedSessions, sessions: result.sessions,
sessionsCursor: result.nextCursor, sessionsCursor: result.nextCursor,
sessionsHasMore: result.hasMore, sessionsHasMore: result.hasMore,
sessionsTotalCount: stableTotalCount, sessionsTotalCount: result.totalCount,
// Don't touch sessionsLoading - keep it as-is // Don't touch sessionsLoading - keep it as-is
}); });
} catch (error) { } catch (error) {
logger.error('refreshSessionsInPlace error:', error); logger.error('refreshSessionsInPlace error:', error);
// Don't set error state - this is a background refresh // 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);
}
} }
}, },