refactor(ProjectScanner): enhance file detail retrieval and session metadata handling
- Updated the ProjectScanner class to utilize a new asynchronous method for resolving file details, improving accuracy in file metadata retrieval. - Introduced birthtimeMs to session file information, ensuring comprehensive metadata is available for session management. - Adjusted session creation logic to account for the new birthtimeMs, enhancing the integrity of session timestamps. - Improved the handling of auto-scroll behavior in ChatHistory and useAutoScrollBottom hook, allowing for smoother user experience during content updates. This commit enhances the ProjectScanner's efficiency and improves user experience in chat history management.
This commit is contained in:
parent
b1e37470cb
commit
e0e399ec31
5 changed files with 90 additions and 51 deletions
|
|
@ -416,8 +416,10 @@ export class ProjectScanner {
|
|||
sessionFiles.map(async (file) => {
|
||||
const sessionId = extractSessionId(file.name);
|
||||
const filePath = path.join(projectPath, file.name);
|
||||
const prefetchedMtimeMs = file.mtimeMs;
|
||||
const prefetchedSize = file.size;
|
||||
const fileDetails = await this.resolveFileDetails(file, filePath);
|
||||
const prefetchedMtimeMs = fileDetails.mtimeMs;
|
||||
const prefetchedSize = fileDetails.size;
|
||||
const prefetchedBirthtimeMs = fileDetails.birthtimeMs;
|
||||
|
||||
if (shouldFilterNoise) {
|
||||
// Check if session has non-noise messages (delegated to SessionContentFilter)
|
||||
|
|
@ -438,7 +440,8 @@ export class ProjectScanner {
|
|||
filePath,
|
||||
decodedPath,
|
||||
prefetchedMtimeMs,
|
||||
prefetchedSize
|
||||
prefetchedSize,
|
||||
prefetchedBirthtimeMs
|
||||
);
|
||||
})
|
||||
);
|
||||
|
|
@ -503,6 +506,7 @@ export class ProjectScanner {
|
|||
filePath: string;
|
||||
mtimeMs: number;
|
||||
size: number;
|
||||
birthtimeMs: number;
|
||||
}
|
||||
|
||||
const fileInfos = await this.collectFulfilledInBatches(
|
||||
|
|
@ -518,6 +522,7 @@ export class ProjectScanner {
|
|||
filePath,
|
||||
mtimeMs: fileDetails.mtimeMs,
|
||||
size: fileDetails.size,
|
||||
birthtimeMs: fileDetails.birthtimeMs,
|
||||
} satisfies SessionFileInfo;
|
||||
}
|
||||
);
|
||||
|
|
@ -642,7 +647,8 @@ export class ProjectScanner {
|
|||
fileInfo.filePath,
|
||||
decodedPath,
|
||||
fileInfo.mtimeMs,
|
||||
fileInfo.size
|
||||
fileInfo.size,
|
||||
fileInfo.birthtimeMs
|
||||
)
|
||||
);
|
||||
sessions.push(...builtSessions);
|
||||
|
|
@ -703,14 +709,17 @@ export class ProjectScanner {
|
|||
filePath: string,
|
||||
projectPath: string,
|
||||
prefetchedMtimeMs?: number,
|
||||
prefetchedSize?: number
|
||||
prefetchedSize?: number,
|
||||
prefetchedBirthtimeMs?: number
|
||||
): Promise<Session> {
|
||||
const usePrefetchedStats =
|
||||
const hasPrefetchedCoreStats =
|
||||
typeof prefetchedMtimeMs === 'number' && typeof prefetchedSize === 'number';
|
||||
const stats = usePrefetchedStats ? null : await this.fsProvider.stat(filePath);
|
||||
const needsBirthtimeStat = typeof prefetchedBirthtimeMs !== 'number';
|
||||
const stats =
|
||||
hasPrefetchedCoreStats && !needsBirthtimeStat ? null : await this.fsProvider.stat(filePath);
|
||||
const effectiveMtime = prefetchedMtimeMs ?? stats?.mtimeMs ?? Date.now();
|
||||
const effectiveSize = prefetchedSize ?? stats?.size ?? -1;
|
||||
const birthtimeMs = stats?.birthtimeMs ?? effectiveMtime;
|
||||
const birthtimeMs = prefetchedBirthtimeMs ?? stats?.birthtimeMs ?? effectiveMtime;
|
||||
const cachedMetadata = this.sessionMetadataCache.get(filePath);
|
||||
const metadata =
|
||||
cachedMetadata?.mtimeMs === effectiveMtime && cachedMetadata.size === effectiveSize
|
||||
|
|
@ -730,13 +739,18 @@ export class ProjectScanner {
|
|||
this.loadTodoData(sessionId),
|
||||
]);
|
||||
const metadataLevel: SessionMetadataLevel = 'deep';
|
||||
const firstMessageTimestampMs = this.parseTimestampMs(metadata.firstUserMessage?.timestamp);
|
||||
const createdAt =
|
||||
firstMessageTimestampMs !== null && Number.isFinite(firstMessageTimestampMs)
|
||||
? firstMessageTimestampMs
|
||||
: birthtimeMs;
|
||||
|
||||
return {
|
||||
id: sessionId,
|
||||
projectId,
|
||||
projectPath,
|
||||
todoData,
|
||||
createdAt: Math.floor(birthtimeMs),
|
||||
createdAt: Math.floor(createdAt),
|
||||
firstMessage: metadata.firstUserMessage?.text,
|
||||
messageTimestamp: metadata.firstUserMessage?.timestamp,
|
||||
hasSubagents,
|
||||
|
|
@ -757,14 +771,17 @@ export class ProjectScanner {
|
|||
filePath: string,
|
||||
projectPath: string,
|
||||
prefetchedMtimeMs?: number,
|
||||
prefetchedSize?: number
|
||||
prefetchedSize?: number,
|
||||
prefetchedBirthtimeMs?: number
|
||||
): Promise<Session> {
|
||||
const usePrefetchedStats =
|
||||
const hasPrefetchedCoreStats =
|
||||
typeof prefetchedMtimeMs === 'number' && typeof prefetchedSize === 'number';
|
||||
const stats = usePrefetchedStats ? null : await this.fsProvider.stat(filePath);
|
||||
const needsBirthtimeStat = typeof prefetchedBirthtimeMs !== 'number';
|
||||
const stats =
|
||||
hasPrefetchedCoreStats && !needsBirthtimeStat ? null : await this.fsProvider.stat(filePath);
|
||||
const effectiveMtime = prefetchedMtimeMs ?? stats?.mtimeMs ?? Date.now();
|
||||
const effectiveSize = prefetchedSize ?? stats?.size ?? -1;
|
||||
const birthtimeMs = stats?.birthtimeMs ?? effectiveMtime;
|
||||
const birthtimeMs = prefetchedBirthtimeMs ?? stats?.birthtimeMs ?? effectiveMtime;
|
||||
const cachedPreview = this.sessionPreviewCache.get(filePath);
|
||||
const preview =
|
||||
cachedPreview?.mtimeMs === effectiveMtime && cachedPreview.size === effectiveSize
|
||||
|
|
@ -778,12 +795,17 @@ export class ProjectScanner {
|
|||
});
|
||||
}
|
||||
const metadataLevel: SessionMetadataLevel = 'light';
|
||||
const previewTimestampMs = this.parseTimestampMs(preview?.timestamp);
|
||||
const createdAt =
|
||||
previewTimestampMs !== null && Number.isFinite(previewTimestampMs)
|
||||
? previewTimestampMs
|
||||
: birthtimeMs;
|
||||
|
||||
return {
|
||||
id: sessionId,
|
||||
projectId,
|
||||
projectPath,
|
||||
createdAt: Math.floor(birthtimeMs),
|
||||
createdAt: Math.floor(createdAt),
|
||||
firstMessage: preview?.text,
|
||||
messageTimestamp: preview?.timestamp,
|
||||
hasSubagents: false,
|
||||
|
|
@ -803,7 +825,8 @@ export class ProjectScanner {
|
|||
filePath: string,
|
||||
projectPath: string,
|
||||
prefetchedMtimeMs?: number,
|
||||
prefetchedSize?: number
|
||||
prefetchedSize?: number,
|
||||
prefetchedBirthtimeMs?: number
|
||||
): Promise<Session> {
|
||||
if (metadataLevel === 'light') {
|
||||
return this.buildLightSessionMetadata(
|
||||
|
|
@ -812,7 +835,8 @@ export class ProjectScanner {
|
|||
filePath,
|
||||
projectPath,
|
||||
prefetchedMtimeMs,
|
||||
prefetchedSize
|
||||
prefetchedSize,
|
||||
prefetchedBirthtimeMs
|
||||
);
|
||||
}
|
||||
|
||||
|
|
@ -823,7 +847,8 @@ export class ProjectScanner {
|
|||
filePath,
|
||||
projectPath,
|
||||
prefetchedMtimeMs,
|
||||
prefetchedSize
|
||||
prefetchedSize,
|
||||
prefetchedBirthtimeMs
|
||||
);
|
||||
} catch (error) {
|
||||
// In SSH mode, never drop a visible session row due to transient deep-parse failures.
|
||||
|
|
@ -838,7 +863,8 @@ export class ProjectScanner {
|
|||
filePath,
|
||||
projectPath,
|
||||
prefetchedMtimeMs,
|
||||
prefetchedSize
|
||||
prefetchedSize,
|
||||
prefetchedBirthtimeMs
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -1069,6 +1095,14 @@ export class ProjectScanner {
|
|||
};
|
||||
}
|
||||
|
||||
private parseTimestampMs(timestamp: string | undefined): number | null {
|
||||
if (!timestamp) {
|
||||
return null;
|
||||
}
|
||||
const parsed = Date.parse(timestamp);
|
||||
return Number.isFinite(parsed) ? parsed : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Runs async mapping in bounded batches and returns only fulfilled results.
|
||||
* This prevents overwhelming SFTP servers with unbounded parallel requests.
|
||||
|
|
|
|||
|
|
@ -339,13 +339,13 @@ export const ChatHistory = ({ tabId }: ChatHistoryProps): JSX.Element => {
|
|||
rootRef: scrollContainerRef,
|
||||
});
|
||||
|
||||
// Auto-scroll to bottom when new content is added
|
||||
// Disabled during navigation to prevent conflicts with deep link scrolling
|
||||
// Uses shared scrollContainerRef created above
|
||||
// resetKey ensures auto-scroll state resets when switching tabs/sessions
|
||||
useAutoScrollBottom([conversation?.items.length], {
|
||||
// Auto-follow when conversation updates, but only if the user was already near bottom.
|
||||
// This preserves manual reading position when the user scrolls up.
|
||||
// Disabled during navigation to prevent conflicts with deep-link/search scrolling.
|
||||
useAutoScrollBottom([conversation], {
|
||||
threshold: 150,
|
||||
smoothDuration: 300,
|
||||
autoBehavior: 'auto',
|
||||
disabled: shouldDisableAutoScroll,
|
||||
externalRef: scrollContainerRef,
|
||||
resetKey: effectiveTabId,
|
||||
|
|
|
|||
|
|
@ -22,6 +22,12 @@ interface UseAutoScrollBottomOptions {
|
|||
*/
|
||||
enabled?: boolean;
|
||||
|
||||
/**
|
||||
* Scroll behavior used for automatic follow when content updates.
|
||||
* Default: 'smooth'
|
||||
*/
|
||||
autoBehavior?: ScrollBehavior;
|
||||
|
||||
/**
|
||||
* Whether auto-scroll is temporarily disabled (e.g., during navigation).
|
||||
* Unlike enabled, this is for transient disabling during specific operations.
|
||||
|
|
@ -115,6 +121,7 @@ export function useAutoScrollBottom(
|
|||
threshold = 100,
|
||||
smoothDuration = 300,
|
||||
enabled = true,
|
||||
autoBehavior = 'smooth',
|
||||
disabled = false,
|
||||
externalRef,
|
||||
resetKey,
|
||||
|
|
@ -241,11 +248,11 @@ export function useAutoScrollBottom(
|
|||
|
||||
// Only auto-scroll if user was at bottom before the update
|
||||
if (wasAtBottomBeforeUpdateRef.current) {
|
||||
scrollToBottom('smooth');
|
||||
scrollToBottom(autoBehavior);
|
||||
}
|
||||
});
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps -- Dynamic dependencies array is intentional design
|
||||
}, [...dependencies, enabled, disabled, scrollToBottom]);
|
||||
}, [...dependencies, enabled, disabled, autoBehavior, scrollToBottom]);
|
||||
|
||||
/**
|
||||
* Getter function for isAtBottom to avoid accessing ref.current during render.
|
||||
|
|
|
|||
|
|
@ -73,9 +73,10 @@ export function initializeNotificationListeners(): () => void {
|
|||
|
||||
const scheduleSessionRefresh = (projectId: string, sessionId: string): void => {
|
||||
const key = `${projectId}/${sessionId}`;
|
||||
const existingTimer = pendingSessionRefreshTimers.get(key);
|
||||
if (existingTimer) {
|
||||
clearTimeout(existingTimer);
|
||||
// Throttle (not trailing debounce): keep at most one pending refresh per session.
|
||||
// Debounce can delay updates indefinitely while the file is continuously appended.
|
||||
if (pendingSessionRefreshTimers.has(key)) {
|
||||
return;
|
||||
}
|
||||
const timer = setTimeout(() => {
|
||||
pendingSessionRefreshTimers.delete(key);
|
||||
|
|
@ -218,25 +219,26 @@ export function initializeNotificationListeners(): () => void {
|
|||
const matchesSelectedProject =
|
||||
!!selectedProjectId &&
|
||||
(eventProjectBaseId == null || selectedProjectBaseId === eventProjectBaseId);
|
||||
const isTopLevelSessionEvent = !event.isSubagent;
|
||||
const isUnknownSessionInSidebar =
|
||||
event.sessionId != null && !state.sessions.some((session) => session.id === event.sessionId);
|
||||
event.sessionId == null || !state.sessions.some((session) => session.id === event.sessionId);
|
||||
const shouldRefreshForPotentialNewSession =
|
||||
event.type === 'change' &&
|
||||
!event.isSubagent &&
|
||||
isTopLevelSessionEvent &&
|
||||
matchesSelectedProject &&
|
||||
state.connectionMode === 'local' &&
|
||||
isUnknownSessionInSidebar;
|
||||
isUnknownSessionInSidebar &&
|
||||
(event.type === 'add' || (state.connectionMode === 'local' && event.type === 'change'));
|
||||
|
||||
// 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) {
|
||||
// Refresh sidebar session list only when a truly new top-level session appears.
|
||||
// Local fs.watch can report "change" before/without "add" for newly created files.
|
||||
if (shouldRefreshForPotentialNewSession) {
|
||||
if (matchesSelectedProject && selectedProjectId) {
|
||||
scheduleProjectRefresh(selectedProjectId);
|
||||
}
|
||||
}
|
||||
|
||||
// Keep opened session view in sync on content changes.
|
||||
if (event.type === 'change' && selectedProjectId) {
|
||||
// Some local writers emit rename/add for in-place updates, so include "add".
|
||||
if ((event.type === 'change' || event.type === 'add') && selectedProjectId) {
|
||||
const activeSessionId = state.selectedSessionId;
|
||||
const eventSessionId = event.sessionId;
|
||||
const isViewingEventSession =
|
||||
|
|
|
|||
|
|
@ -462,14 +462,14 @@ 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)) {
|
||||
sessionRefreshQueued.add(refreshKey);
|
||||
return;
|
||||
}
|
||||
const generation = (sessionRefreshGeneration.get(refreshKey) ?? 0) + 1;
|
||||
sessionRefreshGeneration.set(refreshKey, generation);
|
||||
sessionRefreshInFlight.add(refreshKey);
|
||||
|
||||
try {
|
||||
|
|
@ -501,10 +501,10 @@ export const createSessionDetailSlice: StateCreator<AppState, [], [], SessionDet
|
|||
}
|
||||
|
||||
const latestState = get();
|
||||
const latestActiveTab = latestState.getActiveTab();
|
||||
const latestAllTabs = getAllTabs(latestState.paneLayout);
|
||||
const stillViewingSession =
|
||||
latestState.selectedSessionId === sessionId ||
|
||||
(latestActiveTab?.type === 'session' && latestActiveTab.sessionId === sessionId);
|
||||
latestAllTabs.some((tab) => tab.type === 'session' && tab.sessionId === sessionId);
|
||||
if (!stillViewingSession) {
|
||||
return;
|
||||
}
|
||||
|
|
@ -532,17 +532,14 @@ 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 = currentState.sessions.map((s) =>
|
||||
s.id === sessionId ? { ...s, isOngoing: detail.session?.isOngoing ?? false } : s
|
||||
);
|
||||
|
||||
// Update only the data, preserve UI states
|
||||
set({
|
||||
set((state) => ({
|
||||
sessionDetail: detail,
|
||||
conversation: newConversation,
|
||||
sessions: updatedSessions,
|
||||
// Update on latest sessions state to avoid restoring stale sidebar snapshots.
|
||||
sessions: state.sessions.map((s) =>
|
||||
s.id === sessionId ? { ...s, isOngoing: detail.session?.isOngoing ?? false } : s
|
||||
),
|
||||
// Preserve visible group if it still exists, otherwise keep current
|
||||
...(visibleGroupStillExists
|
||||
? {
|
||||
|
|
@ -551,11 +548,10 @@ export const createSessionDetailSlice: StateCreator<AppState, [], [], SessionDet
|
|||
: {}),
|
||||
// Note: aiGroupExpansionLevels and expandedStepIds are NOT touched
|
||||
// so expansion states are preserved
|
||||
});
|
||||
}));
|
||||
|
||||
// Also update per-tab session data for all tabs viewing this session
|
||||
const latestTabSessionData = { ...get().tabSessionData };
|
||||
const latestAllTabs = getAllTabs(get().paneLayout);
|
||||
for (const tab of latestAllTabs) {
|
||||
if (tab.type === 'session' && tab.sessionId === sessionId && latestTabSessionData[tab.id]) {
|
||||
const tabData = latestTabSessionData[tab.id];
|
||||
|
|
|
|||
Loading…
Reference in a new issue