diff --git a/frontend/src/__tests__/chat-history.test.ts b/frontend/src/__tests__/chat-history.test.ts new file mode 100644 index 00000000000..86212e82cbc --- /dev/null +++ b/frontend/src/__tests__/chat-history.test.ts @@ -0,0 +1,123 @@ +/* Copyright 2024 Marimo. All rights reserved. */ + +import { afterAll, beforeAll, describe, expect, it } from "vitest"; +import { groupChatsByDate } from "../components/chat/chat-history-utils"; +import type { Chat } from "../core/ai/state"; + +// Mock current time for consistent testing +const mockNow = new Date("2024-01-15T12:00:00Z").getTime(); + +// Mock Date.now to return our fixed time +const originalDateNow = Date.now; +beforeAll(() => { + Date.now = () => mockNow; +}); + +afterAll(() => { + Date.now = originalDateNow; +}); + +describe("groupChatsByDate", () => { + const createMockChat = (daysAgo: number, title: string): Chat => ({ + id: `chat-${daysAgo}` as Chat["id"], + title, + messages: [], + createdAt: mockNow - daysAgo * 24 * 60 * 60 * 1000, + updatedAt: mockNow - daysAgo * 24 * 60 * 60 * 1000, + }); + + it("should group chats correctly by date periods", () => { + const chats: Chat[] = [ + createMockChat(0, "Today chat"), + createMockChat(1, "Yesterday chat"), + createMockChat(2, "2 days ago chat"), + createMockChat(3, "3 days ago chat"), + createMockChat(5, "5 days ago chat"), // Should go to "This week" + createMockChat(10, "10 days ago chat"), // Should go to "This month" + createMockChat(40, "40 days ago chat"), // Should go to "Older" + ]; + + const result = groupChatsByDate(chats); + + // Should have 7 groups + expect(result).toHaveLength(chats.length); + + // Check Today group + const todayGroup = result.find((g) => g.label === "Today"); + expect(todayGroup?.chats).toHaveLength(1); + expect(todayGroup?.chats[0].title).toBe("Today chat"); + + // Check Yesterday group + const yesterdayGroup = result.find((g) => g.label === "Yesterday"); + expect(yesterdayGroup?.chats).toHaveLength(1); + expect(yesterdayGroup?.chats[0].title).toBe("Yesterday chat"); + + // Check 2d ago group + const twoDaysGroup = result.find((g) => g.label === "2d ago"); + expect(twoDaysGroup?.chats).toHaveLength(1); + expect(twoDaysGroup?.chats[0].title).toBe("2 days ago chat"); + + // Check 3d ago group + const threeDaysGroup = result.find((g) => g.label === "3d ago"); + expect(threeDaysGroup?.chats).toHaveLength(1); + expect(threeDaysGroup?.chats[0].title).toBe("3 days ago chat"); + + // Check This week group (should include 5) + const thisWeekGroup = result.find((g) => g.label === "This week"); + expect(thisWeekGroup?.chats).toHaveLength(1); + expect(thisWeekGroup?.chats.map((c) => c.title)).toContain( + "5 days ago chat", + ); + + // Check This month group (should include 40 days ago) + const thisMonthGroup = result.find((g) => g.label === "This month"); + expect(thisMonthGroup?.chats).toHaveLength(1); + expect(thisMonthGroup?.chats[0].title).toBe("10 days ago chat"); + + // Check Older group (should include 40 days ago) + const olderGroup = result.find((g) => g.label === "Older"); + expect(olderGroup?.chats).toHaveLength(1); + expect(olderGroup?.chats[0].title).toBe("40 days ago chat"); + }); + + it("should include all chats in some group", () => { + const chats: Chat[] = [ + createMockChat(0, "Today"), + createMockChat(1, "Yesterday"), + createMockChat(2, "2 days ago"), + createMockChat(3, "3 days ago"), + createMockChat(5, "5 days ago"), + createMockChat(10, "10 days ago"), + createMockChat(20, "20 days ago"), + createMockChat(40, "40 days ago"), + createMockChat(100, "100 days ago"), + ]; + + const result = groupChatsByDate(chats); + + // Count total chats across all groups + const totalChatsInGroups = result.reduce( + (sum, group) => sum + group.chats.length, + 0, + ); + expect(totalChatsInGroups).toBe(chats.length); + }); + + it("should handle empty chat list", () => { + const result = groupChatsByDate([]); + expect(result).toHaveLength(0); + }); + + it("should filter out empty groups", () => { + const chats: Chat[] = [ + createMockChat(0, "Today chat"), + createMockChat(40, "Old chat"), + ]; + + const result = groupChatsByDate(chats); + + // Should only have Today and Older groups, not the empty ones in between + expect(result).toHaveLength(2); + expect(result.map((g) => g.label)).toEqual(["Today", "Older"]); + }); +}); diff --git a/frontend/src/components/chat/chat-history-popover.tsx b/frontend/src/components/chat/chat-history-popover.tsx new file mode 100644 index 00000000000..8ec57c90fbf --- /dev/null +++ b/frontend/src/components/chat/chat-history-popover.tsx @@ -0,0 +1,125 @@ +/* Copyright 2024 Marimo. All rights reserved. */ + +import { useAtomValue } from "jotai"; +import { BotMessageSquareIcon, ClockIcon, SearchIcon } from "lucide-react"; +import { useMemo, useState } from "react"; +import { useLocale } from "react-aria"; +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { + Popover, + PopoverContent, + PopoverTrigger, +} from "@/components/ui/popover"; +import { ScrollArea } from "@/components/ui/scroll-area"; +import { Tooltip } from "@/components/ui/tooltip"; +import { type ChatId, chatStateAtom } from "@/core/ai/state"; +import { cn } from "@/utils/cn"; +import { timeAgo } from "@/utils/dates"; +import { PanelEmptyState } from "../editor/chrome/panels/empty-state"; +import { groupChatsByDate } from "./chat-history-utils"; + +interface ChatHistoryPopoverProps { + activeChatId: ChatId | undefined; + setActiveChat: (id: ChatId | null) => void; +} + +export const ChatHistoryPopover: React.FC = ({ + activeChatId, + setActiveChat, +}) => { + const chatState = useAtomValue(chatStateAtom); + const { locale } = useLocale(); + const [searchQuery, setSearchQuery] = useState(""); + + const chats = useMemo(() => { + return [...chatState.chats.values()].sort( + (a, b) => b.updatedAt - a.updatedAt, + ); + }, [chatState.chats]); + + const filteredChats = useMemo(() => { + if (!searchQuery.trim()) { + return chats; + } + return chats.filter((chat) => + chat.title.toLowerCase().includes(searchQuery.toLowerCase()), + ); + }, [chats, searchQuery]); + + const groupedChats = useMemo(() => { + return groupChatsByDate(filteredChats); + }, [filteredChats]); + + return ( + + + + + + + +
+ setSearchQuery(e.target.value)} + className="text-xs" + /> +
+ +
+ {chats.length === 0 && ( + } + /> + )} + {filteredChats.length === 0 && searchQuery && chats.length > 0 && ( + } + /> + )} + {groupedChats.map((group, idx) => ( +
+
+ {group.label} +
+
+ {group.chats.map((chat) => ( + + ))} +
+ {/* If last group, don't show a divider */} + {idx !== groupedChats.length - 1 &&
} +
+ ))} +
+
+
+
+ ); +}; diff --git a/frontend/src/components/chat/chat-history-utils.ts b/frontend/src/components/chat/chat-history-utils.ts new file mode 100644 index 00000000000..e8ddbb9d1a9 --- /dev/null +++ b/frontend/src/components/chat/chat-history-utils.ts @@ -0,0 +1,69 @@ +/* Copyright 2024 Marimo. All rights reserved. */ + +import type { Chat } from "@/core/ai/state"; + +const DATE_GROUP_CONFIG = [ + { label: "Today", days: 0 }, + { label: "Yesterday", days: 1 }, + { label: "2d ago", days: 2 }, + { label: "3d ago", days: 3 }, + { label: "This week", days: 7 }, + { label: "This month", days: 30 }, +] as const; + +interface DateGroup { + label: string; + days: number; + chats: Chat[]; +} + +// Utility function to group chats by date periods +export const groupChatsByDate = (chats: Chat[]): DateGroup[] => { + const now = Date.now(); + const oneDayMs = 24 * 60 * 60 * 1000; + + // Initialize groups with empty chat arrays + const groups: DateGroup[] = DATE_GROUP_CONFIG.map((config) => ({ + ...config, + chats: [], + })); + + const olderGroup: DateGroup = { + label: "Older", + days: Infinity, + chats: [], + }; + + // Helper function to determine which group a chat belongs to + const getGroupForChat = (daysDiff: number): DateGroup => { + // Use switch for exact day matches, then handle ranges + switch (daysDiff) { + case 0: + return groups[0]; // Today + case 1: + return groups[1]; // Yesterday + case 2: + return groups[2]; // 2d ago + case 3: + return groups[3]; // 3d ago + default: + // Handle range-based grouping for older chats + if (daysDiff >= 4 && daysDiff <= 7) { + return groups[4]; // This week + } else if (daysDiff >= 8 && daysDiff <= 30) { + return groups[5]; // This month + } + // Everything else goes to Older + return olderGroup; + } + }; + + for (const chat of chats) { + const daysDiff = Math.floor((now - chat.updatedAt) / oneDayMs); + const targetGroup = getGroupForChat(daysDiff); + targetGroup.chats.push(chat); + } + + // Return only non-empty groups + return [...groups, olderGroup].filter((group) => group.chats.length > 0); +}; diff --git a/frontend/src/components/chat/chat-panel.tsx b/frontend/src/components/chat/chat-panel.tsx index dce1e4263b8..6d223f12d6d 100644 --- a/frontend/src/components/chat/chat-panel.tsx +++ b/frontend/src/components/chat/chat-panel.tsx @@ -9,7 +9,6 @@ import { useAtom, useAtomValue, useSetAtom } from "jotai"; import { AtSignIcon, BotMessageSquareIcon, - ClockIcon, Loader2, PaperclipIcon, PlusIcon, @@ -17,16 +16,9 @@ import { SettingsIcon, SquareIcon, } from "lucide-react"; -import { memo, useEffect, useMemo, useRef, useState } from "react"; -import { useLocale } from "react-aria"; +import { memo, useEffect, useRef, useState } from "react"; import useEvent from "react-use-event-hook"; import { Button } from "@/components/ui/button"; -import { - Popover, - PopoverContent, - PopoverTrigger, -} from "@/components/ui/popover"; -import { ScrollArea } from "@/components/ui/scroll-area"; import { Select, SelectContent, @@ -52,8 +44,8 @@ import { useRequestClient } from "@/core/network/requests"; import { useRuntimeManager } from "@/core/runtime/config"; import { ErrorBanner } from "@/plugins/impl/common/error-banner"; import { cn } from "@/utils/cn"; -import { timeAgo } from "@/utils/dates"; import { Logger } from "@/utils/Logger"; + import { AIModelDropdown } from "../ai/ai-model-dropdown"; import { useOpenSettingsToTab } from "../app-config/state"; import { PromptInput } from "../editor/ai/add-cell-with-ai"; @@ -68,6 +60,7 @@ import { Input } from "../ui/input"; import { Tooltip, TooltipProvider } from "../ui/tooltip"; import { toast } from "../ui/use-toast"; import { AttachmentRenderer, FileAttachmentPill } from "./chat-components"; +import { ChatHistoryPopover } from "./chat-history-popover"; import { buildCompletionRequestBody, convertToFileUIPart, @@ -105,13 +98,6 @@ const ChatHeader: React.FC = ({ setActiveChat, }) => { const { handleClick } = useOpenSettingsToTab(); - const chatState = useAtomValue(chatStateAtom); - const { locale } = useLocale(); - const chats = useMemo(() => { - return [...chatState.chats.values()].sort( - (a, b) => b.updatedAt - a.updatedAt, - ); - }, [chatState.chats]); return (
@@ -132,46 +118,10 @@ const ChatHeader: React.FC = ({ - - - - - - - - -
- {chats.length === 0 && ( - } - /> - )} - {chats.map((chat) => ( - - ))} -
-
-
-
+
);