diff --git a/src/main/services/discovery/ProjectScanner.ts b/src/main/services/discovery/ProjectScanner.ts index ff9702b9..eb6dfab6 100644 --- a/src/main/services/discovery/ProjectScanner.ts +++ b/src/main/services/discovery/ProjectScanner.ts @@ -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 { - 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 { - 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 { 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. diff --git a/src/renderer/components/chat/ChatHistory.tsx b/src/renderer/components/chat/ChatHistory.tsx index 143b58ed..5b60dd30 100644 --- a/src/renderer/components/chat/ChatHistory.tsx +++ b/src/renderer/components/chat/ChatHistory.tsx @@ -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, diff --git a/src/renderer/hooks/useAutoScrollBottom.ts b/src/renderer/hooks/useAutoScrollBottom.ts index 6d303931..6927192b 100644 --- a/src/renderer/hooks/useAutoScrollBottom.ts +++ b/src/renderer/hooks/useAutoScrollBottom.ts @@ -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. diff --git a/src/renderer/store/index.ts b/src/renderer/store/index.ts index ede5774c..f686fd4a 100644 --- a/src/renderer/store/index.ts +++ b/src/renderer/store/index.ts @@ -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 = diff --git a/src/renderer/store/slices/sessionDetailSlice.ts b/src/renderer/store/slices/sessionDetailSlice.ts index b5e85eb7..344e93c3 100644 --- a/src/renderer/store/slices/sessionDetailSlice.ts +++ b/src/renderer/store/slices/sessionDetailSlice.ts @@ -462,14 +462,14 @@ export const createSessionDetailSlice: StateCreator tab.type === 'session' && tab.sessionId === sessionId); if (!stillViewingSession) { return; } @@ -532,17 +532,14 @@ export const createSessionDetailSlice: StateCreator - 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