Skip to content

Commit 77d765d

Browse files
xuyushun441-sysos-zhuangclaude
authored
feat(console/ai): group the conversations list by date (Today / Yesterday / …) (#1709)
The conversations list was a flat, undated stream. Group it into recency sections like ChatGPT/Claude — Today, Yesterday, Previous 7 days, Previous 30 days, Older — with section headers, newest-first within each, empty sections omitted. Boundaries are calendar-day based (local midnight), so Today/Yesterday track the real day rather than a rolling 24h. The bucketing is a pure exported `groupConversationsByDate(conversations, nowMs?)` (nowMs defaults to now, kept out of the render path) and unit-tested. Search, active highlight, rename/delete, and the per-row relative time are unchanged. Test: 506 pass (4 new — ordered buckets, empty omitted, newest-first + unparseable-as-oldest, createdAt fallback). tsc clean; no new lint errors. Co-authored-by: Jack Zhuang <277994282+os-zhuang@users.noreply.github.com> Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent e9ee802 commit 77d765d

2 files changed

Lines changed: 131 additions & 17 deletions

File tree

packages/app-shell/src/console/ai/ConversationsSidebar.tsx

Lines changed: 81 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -49,6 +49,61 @@ function formatTimestamp(
4949
return d.toLocaleDateString();
5050
}
5151

52+
export type ConversationGroupKey = 'today' | 'yesterday' | 'previous7Days' | 'previous30Days' | 'older';
53+
54+
export interface ConversationGroup {
55+
key: ConversationGroupKey;
56+
items: ConversationSummary[];
57+
}
58+
59+
const GROUP_ORDER: ConversationGroupKey[] = ['today', 'yesterday', 'previous7Days', 'previous30Days', 'older'];
60+
61+
/** English fallbacks for the section headers (overridable via i18n). */
62+
export const CONVERSATION_GROUP_LABELS: Record<ConversationGroupKey, string> = {
63+
today: 'Today',
64+
yesterday: 'Yesterday',
65+
previous7Days: 'Previous 7 days',
66+
previous30Days: 'Previous 30 days',
67+
older: 'Older',
68+
};
69+
70+
/**
71+
* Bucket conversations into recency sections (ChatGPT/Claude-style), newest
72+
* first within each. Boundaries are calendar-day based off local midnight, so
73+
* "Today"/"Yesterday" track the actual day rather than a rolling 24h. Pure +
74+
* exported for tests; `nowMs` defaults to the current time (kept out of the
75+
* component's render path). Empty sections are omitted.
76+
*/
77+
export function groupConversationsByDate(
78+
conversations: ConversationSummary[],
79+
nowMs: number = Date.now(),
80+
): ConversationGroup[] {
81+
const startOfToday = new Date(nowMs);
82+
startOfToday.setHours(0, 0, 0, 0);
83+
const todayMs = startOfToday.getTime();
84+
const DAY = 24 * 60 * 60 * 1000;
85+
const stamp = (c: ConversationSummary): number => {
86+
const v = new Date(c.updatedAt ?? c.createdAt ?? 0).getTime();
87+
return Number.isNaN(v) ? 0 : v;
88+
};
89+
const buckets: Record<ConversationGroupKey, ConversationSummary[]> = {
90+
today: [],
91+
yesterday: [],
92+
previous7Days: [],
93+
previous30Days: [],
94+
older: [],
95+
};
96+
for (const c of [...conversations].sort((a, b) => stamp(b) - stamp(a))) {
97+
const v = stamp(c);
98+
if (v >= todayMs) buckets.today.push(c);
99+
else if (v >= todayMs - DAY) buckets.yesterday.push(c);
100+
else if (v >= todayMs - 7 * DAY) buckets.previous7Days.push(c);
101+
else if (v >= todayMs - 30 * DAY) buckets.previous30Days.push(c);
102+
else buckets.older.push(c);
103+
}
104+
return GROUP_ORDER.filter((k) => buckets[k].length > 0).map((key) => ({ key, items: buckets[key] }));
105+
}
106+
52107
export function ConversationsSidebar({
53108
userId,
54109
apiBase,
@@ -165,24 +220,33 @@ export function ConversationsSidebar({
165220
) : visible.length === 0 ? (
166221
<div className="px-3 py-4 text-xs text-muted-foreground">{t('console.ai.noMatchingChats')}</div>
167222
) : (
168-
<ul className="flex flex-col py-1">
169-
{visible.map((c) => (
170-
<ConversationRow
171-
key={c.id}
172-
conversation={c}
173-
active={c.id === activeId}
174-
renaming={c.id === renamingId}
175-
onSelect={() => {
176-
navigate(`/ai/${c.id}`);
177-
onNavigate?.();
178-
}}
179-
onDelete={(e) => handleDelete(e, c.id)}
180-
onStartRename={() => setRenamingId(c.id)}
181-
onCancelRename={() => setRenamingId(undefined)}
182-
onSubmitRename={(title) => handleRenameSubmit(c.id, title)}
183-
/>
223+
<div className="flex flex-col py-1">
224+
{groupConversationsByDate(visible).map((group) => (
225+
<section key={group.key} data-testid={`ai-conversation-group-${group.key}`}>
226+
<h3 className="px-3 pb-1 pt-3 text-[10px] font-medium uppercase tracking-wide text-muted-foreground">
227+
{t(`console.ai.group.${group.key}`, { defaultValue: CONVERSATION_GROUP_LABELS[group.key] })}
228+
</h3>
229+
<ul className="flex flex-col">
230+
{group.items.map((c) => (
231+
<ConversationRow
232+
key={c.id}
233+
conversation={c}
234+
active={c.id === activeId}
235+
renaming={c.id === renamingId}
236+
onSelect={() => {
237+
navigate(`/ai/${c.id}`);
238+
onNavigate?.();
239+
}}
240+
onDelete={(e) => handleDelete(e, c.id)}
241+
onStartRename={() => setRenamingId(c.id)}
242+
onCancelRename={() => setRenamingId(undefined)}
243+
onSubmitRename={(title) => handleRenameSubmit(c.id, title)}
244+
/>
245+
))}
246+
</ul>
247+
</section>
184248
))}
185-
</ul>
249+
</div>
186250
)}
187251
</ScrollArea>
188252
</aside>
Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,50 @@
1+
import { describe, expect, it } from 'vitest';
2+
import { groupConversationsByDate } from '../ConversationsSidebar';
3+
import type { ConversationSummary } from '../../../hooks/useConversationList';
4+
5+
const DAY = 24 * 60 * 60 * 1000;
6+
// Fixed reference time. Offsets are chosen to land squarely in their bucket
7+
// regardless of the test runner's timezone (0 = today; exactly 24h = yesterday;
8+
// 4/15/60 days are unambiguous), so the calendar-midnight boundary can't flake.
9+
const now = Date.UTC(2026, 5, 13, 12, 0, 0);
10+
const conv = (id: string, offset: number): ConversationSummary =>
11+
({ id, updatedAt: new Date(now - offset).toISOString() }) as ConversationSummary;
12+
13+
describe('groupConversationsByDate', () => {
14+
it('buckets into ordered recency sections', () => {
15+
const groups = groupConversationsByDate(
16+
[conv('older', 60 * DAY), conv('today', 0), conv('week', 4 * DAY), conv('yesterday', DAY), conv('month', 15 * DAY)],
17+
now,
18+
);
19+
expect(groups.map((g) => g.key)).toEqual(['today', 'yesterday', 'previous7Days', 'previous30Days', 'older']);
20+
expect(groups.map((g) => g.items[0].id)).toEqual(['today', 'yesterday', 'week', 'month', 'older']);
21+
});
22+
23+
it('omits empty sections', () => {
24+
const groups = groupConversationsByDate([conv('a', 0), conv('b', 0)], now);
25+
expect(groups).toHaveLength(1);
26+
expect(groups[0].key).toBe('today');
27+
expect(groups[0].items).toHaveLength(2);
28+
});
29+
30+
it('sorts newest-first within a section and treats unparseable timestamps as oldest', () => {
31+
const groups = groupConversationsByDate(
32+
[
33+
conv('o2', 61 * DAY),
34+
conv('o1', 60 * DAY),
35+
{ id: 'bad', updatedAt: 'not-a-date' } as ConversationSummary,
36+
],
37+
now,
38+
);
39+
const older = groups.find((g) => g.key === 'older');
40+
expect(older?.items.map((i) => i.id)).toEqual(['o1', 'o2', 'bad']);
41+
});
42+
43+
it('falls back to createdAt when updatedAt is absent', () => {
44+
const groups = groupConversationsByDate(
45+
[{ id: 'c', createdAt: new Date(now).toISOString() } as ConversationSummary],
46+
now,
47+
);
48+
expect(groups[0]?.key).toBe('today');
49+
});
50+
});

0 commit comments

Comments
 (0)