Skip to content

Commit 2681e5e

Browse files
committed
feat(kernel-tui): group sessions into recent/oldish/archived buckets
1 parent aac8d58 commit 2681e5e

12 files changed

Lines changed: 482 additions & 37 deletions

File tree

packages/kernel-node-runtime/src/daemon/rpc-socket-server.test.ts

Lines changed: 11 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -60,10 +60,12 @@ async function sendRequest(
6060
}
6161

6262
function makeTestSession(overrides: Partial<Session> = {}): Session {
63+
const startedAt = overrides.startedAt ?? '2026-01-01T00:00:00.000Z';
6364
return {
6465
sessionId: 'alice',
6566
ocapUrl: 'ocap://test-url',
66-
startedAt: '2026-01-01T00:00:00.000Z',
67+
startedAt,
68+
lastActiveAt: vi.fn().mockReturnValue(startedAt),
6769
listPending: vi.fn().mockReturnValue([]),
6870
listHistory: vi.fn().mockReturnValue([]),
6971
decide: vi.fn(),
@@ -138,7 +140,7 @@ describe('startRpcSocketServer — session.* methods', () => {
138140
vi.clearAllMocks();
139141
});
140142

141-
it('session.create response includes sessionId, ocapUrl, startedAt', async () => {
143+
it('session.create response includes sessionId, ocapUrl, startedAt, lastActiveAt', async () => {
142144
const { startRpcSocketServer } = await import('./rpc-socket-server.ts');
143145
const socketPath = makeSocketPath();
144146
const registry = makeTestRegistry();
@@ -157,6 +159,7 @@ describe('startRpcSocketServer — session.* methods', () => {
157159
sessionId: 'alice',
158160
ocapUrl: 'ocap://alice',
159161
startedAt: '2026-01-01T00:00:00.000Z',
162+
lastActiveAt: '2026-01-01T00:00:00.000Z',
160163
});
161164
});
162165

@@ -182,6 +185,7 @@ describe('startRpcSocketServer — session.* methods', () => {
182185
ocapUrl: 'ocap://alice',
183186
cwd: '/home/user',
184187
startedAt: '2026-01-01T00:00:00.000Z',
188+
lastActiveAt: '2026-01-01T00:00:00.000Z',
185189
});
186190
});
187191

@@ -203,13 +207,14 @@ describe('startRpcSocketServer — session.* methods', () => {
203207
expect(response.result).not.toHaveProperty('cwd');
204208
});
205209

206-
it('session.list returns sessions with sessionId, ocapUrl, startedAt', async () => {
210+
it('session.list returns sessions with sessionId, ocapUrl, startedAt, lastActiveAt', async () => {
207211
const { startRpcSocketServer } = await import('./rpc-socket-server.ts');
208212
const socketPath = makeSocketPath();
209213
const existing = makeTestSession({
210214
sessionId: 'alice',
211215
ocapUrl: 'ocap://alice',
212216
startedAt: '2026-01-01T00:00:00.000Z',
217+
lastActiveAt: vi.fn().mockReturnValue('2026-01-02T00:00:00.000Z'),
213218
});
214219
const registry = makeTestRegistry([existing]);
215220

@@ -228,11 +233,12 @@ describe('startRpcSocketServer — session.* methods', () => {
228233
sessionId: 'alice',
229234
ocapUrl: 'ocap://alice',
230235
startedAt: '2026-01-01T00:00:00.000Z',
236+
lastActiveAt: '2026-01-02T00:00:00.000Z',
231237
},
232238
]);
233239
});
234240

235-
it('session.get returns session with sessionId, ocapUrl, startedAt', async () => {
241+
it('session.get returns session with sessionId, ocapUrl, startedAt, lastActiveAt', async () => {
236242
const { startRpcSocketServer } = await import('./rpc-socket-server.ts');
237243
const socketPath = makeSocketPath();
238244
const existing = makeTestSession({
@@ -258,6 +264,7 @@ describe('startRpcSocketServer — session.* methods', () => {
258264
sessionId: 'alice',
259265
ocapUrl: 'ocap://alice',
260266
startedAt: '2026-01-01T00:00:00.000Z',
267+
lastActiveAt: '2026-01-01T00:00:00.000Z',
261268
});
262269
});
263270

packages/kernel-node-runtime/src/daemon/rpc-socket-server.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -323,6 +323,7 @@ async function handleSessionRequest(
323323
ocapUrl: session.ocapUrl,
324324
...ifDefined({ cwd: session.cwd }),
325325
startedAt: session.startedAt,
326+
lastActiveAt: session.lastActiveAt(),
326327
});
327328
}
328329

@@ -333,6 +334,7 @@ async function handleSessionRequest(
333334
ocapUrl: sess.ocapUrl,
334335
...ifDefined({ cwd: sess.cwd }),
335336
startedAt: sess.startedAt,
337+
lastActiveAt: sess.lastActiveAt(),
336338
})),
337339
);
338340
}
@@ -349,6 +351,7 @@ async function handleSessionRequest(
349351
ocapUrl: session.ocapUrl,
350352
...ifDefined({ cwd: session.cwd }),
351353
startedAt: session.startedAt,
354+
lastActiveAt: session.lastActiveAt(),
352355
});
353356
}
354357

