Skip to content

Commit 47e7314

Browse files
committed
add conversation group
1 parent 993b4dd commit 47e7314

File tree

1 file changed

+179
-57
lines changed

1 file changed

+179
-57
lines changed

tools/server/webui/src/components/Sidebar.tsx

Lines changed: 179 additions & 57 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { useEffect, useState } from 'react';
1+
import { useEffect, useMemo, useState } from 'react';
22
import { classNames } from '../utils/misc';
33
import { Conversation } from '../utils/types';
44
import StorageUtils from '../utils/storage';
@@ -38,6 +38,11 @@ export default function Sidebar() {
3838
};
3939
}, []);
4040

41+
const groupedConv = useMemo(
42+
() => groupConversationsByDate(conversations),
43+
[conversations]
44+
);
45+
4146
return (
4247
<>
4348
<input
@@ -63,69 +68,90 @@ export default function Sidebar() {
6368
</label>
6469
</div>
6570

66-
{/* list of conversations */}
71+
{/* new conversation button */}
6772
<div
6873
className={classNames({
69-
'btn btn-ghost justify-start': true,
74+
'btn btn-ghost justify-start px-2': true,
7075
'btn-soft': !currConv,
7176
})}
7277
onClick={() => navigate('/')}
7378
>
7479
+ New conversation
7580
</div>
76-
{conversations.map((conv) => (
77-
<ConversationItem
78-
key={conv.id}
79-
conv={conv}
80-
isCurrConv={currConv?.id === conv.id}
81-
onSelect={() => {
82-
navigate(`/chat/${conv.id}`);
83-
}}
84-
onDelete={() => {
85-
if (isGenerating(conv.id)) {
86-
toast.error('Cannot delete conversation while generating');
87-
return;
88-
}
89-
if (
90-
window.confirm('Are you sure to delete this conversation?')
91-
) {
92-
toast.success('Conversation deleted');
93-
StorageUtils.remove(conv.id);
94-
navigate('/');
95-
}
96-
}}
97-
onDownload={() => {
98-
if (isGenerating(conv.id)) {
99-
toast.error('Cannot download conversation while generating');
100-
return;
101-
}
102-
const conversationJson = JSON.stringify(conv, null, 2);
103-
const blob = new Blob([conversationJson], {
104-
type: 'application/json',
105-
});
106-
const url = URL.createObjectURL(blob);
107-
const a = document.createElement('a');
108-
a.href = url;
109-
a.download = `conversation_${conv.id}.json`;
110-
document.body.appendChild(a);
111-
a.click();
112-
document.body.removeChild(a);
113-
URL.revokeObjectURL(url);
114-
}}
115-
onRename={() => {
116-
if (isGenerating(conv.id)) {
117-
toast.error('Cannot rename conversation while generating');
118-
return;
119-
}
120-
const newName = window.prompt(
121-
'Enter new name for the conversation',
122-
conv.name
123-
);
124-
if (newName && newName.trim().length > 0) {
125-
StorageUtils.updateConversationName(conv.id, newName);
126-
}
127-
}}
128-
/>
81+
82+
{/* list of conversations */}
83+
{groupedConv.map((group) => (
84+
<div>
85+
{/* group name (by date) */}
86+
{group.title.length ? (
87+
<b className="block text-xs px-2 mb-2 mt-6">{group.title}</b>
88+
) : (
89+
<div className="h-2" />
90+
)}
91+
92+
{group.conversations.map((conv) => (
93+
<ConversationItem
94+
key={conv.id}
95+
conv={conv}
96+
isCurrConv={currConv?.id === conv.id}
97+
onSelect={() => {
98+
navigate(`/chat/${conv.id}`);
99+
}}
100+
onDelete={() => {
101+
if (isGenerating(conv.id)) {
102+
toast.error(
103+
'Cannot delete conversation while generating'
104+
);
105+
return;
106+
}
107+
if (
108+
window.confirm(
109+
'Are you sure to delete this conversation?'
110+
)
111+
) {
112+
toast.success('Conversation deleted');
113+
StorageUtils.remove(conv.id);
114+
navigate('/');
115+
}
116+
}}
117+
onDownload={() => {
118+
if (isGenerating(conv.id)) {
119+
toast.error(
120+
'Cannot download conversation while generating'
121+
);
122+
return;
123+
}
124+
const conversationJson = JSON.stringify(conv, null, 2);
125+
const blob = new Blob([conversationJson], {
126+
type: 'application/json',
127+
});
128+
const url = URL.createObjectURL(blob);
129+
const a = document.createElement('a');
130+
a.href = url;
131+
a.download = `conversation_${conv.id}.json`;
132+
document.body.appendChild(a);
133+
a.click();
134+
document.body.removeChild(a);
135+
URL.revokeObjectURL(url);
136+
}}
137+
onRename={() => {
138+
if (isGenerating(conv.id)) {
139+
toast.error(
140+
'Cannot rename conversation while generating'
141+
);
142+
return;
143+
}
144+
const newName = window.prompt(
145+
'Enter new name for the conversation',
146+
conv.name
147+
);
148+
if (newName && newName.trim().length > 0) {
149+
StorageUtils.updateConversationName(conv.id, newName);
150+
}
151+
}}
152+
/>
153+
))}
154+
</div>
129155
))}
130156
<div className="text-center text-xs opacity-40 mt-auto mx-4">
131157
Conversations are saved to browser's IndexedDB
@@ -154,7 +180,7 @@ function ConversationItem({
154180
return (
155181
<div
156182
className={classNames({
157-
'group flex flex-row btn btn-ghost justify-start items-center font-normal pr-2':
183+
'group flex flex-row btn btn-ghost justify-start items-center font-normal px-2 h-9':
158184
true,
159185
'btn-soft': isCurrConv,
160186
})}
@@ -206,3 +232,99 @@ function ConversationItem({
206232
</div>
207233
);
208234
}
235+
236+
// WARN: vibe code below
237+
238+
export interface GroupedConversations {
239+
title: string;
240+
conversations: Conversation[];
241+
}
242+
243+
// TODO @ngxson : add test for this function
244+
// Group conversations by date
245+
// - "Previous 7 Days"
246+
// - "Previous 30 Days"
247+
// - "Month Year" (e.g., "April 2023")
248+
export function groupConversationsByDate(
249+
conversations: Conversation[]
250+
): GroupedConversations[] {
251+
const now = new Date();
252+
const today = new Date(now.getFullYear(), now.getMonth(), now.getDate()); // Start of today
253+
254+
const sevenDaysAgo = new Date(today);
255+
sevenDaysAgo.setDate(today.getDate() - 7);
256+
257+
const thirtyDaysAgo = new Date(today);
258+
thirtyDaysAgo.setDate(today.getDate() - 30);
259+
260+
const groups: { [key: string]: Conversation[] } = {
261+
Today: [],
262+
'Previous 7 Days': [],
263+
'Previous 30 Days': [],
264+
};
265+
const monthlyGroups: { [key: string]: Conversation[] } = {}; // Key format: "Month Year" e.g., "April 2023"
266+
267+
// Sort conversations by lastModified date in descending order (newest first)
268+
// This helps when adding to groups, but the final output order of groups is fixed.
269+
const sortedConversations = [...conversations].sort(
270+
(a, b) => b.lastModified - a.lastModified
271+
);
272+
273+
for (const conv of sortedConversations) {
274+
const convDate = new Date(conv.lastModified);
275+
276+
if (convDate >= today) {
277+
groups['Today'].push(conv);
278+
} else if (convDate >= sevenDaysAgo) {
279+
groups['Previous 7 Days'].push(conv);
280+
} else if (convDate >= thirtyDaysAgo) {
281+
groups['Previous 30 Days'].push(conv);
282+
} else {
283+
const monthName = convDate.toLocaleString('default', { month: 'long' });
284+
const year = convDate.getFullYear();
285+
const monthYearKey = `${monthName} ${year}`;
286+
if (!monthlyGroups[monthYearKey]) {
287+
monthlyGroups[monthYearKey] = [];
288+
}
289+
monthlyGroups[monthYearKey].push(conv);
290+
}
291+
}
292+
293+
const result: GroupedConversations[] = [];
294+
295+
if (groups['Today'].length > 0) {
296+
result.push({
297+
title: '', // no title for Today
298+
conversations: groups['Today'],
299+
});
300+
}
301+
302+
if (groups['Previous 7 Days'].length > 0) {
303+
result.push({
304+
title: 'Previous 7 Days',
305+
conversations: groups['Previous 7 Days'],
306+
});
307+
}
308+
309+
if (groups['Previous 30 Days'].length > 0) {
310+
result.push({
311+
title: 'Previous 30 Days',
312+
conversations: groups['Previous 30 Days'],
313+
});
314+
}
315+
316+
// Sort monthly groups by date (most recent month first)
317+
const sortedMonthKeys = Object.keys(monthlyGroups).sort((a, b) => {
318+
const dateA = new Date(a); // "Month Year" can be parsed by Date constructor
319+
const dateB = new Date(b);
320+
return dateB.getTime() - dateA.getTime();
321+
});
322+
323+
for (const monthKey of sortedMonthKeys) {
324+
if (monthlyGroups[monthKey].length > 0) {
325+
result.push({ title: monthKey, conversations: monthlyGroups[monthKey] });
326+
}
327+
}
328+
329+
return result;
330+
}

0 commit comments

Comments
 (0)