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:
parent
49fd2b592f
commit
b1e37470cb
4 changed files with 59 additions and 44 deletions
|
|
@ -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<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)
|
||||
// ===========================================================================
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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<string, number>();
|
||||
const sessionRefreshInFlight = new Set<string>();
|
||||
const sessionRefreshQueued = new Set<string>();
|
||||
let sessionDetailFetchGeneration = 0;
|
||||
|
|
@ -457,6 +462,8 @@ export const createSessionDetailSlice: StateCreator<AppState, [], [], SessionDet
|
|||
}
|
||||
|
||||
const refreshKey = `${projectId}/${sessionId}`;
|
||||
const generation = (sessionRefreshGeneration.get(refreshKey) ?? 0) + 1;
|
||||
sessionRefreshGeneration.set(refreshKey, generation);
|
||||
|
||||
// Coalesce duplicate in-flight refreshes for the same session.
|
||||
if (sessionRefreshInFlight.has(refreshKey)) {
|
||||
|
|
@ -468,6 +475,11 @@ export const createSessionDetailSlice: StateCreator<AppState, [], [], SessionDet
|
|||
try {
|
||||
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) {
|
||||
return;
|
||||
}
|
||||
|
|
@ -490,12 +502,8 @@ export const createSessionDetailSlice: StateCreator<AppState, [], [], SessionDet
|
|||
|
||||
const latestState = get();
|
||||
const latestActiveTab = latestState.getActiveTab();
|
||||
const latestTabsViewingSession = getAllTabs(latestState.paneLayout).filter(
|
||||
(t) => 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<AppState, [], [], SessionDet
|
|||
|
||||
// Also update the session's isOngoing in the sessions array
|
||||
// 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
|
||||
);
|
||||
|
||||
|
|
|
|||
|
|
@ -11,8 +11,11 @@ import type { StateCreator } from 'zustand';
|
|||
|
||||
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
|
||||
|
|
@ -214,14 +217,8 @@ export const createSessionSlice: StateCreator<AppState, [], [], SessionSlice> =
|
|||
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<AppState, [], [], SessionSlice> =
|
|||
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);
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
|
|
|
|||
Loading…
Reference in a new issue