Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
123 changes: 123 additions & 0 deletions frontend/src/__tests__/chat-history.test.ts
Original file line number Diff line number Diff line change
@@ -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"]);
});
});
125 changes: 125 additions & 0 deletions frontend/src/components/chat/chat-history-popover.tsx
Original file line number Diff line number Diff line change
@@ -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<ChatHistoryPopoverProps> = ({
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 (
<Popover>
<Tooltip content="Previous chats">
<PopoverTrigger asChild={true}>
<Button variant="text" size="icon">
<ClockIcon className="h-4 w-4" />
</Button>
</PopoverTrigger>
</Tooltip>
<PopoverContent className="w-[480px] p-0" align="start" side="right">
<div className="pt-3 px-3 w-full">
<Input
placeholder="Search chat history..."
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
className="text-xs"
/>
</div>
<ScrollArea className="h-[450px] p-2">
<div className="space-y-3">
{chats.length === 0 && (
<PanelEmptyState
title="No chats yet"
description="Start a new chat to get started"
icon={<BotMessageSquareIcon />}
/>
)}
{filteredChats.length === 0 && searchQuery && chats.length > 0 && (
<PanelEmptyState
title="No chats found"
description={`No chats match "${searchQuery}"`}
icon={<SearchIcon />}
/>
)}
{groupedChats.map((group, idx) => (
<div key={group.label} className="space-y-2">
<div className="text-xs px-1 text-muted-foreground/60">
{group.label}
</div>
<div>
{group.chats.map((chat) => (
<button
key={chat.id}
className={cn(
"w-full p-1 rounded-md cursor-pointer text-left flex items-center justify-between",
chat.id === activeChatId && "bg-accent",
chat.id !== activeChatId && "hover:bg-muted/20",
)}
onClick={() => {
setActiveChat(chat.id);
}}
type="button"
>
<div className="flex-1 min-w-0">
<div className="text-sm truncate">{chat.title}</div>
</div>
<div className="text-xs text-muted-foreground/60 ml-2 flex-shrink-0">
{timeAgo(chat.updatedAt, locale)}
</div>
</button>
))}
</div>
{/* If last group, don't show a divider */}
{idx !== groupedChats.length - 1 && <hr />}
</div>
))}
</div>
</ScrollArea>
</PopoverContent>
</Popover>
);
};
69 changes: 69 additions & 0 deletions frontend/src/components/chat/chat-history-utils.ts
Original file line number Diff line number Diff line change
@@ -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);
};
Loading
Loading