Lines changed: 100 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,100 @@
1+
import { describe, it, expect } from 'vitest';
2+
3+
import { bucketFor, bucketSessions } from './session-buckets.ts';
4+
import type { SessionWithRequests } from '../hooks/use-session-data.ts';
5+
6+
function makeSession(
7+
overrides: Partial<SessionWithRequests>,
8+
): SessionWithRequests {
9+
return {
10+
sessionId: 'sess',
11+
ocapUrl: 'ocap://sess',
12+
requests: [],
13+
...overrides,
14+
};
15+
}
16+
17+
// A Tuesday at 14:00 local time, well clear of any DST edge.
18+
const NOW = new Date(2026, 5, 16, 14, 0, 0); // 2026-06-16 (Tue)
19+
20+
describe('bucketFor', () => {
21+
it.each([
22+
{ label: 'same day, hours earlier', offsetMs: -2 * 60 * 60 * 1000 },
23+
{ label: 'same day, minutes earlier', offsetMs: -10 * 60 * 1000 },
24+
{ label: 'exactly today midnight', offsetMs: -14 * 60 * 60 * 1000 },
25+
])('classifies $label as recent', ({ offsetMs }) => {
26+
const stamp = new Date(NOW.getTime() + offsetMs).toISOString();
27+
expect(bucketFor(stamp, NOW)).toBe('recent');
28+
});
29+
30+
it('classifies a late-night session from yesterday within the last 3 hours as recent', () => {
31+
// It's 02:00; lastActive was 23:30 yesterday — 2.5h ago.
32+
const earlyMorning = new Date(2026, 5, 17, 2, 0, 0);
33+
const lateLastNight = new Date(2026, 5, 16, 23, 30, 0).toISOString();
34+
expect(bucketFor(lateLastNight, earlyMorning)).toBe('recent');
35+
});
36+
37+
it('classifies yesterday afternoon (more than 3h ago, before today) as oldish', () => {
38+
// NOW is Tuesday 14:00; yesterday 14:00 is 24h ago → not recent, but in this week.
39+
const yesterdayAfternoon = new Date(2026, 5, 15, 14, 0, 0).toISOString();
40+
expect(bucketFor(yesterdayAfternoon, NOW)).toBe('oldish');
41+
});
42+
43+
it('classifies last calendar week as oldish', () => {
44+
// NOW is Tuesday 2026-06-16; previous Monday is 2026-06-08.
45+
const lastWeek = new Date(2026, 5, 9, 10, 0, 0).toISOString();
46+
expect(bucketFor(lastWeek, NOW)).toBe('oldish');
47+
});
48+
49+
it('classifies two weeks ago as archived', () => {
50+
const twoWeeksAgo = new Date(2026, 5, 2, 10, 0, 0).toISOString();
51+
expect(bucketFor(twoWeeksAgo, NOW)).toBe('archived');
52+
});
53+
54+
it('classifies an undefined timestamp as archived', () => {
55+
expect(bucketFor(undefined, NOW)).toBe('archived');
56+
});
57+
58+
it('classifies an unparseable timestamp as archived', () => {
59+
expect(bucketFor('not-a-date', NOW)).toBe('archived');
60+
});
61+
});
62+
63+
describe('bucketSessions', () => {
64+
it('groups sessions by lastActiveAt with fallback to startedAt', () => {
65+
const recent = makeSession({
66+
sessionId: 'r',
67+
lastActiveAt: new Date(NOW.getTime() - 10 * 60 * 1000).toISOString(),
68+
});
69+
const earlier = makeSession({
70+
sessionId: 'e',
71+
lastActiveAt: new Date(2026, 5, 15, 10, 0, 0).toISOString(),
72+
});
73+
const old = makeSession({
74+
sessionId: 'o',
75+
// no lastActiveAt, falls back to startedAt
76+
startedAt: new Date(2026, 5, 1, 10, 0, 0).toISOString(),
77+
});
78+
expect(bucketSessions([recent, earlier, old], NOW)).toStrictEqual({
79+
recent: [recent],
80+
oldish: [earlier],
81+
archived: [old],
82+
});
83+
});
84+
85+
it('preserves input order within each bucket', () => {
86+
const first = makeSession({
87+
sessionId: 'a',
88+
lastActiveAt: new Date(NOW.getTime() - 10 * 60 * 1000).toISOString(),
89+
});
90+
const second = makeSession({
91+
sessionId: 'b',
92+
lastActiveAt: new Date(NOW.getTime() - 30 * 60 * 1000).toISOString(),
93+
});
94+
const grouped = bucketSessions([first, second], NOW);
95+
expect(grouped.recent.map((sess) => sess.sessionId)).toStrictEqual([
96+
'a',
97+
'b',
98+
]);
99+
});
100+
});
Lines changed: 97 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,97 @@
1+
import type { SessionWithRequests } from '../hooks/use-session-data.ts';
2+
3+
export type Bucket = 'recent' | 'oldish' | 'archived';
4+
5+
const HOURS_3_MS = 3 * 60 * 60 * 1000;
6+
const DAY_MS = 24 * 60 * 60 * 1000;
7+
8+
/**
9+
* Return a Date at the start of the local calendar day containing `date`.
10+
*
11+
* @param date - The reference Date.
12+
* @returns A new Date at 00:00:00.000 local time.
13+
*/
14+
function startOfDay(date: Date): Date {
15+
const result = new Date(date);
16+
result.setHours(0, 0, 0, 0);
17+
return result;
18+
}
19+
20+
/**
21+
* Return a Date at the start of the ISO week (Monday 00:00 local) containing
22+
* `date`.
23+
*
24+
* @param date - The reference Date.
25+
* @returns A new Date at the start of that week.
26+
*/
27+
function startOfWeekMonday(date: Date): Date {
28+
const result = startOfDay(date);
29+
const day = result.getDay();
30+
const offset = day === 0 ? 6 : day - 1;
31+
result.setDate(result.getDate() - offset);
32+
return result;
33+
}
34+
35+
/**
36+
* Classify a timestamp into a recency bucket.
37+
*
38+
* - `recent`: lastActive is on today's calendar day OR within the last 3 hours.
39+
* - `oldish`: lastActive is within this or last ISO calendar week, but not recent.
40+
* - `archived`: anything older, or missing/invalid.
41+
*
42+
* @param timestamp - ISO 8601 timestamp, or undefined.
43+
* @param now - The reference "now" Date.
44+
* @returns The bucket name.
45+
*/
46+
export function bucketFor(timestamp: string | undefined, now: Date): Bucket {
47+
if (timestamp === undefined) {
48+
return 'archived';
49+
}
50+
const stamp = new Date(timestamp);
51+
const stampMs = stamp.getTime();
52+
if (Number.isNaN(stampMs)) {
53+
return 'archived';
54+
}
55+
const todayStart = startOfDay(now).getTime();
56+
const last3hCutoff = now.getTime() - HOURS_3_MS;
57+
if (stampMs >= todayStart || stampMs >= last3hCutoff) {
58+
return 'recent';
59+
}
60+
const thisWeekStart = startOfWeekMonday(now).getTime();
61+
const lastWeekStart = thisWeekStart - 7 * DAY_MS;
62+
if (stampMs >= lastWeekStart) {
63+
return 'oldish';
64+
}
65+
return 'archived';
66+
}
67+
68+
export type BucketedSessions = {
69+
recent: SessionWithRequests[];
70+
oldish: SessionWithRequests[];
71+
archived: SessionWithRequests[];
72+
};
73+
74+
/**
75+
* Group sessions into recency buckets. Each session's `lastActiveAt` (with
76+
* fallback to `startedAt`) is run through {@link bucketFor}; the input order
77+
* within each bucket is preserved.
78+
*
79+
* @param sessions - Sessions in display order (most recently active first).
80+
* @param now - The reference "now" Date.
81+
* @returns Buckets keyed by name.
82+
*/
83+
export function bucketSessions(
84+
sessions: SessionWithRequests[],
85+
now: Date,
86+
): BucketedSessions {
87+
const buckets: BucketedSessions = {
88+
recent: [],
89+
oldish: [],
90+
archived: [],
91+
};
92+
for (const session of sessions) {
93+
const stamp = session.lastActiveAt ?? session.startedAt;
94+
buckets[bucketFor(stamp, now)].push(session);
95+
}
96+
return buckets;
97+
}

0 commit comments

Comments
 (0)