diff --git a/src/main/services/discovery/ProjectScanner.ts b/src/main/services/discovery/ProjectScanner.ts index 069af1bc..32e4b551 100644 --- a/src/main/services/discovery/ProjectScanner.ts +++ b/src/main/services/discovery/ProjectScanner.ts @@ -760,6 +760,7 @@ export class ProjectScanner { projectPath, todoData, createdAt: Math.floor(createdAt), + updatedAt: Math.floor(effectiveMtime), firstMessage: metadata.firstUserMessage?.text, messageTimestamp: metadata.firstUserMessage?.timestamp, hasSubagents, @@ -830,6 +831,7 @@ export class ProjectScanner { projectId, projectPath, createdAt: Math.floor(createdAt), + updatedAt: Math.floor(effectiveMtime), firstMessage: metadata.firstUserMessage?.text, messageTimestamp: metadata.firstUserMessage?.timestamp, hasSubagents: false, diff --git a/src/main/types/domain.ts b/src/main/types/domain.ts index 6d2146b0..c50f1a44 100644 --- a/src/main/types/domain.ts +++ b/src/main/types/domain.ts @@ -89,6 +89,8 @@ export interface Session { todoData?: unknown; /** Unix timestamp when session file was created */ createdAt: number; + /** Unix timestamp of last update/activity */ + updatedAt?: number; /** First user message text (for preview) */ firstMessage?: string; /** Timestamp of first user message (RFC3339) */ diff --git a/src/main/utils/jsonl.ts b/src/main/utils/jsonl.ts index d998438f..8c90181e 100644 --- a/src/main/utils/jsonl.ts +++ b/src/main/utils/jsonl.ts @@ -440,7 +440,11 @@ export async function analyzeSessionFileMetadata( gitBranch = entry.gitBranch; } - if (!firstUserMessage && entry.type === 'user') { + if ( + !firstUserMessage && + entry.type === 'user' && + !('isMeta' in entry && entry.isMeta === true) + ) { const content = entry.message?.content; if (typeof content === 'string') { if (isCommandOutputContent(content)) { diff --git a/src/renderer/components/sidebar/SessionItem.tsx b/src/renderer/components/sidebar/SessionItem.tsx index b2bba28f..03392ffb 100644 --- a/src/renderer/components/sidebar/SessionItem.tsx +++ b/src/renderer/components/sidebar/SessionItem.tsx @@ -285,7 +285,11 @@ export const SessionItem = React.memo(function SessionItem({ {session.messageCount} · - {formatShortTime(new Date(session.createdAt))} + + {formatShortTime( + new Date(Math.max(session.updatedAt ?? session.createdAt, session.createdAt)) + )} + {session.contextConsumption != null && session.contextConsumption > 0 && ( <> · diff --git a/src/renderer/store/index.ts b/src/renderer/store/index.ts index 07aa12c5..7c2b1c23 100644 --- a/src/renderer/store/index.ts +++ b/src/renderer/store/index.ts @@ -249,15 +249,13 @@ export function initializeNotificationListeners(): () => void { const isUnknownSessionInSidebar = event.sessionId == null || !state.sessions.some((session) => session.id === event.sessionId); - const shouldRefreshForPotentialNewSession = + const shouldRefreshSidebar = isTopLevelSessionEvent && matchesSelectedProject && - isUnknownSessionInSidebar && - (event.type === 'add' || (state.connectionMode === 'local' && event.type === 'change')); + (isUnknownSessionInSidebar || event.type === 'change' || event.type === 'add'); - // 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) { + // Refresh sidebar session list when a new session appears or an existing session updates. + if (shouldRefreshSidebar) { if (matchesSelectedProject && selectedProjectId) { scheduleProjectRefresh(selectedProjectId); } diff --git a/src/renderer/store/slices/sessionSlice.ts b/src/renderer/store/slices/sessionSlice.ts index 31f9596b..c0319d2c 100644 --- a/src/renderer/store/slices/sessionSlice.ts +++ b/src/renderer/store/slices/sessionSlice.ts @@ -109,8 +109,12 @@ export const createSessionSlice: StateCreator = set({ sessionsLoading: true, sessionsError: null }); try { const sessions = await api.getSessions(projectId); - // Sort by createdAt (descending) - const sorted = [...sessions].sort((a, b) => b.createdAt - a.createdAt); + // Sort by max of updatedAt/createdAt (descending) + const sorted = [...sessions].sort( + (a, b) => + Math.max(b.updatedAt ?? b.createdAt, b.createdAt) - + Math.max(a.updatedAt ?? a.createdAt, a.createdAt) + ); set({ sessions: sorted, sessionsLoading: false }); } catch (error) { set({ diff --git a/src/renderer/utils/dateGrouping.ts b/src/renderer/utils/dateGrouping.ts index 718c0351..bfcfd630 100644 --- a/src/renderer/utils/dateGrouping.ts +++ b/src/renderer/utils/dateGrouping.ts @@ -12,11 +12,12 @@ import type { DateCategory, DateGroupedSessions } from '../types/tabs'; /** * Groups sessions by relative date category. - * Sessions are categorized based on their createdAt timestamp: - * - Today: Sessions created today - * - Yesterday: Sessions created yesterday - * - Previous 7 Days: Sessions created 2-7 days ago - * - Older: Sessions created more than 7 days ago + * Sessions are categorized based on their most recent timestamp + * (max(updatedAt, createdAt), with createdAt fallback): + * - Today: Sessions updated/created today + * - Yesterday: Sessions updated/created yesterday + * - Previous 7 Days: Sessions updated/created 2-7 days ago + * - Older: Sessions updated/created more than 7 days ago * * Within each category, sessions maintain their original sort order. * @@ -28,7 +29,9 @@ export function groupSessionsByDate(sessions: Session[]): DateGroupedSessions { return sessions.reduce( (acc, session) => { - const sessionDate = new Date(session.createdAt); + // Use updatedAt if available, fallback to createdAt. Ensure we don't go backwards in time. + const timestamp = Math.max(session.updatedAt ?? session.createdAt, session.createdAt); + const sessionDate = new Date(timestamp); if (isToday(sessionDate)) { acc.Today.push(session); diff --git a/test-debug.ts b/test-debug.ts new file mode 100644 index 00000000..ad8fad2a --- /dev/null +++ b/test-debug.ts @@ -0,0 +1,36 @@ +import * as fs from 'fs'; +import * as os from 'os'; +import * as path from 'path'; +import { ProjectScanner } from './src/main/services/discovery/ProjectScanner'; + +async function run() { + const projectsDir = fs.mkdtempSync(path.join(os.tmpdir(), 'scanner-')); + const encodedName = '-Users-test-myproject'; + const projectDir = path.join(projectsDir, encodedName); + fs.mkdirSync(projectDir); + + const filePath = path.join(projectDir, 'session-timestamp-test.jsonl'); + + const oldDateMs = Date.now() - 30 * 24 * 60 * 60 * 1000; + const oldIsoString = new Date(oldDateMs).toISOString(); + + fs.writeFileSync( + filePath, + JSON.stringify({ + uuid: 'test-uuid', + type: 'user', + message: { role: 'user', content: 'hello' }, + timestamp: oldIsoString, + isMeta: false, + }) + '\n' + ); + + const nowMs = Date.now(); + fs.utimesSync(filePath, new Date(nowMs), new Date(nowMs)); + + const scanner = new ProjectScanner(projectsDir); + const projects = await scanner.scanProject(encodedName); + const sessions = await scanner.listSessions(encodedName); + console.log(JSON.stringify(sessions, null, 2)); +} +run(); diff --git a/test/main/services/discovery/ProjectScanner.updatedAt.test.ts b/test/main/services/discovery/ProjectScanner.updatedAt.test.ts new file mode 100644 index 00000000..c64e501a --- /dev/null +++ b/test/main/services/discovery/ProjectScanner.updatedAt.test.ts @@ -0,0 +1,70 @@ +import * as fs from 'fs'; +import * as os from 'os'; +import * as path from 'path'; +import { afterEach, describe, expect, it } from 'vitest'; + +import { ProjectScanner } from '../../../../src/main/services/discovery/ProjectScanner'; + +function createSessionLine(opts: { type?: string; timestamp?: string; role?: string }): string { + return JSON.stringify({ + uuid: 'test-uuid', + type: opts.type ?? 'user', + message: { role: opts.role ?? 'user', content: 'hello' }, + timestamp: opts.timestamp ?? new Date().toISOString(), + isMeta: false, // Must not be meta so the scanner recognizes it as the first real user message + }); +} + +describe('ProjectScanner updatedAt logic', () => { + const tempDirs: string[] = []; + + afterEach(async () => { + await new Promise((resolve) => setTimeout(resolve, 50)); + for (const dir of tempDirs) { + try { + fs.rmSync(dir, { recursive: true, force: true, maxRetries: 5, retryDelay: 200 }); + } catch { + // Ignore cleanup failures + } + } + tempDirs.length = 0; + }); + + it('preserves old createdAt from first user message but uses recent mtime for updatedAt', async () => { + const projectsDir = fs.mkdtempSync(path.join(os.tmpdir(), 'scanner-')); + tempDirs.push(projectsDir); + + const encodedName = '-Users-test-myproject'; + const projectDir = path.join(projectsDir, encodedName); + fs.mkdirSync(projectDir); + + const filePath = path.join(projectDir, 'session-timestamp-test.jsonl'); + + // Simulate an old first user message (weeks ago) + const oldDateMs = Date.now() - 30 * 24 * 60 * 60 * 1000; + const oldIsoString = new Date(oldDateMs).toISOString(); + + fs.writeFileSync( + filePath, + createSessionLine({ type: 'user', role: 'user', timestamp: oldIsoString }) + '\n' + ); + + // Force the file mtime to be exactly now + const nowMs = Date.now(); + fs.utimesSync(filePath, new Date(nowMs), new Date(nowMs)); + + const scanner = new ProjectScanner(projectsDir); + const sessions = await scanner.listSessions(encodedName); + + expect(sessions).toHaveLength(1); + + const session = sessions[0]; + + // createdAt should strictly use the old message timestamp + expect(session.createdAt).toBe(Math.floor(oldDateMs)); + + // updatedAt should use the forced recent file mtime (within ~1 second variance depending on fs resolution) + expect(session.updatedAt).toBeDefined(); + expect(Math.abs(session.updatedAt! - nowMs)).toBeLessThan(2000); + }); +}); diff --git a/test/renderer/utils/dateGrouping.test.ts b/test/renderer/utils/dateGrouping.test.ts index 381f0656..bb388b9a 100644 --- a/test/renderer/utils/dateGrouping.test.ts +++ b/test/renderer/utils/dateGrouping.test.ts @@ -7,18 +7,15 @@ import { import type { Session } from '../../../src/renderer/types/data'; // Helper to create a session with a specific date -function createSession(id: string, createdAt: Date): Session { +function createSession(id: string, createdAt: Date, updatedAt?: Date): Session { return { id, - createdAt: createdAt.toISOString(), - updatedAt: createdAt.toISOString(), - displayName: `Session ${id}`, - triggerCount: 1, - ongoing: false, - lastTriggerPreview: 'Test', - cwd: '/test', - todos: [], - totalTokens: 0, + projectId: 'test-project', + projectPath: '/test/project', + createdAt: createdAt.getTime(), + updatedAt: updatedAt?.getTime(), + hasSubagents: false, + messageCount: 5, }; } @@ -119,6 +116,28 @@ describe('dateGrouping', () => { expect(result.Today.map((s) => s.id)).toEqual(['first', 'second', 'third']); }); + + it('should use updatedAt if available over createdAt', () => { + const createdAgo = new Date('2024-01-01T10:00:00Z'); // Older + const updatedToday = new Date('2024-01-15T10:00:00Z'); // Today + const sessions = [createSession('1', createdAgo, updatedToday)]; + + const result = groupSessionsByDate(sessions); + + expect(result.Today).toHaveLength(1); + expect(result.Older).toHaveLength(0); + }); + + it('should not allow an older updatedAt to override a newer createdAt', () => { + const createdToday = new Date('2024-01-15T10:00:00Z'); // Today + const updatedOld = new Date('2024-01-01T10:00:00Z'); // Older + const sessions = [createSession('1', createdToday, updatedOld)]; + + const result = groupSessionsByDate(sessions); + + expect(result.Today).toHaveLength(1); + expect(result.Older).toHaveLength(0); + }); }); describe('getNonEmptyCategories', () => {