Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions src/main/services/discovery/ProjectScanner.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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,
Expand Down
2 changes: 2 additions & 0 deletions src/main/types/domain.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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) */
Expand Down
6 changes: 5 additions & 1 deletion src/main/utils/jsonl.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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)) {
Expand Down
6 changes: 5 additions & 1 deletion src/renderer/components/sidebar/SessionItem.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -285,7 +285,11 @@ export const SessionItem = React.memo(function SessionItem({
{session.messageCount}
</span>
<span style={{ opacity: 0.5 }}>·</span>
<span className="tabular-nums">{formatShortTime(new Date(session.createdAt))}</span>
<span className="tabular-nums">
{formatShortTime(
new Date(Math.max(session.updatedAt ?? session.createdAt, session.createdAt))
)}
</span>
{session.contextConsumption != null && session.contextConsumption > 0 && (
<>
<span style={{ opacity: 0.5 }}>·</span>
Expand Down
10 changes: 4 additions & 6 deletions src/renderer/store/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}
Expand Down
8 changes: 6 additions & 2 deletions src/renderer/store/slices/sessionSlice.ts
Original file line number Diff line number Diff line change
Expand Up @@ -109,8 +109,12 @@ export const createSessionSlice: StateCreator<AppState, [], [], SessionSlice> =
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({
Expand Down
15 changes: 9 additions & 6 deletions src/renderer/utils/dateGrouping.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
*
Expand All @@ -28,7 +29,9 @@ export function groupSessionsByDate(sessions: Session[]): DateGroupedSessions {

return sessions.reduce<DateGroupedSessions>(
(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);
Expand Down
36 changes: 36 additions & 0 deletions test-debug.ts
Original file line number Diff line number Diff line change
@@ -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();
70 changes: 70 additions & 0 deletions test/main/services/discovery/ProjectScanner.updatedAt.test.ts
Original file line number Diff line number Diff line change
@@ -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);
});
});
39 changes: 29 additions & 10 deletions test/renderer/utils/dateGrouping.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
};
}

Expand Down Expand Up @@ -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', () => {
Expand Down
Loading