Skip to content

Commit cea5ad2

Browse files
committed
group chat history by date, add search bar
1 parent 4149015 commit cea5ad2

File tree

4 files changed

+324
-57
lines changed

4 files changed

+324
-57
lines changed
Lines changed: 123 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,123 @@
1+
/* Copyright 2024 Marimo. All rights reserved. */
2+
3+
import { afterAll, beforeAll, describe, expect, it } from "vitest";
4+
import { groupChatsByDate } from "../components/chat/chat-history-utils";
5+
import type { Chat } from "../core/ai/state";
6+
7+
// Mock current time for consistent testing
8+
const mockNow = new Date("2024-01-15T12:00:00Z").getTime();
9+
10+
// Mock Date.now to return our fixed time
11+
const originalDateNow = Date.now;
12+
beforeAll(() => {
13+
Date.now = () => mockNow;
14+
});
15+
16+
afterAll(() => {
17+
Date.now = originalDateNow;
18+
});
19+
20+
describe("groupChatsByDate", () => {
21+
const createMockChat = (daysAgo: number, title: string): Chat => ({
22+
id: `chat-${daysAgo}` as Chat["id"],
23+
title,
24+
messages: [],
25+
createdAt: mockNow - daysAgo * 24 * 60 * 60 * 1000,
26+
updatedAt: mockNow - daysAgo * 24 * 60 * 60 * 1000,
27+
});
28+
29+
it("should group chats correctly by date periods", () => {
30+
const chats: Chat[] = [
31+
createMockChat(0, "Today chat"),
32+
createMockChat(1, "Yesterday chat"),
33+
createMockChat(2, "2 days ago chat"),
34+
createMockChat(3, "3 days ago chat"),
35+
createMockChat(5, "5 days ago chat"), // Should go to "This week"
36+
createMockChat(10, "10 days ago chat"), // Should go to "This month"
37+
createMockChat(40, "40 days ago chat"), // Should go to "Older"
38+
];
39+
40+
const result = groupChatsByDate(chats);
41+
42+
// Should have 7 groups
43+
expect(result).toHaveLength(chats.length);
44+
45+
// Check Today group
46+
const todayGroup = result.find((g) => g.label === "Today");
47+
expect(todayGroup?.chats).toHaveLength(1);
48+
expect(todayGroup?.chats[0].title).toBe("Today chat");
49+
50+
// Check Yesterday group
51+
const yesterdayGroup = result.find((g) => g.label === "Yesterday");
52+
expect(yesterdayGroup?.chats).toHaveLength(1);
53+
expect(yesterdayGroup?.chats[0].title).toBe("Yesterday chat");
54+
55+
// Check 2d ago group
56+
const twoDaysGroup = result.find((g) => g.label === "2d ago");
57+
expect(twoDaysGroup?.chats).toHaveLength(1);
58+
expect(twoDaysGroup?.chats[0].title).toBe("2 days ago chat");
59+
60+
// Check 3d ago group
61+
const threeDaysGroup = result.find((g) => g.label === "3d ago");
62+
expect(threeDaysGroup?.chats).toHaveLength(1);
63+
expect(threeDaysGroup?.chats[0].title).toBe("3 days ago chat");
64+
65+
// Check This week group (should include 5)
66+
const thisWeekGroup = result.find((g) => g.label === "This week");
67+
expect(thisWeekGroup?.chats).toHaveLength(1);
68+
expect(thisWeekGroup?.chats.map((c) => c.title)).toContain(
69+
"5 days ago chat",
70+
);
71+
72+
// Check This month group (should include 40 days ago)
73+
const thisMonthGroup = result.find((g) => g.label === "This month");
74+
expect(thisMonthGroup?.chats).toHaveLength(1);
75+
expect(thisMonthGroup?.chats[0].title).toBe("10 days ago chat");
76+
77+
// Check Older group (should include 40 days ago)
78+
const olderGroup = result.find((g) => g.label === "Older");
79+
expect(olderGroup?.chats).toHaveLength(1);
80+
expect(olderGroup?.chats[0].title).toBe("40 days ago chat");
81+
});
82+
83+
it("should include all chats in some group", () => {
84+
const chats: Chat[] = [
85+
createMockChat(0, "Today"),
86+
createMockChat(1, "Yesterday"),
87+
createMockChat(2, "2 days ago"),
88+
createMockChat(3, "3 days ago"),
89+
createMockChat(5, "5 days ago"),
90+
createMockChat(10, "10 days ago"),
91+
createMockChat(20, "20 days ago"),
92+
createMockChat(40, "40 days ago"),
93+
createMockChat(100, "100 days ago"),
94+
];
95+
96+
const result = groupChatsByDate(chats);
97+
98+
// Count total chats across all groups
99+
const totalChatsInGroups = result.reduce(
100+
(sum, group) => sum + group.chats.length,
101+
0,
102+
);
103+
expect(totalChatsInGroups).toBe(chats.length);
104+
});
105+
106+
it("should handle empty chat list", () => {
107+
const result = groupChatsByDate([]);
108+
expect(result).toHaveLength(0);
109+
});
110+
111+
it("should filter out empty groups", () => {
112+
const chats: Chat[] = [
113+
createMockChat(0, "Today chat"),
114+
createMockChat(40, "Old chat"),
115+
];
116+
117+
const result = groupChatsByDate(chats);
118+
119+
// Should only have Today and Older groups, not the empty ones in between
120+
expect(result).toHaveLength(2);
121+
expect(result.map((g) => g.label)).toEqual(["Today", "Older"]);
122+
});
123+
});
Lines changed: 125 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,125 @@
1+
/* Copyright 2024 Marimo. All rights reserved. */
2+
3+
import { useAtomValue } from "jotai";
4+
import { BotMessageSquareIcon, ClockIcon, SearchIcon } from "lucide-react";
5+
import { useMemo, useState } from "react";
6+
import { useLocale } from "react-aria";
7+
import { Button } from "@/components/ui/button";
8+
import { Input } from "@/components/ui/input";
9+
import {
10+
Popover,
11+
PopoverContent,
12+
PopoverTrigger,
13+
} from "@/components/ui/popover";
14+
import { ScrollArea } from "@/components/ui/scroll-area";
15+
import { Tooltip } from "@/components/ui/tooltip";
16+
import { type ChatId, chatStateAtom } from "@/core/ai/state";
17+
import { cn } from "@/utils/cn";
18+
import { timeAgo } from "@/utils/dates";
19+
import { PanelEmptyState } from "../editor/chrome/panels/empty-state";
20+
import { groupChatsByDate } from "./chat-history-utils";
21+
22+
interface ChatHistoryPopoverProps {
23+
activeChatId: ChatId | undefined;
24+
setActiveChat: (id: ChatId | null) => void;
25+
}
26+
27+
export const ChatHistoryPopover: React.FC<ChatHistoryPopoverProps> = ({
28+
activeChatId,
29+
setActiveChat,
30+
}) => {
31+
const chatState = useAtomValue(chatStateAtom);
32+
const { locale } = useLocale();
33+
const [searchQuery, setSearchQuery] = useState("");
34+
35+
const chats = useMemo(() => {
36+
return [...chatState.chats.values()].sort(
37+
(a, b) => b.updatedAt - a.updatedAt,
38+
);
39+
}, [chatState.chats]);
40+
41+
const filteredChats = useMemo(() => {
42+
if (!searchQuery.trim()) {
43+
return chats;
44+
}
45+
return chats.filter((chat) =>
46+
chat.title.toLowerCase().includes(searchQuery.toLowerCase()),
47+
);
48+
}, [chats, searchQuery]);
49+
50+
const groupedChats = useMemo(() => {
51+
return groupChatsByDate(filteredChats);
52+
}, [filteredChats]);
53+
54+
return (
55+
<Popover>
56+
<Tooltip content="Previous chats">
57+
<PopoverTrigger asChild={true}>
58+
<Button variant="text" size="icon">
59+
<ClockIcon className="h-4 w-4" />
60+
</Button>
61+
</PopoverTrigger>
62+
</Tooltip>
63+
<PopoverContent className="w-[480px] p-0" align="start" side="right">
64+
<div className="pt-3 px-3 w-full">
65+
<Input
66+
placeholder="Search chat history..."
67+
value={searchQuery}
68+
onChange={(e) => setSearchQuery(e.target.value)}
69+
className="text-xs"
70+
/>
71+
</div>
72+
<ScrollArea className="h-[450px] p-2">
73+
<div className="space-y-3">
74+
{chats.length === 0 && (
75+
<PanelEmptyState
76+
title="No chats yet"
77+
description="Start a new chat to get started"
78+
icon={<BotMessageSquareIcon />}
79+
/>
80+
)}
81+
{filteredChats.length === 0 && searchQuery && chats.length > 0 && (
82+
<PanelEmptyState
83+
title="No chats found"
84+
description={`No chats match "${searchQuery}"`}
85+
icon={<SearchIcon />}
86+
/>
87+
)}
88+
{groupedChats.map((group, idx) => (
89+
<div key={group.label} className="space-y-2">
90+
<div className="text-xs px-1 text-muted-foreground/60">
91+
{group.label}
92+
</div>
93+
<div>
94+
{group.chats.map((chat) => (
95+
<button
96+
key={chat.id}
97+
className={cn(
98+
"w-full p-1 rounded-md cursor-pointer text-left flex items-center justify-between",
99+
chat.id === activeChatId && "bg-accent",
100+
chat.id !== activeChatId && "hover:bg-muted/20",
101+
)}
102+
onClick={() => {
103+
setActiveChat(chat.id);
104+
}}
105+
type="button"
106+
>
107+
<div className="flex-1 min-w-0">
108+
<div className="text-sm truncate">{chat.title}</div>
109+
</div>
110+
<div className="text-xs text-muted-foreground/60 ml-2 flex-shrink-0">
111+
{timeAgo(chat.updatedAt, locale)}
112+
</div>
113+
</button>
114+
))}
115+
</div>
116+
{/* If last group, don't show a divider */}
117+
{idx !== groupedChats.length - 1 && <hr />}
118+
</div>
119+
))}
120+
</div>
121+
</ScrollArea>
122+
</PopoverContent>
123+
</Popover>
124+
);
125+
};
Lines changed: 69 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,69 @@
1+
/* Copyright 2024 Marimo. All rights reserved. */
2+
3+
import type { Chat } from "@/core/ai/state";
4+
5+
const DATE_GROUP_CONFIG = [
6+
{ label: "Today", days: 0 },
7+
{ label: "Yesterday", days: 1 },
8+
{ label: "2d ago", days: 2 },
9+
{ label: "3d ago", days: 3 },
10+
{ label: "This week", days: 7 },
11+
{ label: "This month", days: 30 },
12+
] as const;
13+
14+
interface DateGroup {
15+
label: string;
16+
days: number;
17+
chats: Chat[];
18+
}
19+
20+
// Utility function to group chats by date periods
21+
export const groupChatsByDate = (chats: Chat[]): DateGroup[] => {
22+
const now = Date.now();
23+
const oneDayMs = 24 * 60 * 60 * 1000;
24+
25+
// Initialize groups with empty chat arrays
26+
const groups: DateGroup[] = DATE_GROUP_CONFIG.map((config) => ({
27+
...config,
28+
chats: [],
29+
}));
30+
31+
const olderGroup: DateGroup = {
32+
label: "Older",
33+
days: Infinity,
34+
chats: [],
35+
};
36+
37+
// Helper function to determine which group a chat belongs to
38+
const getGroupForChat = (daysDiff: number): DateGroup => {
39+
// Use switch for exact day matches, then handle ranges
40+
switch (daysDiff) {
41+
case 0:
42+
return groups[0]; // Today
43+
case 1:
44+
return groups[1]; // Yesterday
45+
case 2:
46+
return groups[2]; // 2d ago
47+
case 3:
48+
return groups[3]; // 3d ago
49+
default:
50+
// Handle range-based grouping for older chats
51+
if (daysDiff >= 4 && daysDiff <= 7) {
52+
return groups[4]; // This week
53+
} else if (daysDiff >= 8 && daysDiff <= 30) {
54+
return groups[5]; // This month
55+
}
56+
// Everything else goes to Older
57+
return olderGroup;
58+
}
59+
};
60+
61+
for (const chat of chats) {
62+
const daysDiff = Math.floor((now - chat.updatedAt) / oneDayMs);
63+
const targetGroup = getGroupForChat(daysDiff);
64+
targetGroup.chats.push(chat);
65+
}
66+
67+
// Return only non-empty groups
68+
return [...groups, olderGroup].filter((group) => group.chats.length > 0);
69+
};

0 commit comments

Comments
 (0)