Skip to content

Commit a07a3aa

Browse files
feat: paginate chat history (vercel#903)
1 parent 235b0ed commit a07a3aa

File tree

6 files changed

+349
-215
lines changed

6 files changed

+349
-215
lines changed

app/(chat)/api/history/route.ts

+28-5
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,37 @@
11
import { auth } from '@/app/(auth)/auth';
2+
import { NextRequest } from 'next/server';
23
import { getChatsByUserId } from '@/lib/db/queries';
34

4-
export async function GET() {
5+
export async function GET(request: NextRequest) {
6+
const { searchParams } = request.nextUrl;
7+
8+
const limit = parseInt(searchParams.get('limit') || '10');
9+
const startingAfter = searchParams.get('starting_after');
10+
const endingBefore = searchParams.get('ending_before');
11+
12+
if (startingAfter && endingBefore) {
13+
return Response.json(
14+
'Only one of starting_after or ending_before can be provided!',
15+
{ status: 400 },
16+
);
17+
}
18+
519
const session = await auth();
620

7-
if (!session || !session.user) {
21+
if (!session?.user?.id) {
822
return Response.json('Unauthorized!', { status: 401 });
923
}
1024

11-
// biome-ignore lint: Forbidden non-null assertion.
12-
const chats = await getChatsByUserId({ id: session.user.id! });
13-
return Response.json(chats);
25+
try {
26+
const chats = await getChatsByUserId({
27+
id: session.user.id,
28+
limit,
29+
startingAfter,
30+
endingBefore,
31+
});
32+
33+
return Response.json(chats);
34+
} catch (_) {
35+
return Response.json('Failed to fetch chats!', { status: 500 });
36+
}
1437
}

components/chat.tsx

+4-2
Original file line numberDiff line numberDiff line change
@@ -10,9 +10,11 @@ import { fetcher, generateUUID } from '@/lib/utils';
1010
import { Artifact } from './artifact';
1111
import { MultimodalInput } from './multimodal-input';
1212
import { Messages } from './messages';
13-
import { VisibilityType } from './visibility-selector';
13+
import type { VisibilityType } from './visibility-selector';
1414
import { useArtifactSelector } from '@/hooks/use-artifact';
1515
import { toast } from 'sonner';
16+
import { unstable_serialize } from 'swr/infinite';
17+
import { getChatHistoryPaginationKey } from './sidebar-history';
1618

1719
export function Chat({
1820
id,
@@ -47,7 +49,7 @@ export function Chat({
4749
sendExtraMessageFields: true,
4850
generateId: generateUUID,
4951
onFinish: () => {
50-
mutate('/api/history');
52+
mutate(unstable_serialize(getChatHistoryPaginationKey));
5153
},
5254
onError: () => {
5355
toast.error('An error occured, please try again!');

components/sidebar-history-item.tsx

+118
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,118 @@
1+
import { Chat } from '@/lib/db/schema';
2+
import {
3+
SidebarMenuAction,
4+
SidebarMenuButton,
5+
SidebarMenuItem,
6+
} from './ui/sidebar';
7+
import Link from 'next/link';
8+
import {
9+
DropdownMenu,
10+
DropdownMenuContent,
11+
DropdownMenuItem,
12+
DropdownMenuPortal,
13+
DropdownMenuSub,
14+
DropdownMenuSubContent,
15+
DropdownMenuSubTrigger,
16+
DropdownMenuTrigger,
17+
} from './ui/dropdown-menu';
18+
import {
19+
CheckCircleFillIcon,
20+
GlobeIcon,
21+
LockIcon,
22+
MoreHorizontalIcon,
23+
ShareIcon,
24+
TrashIcon,
25+
} from './icons';
26+
import { memo } from 'react';
27+
import { useChatVisibility } from '@/hooks/use-chat-visibility';
28+
29+
const PureChatItem = ({
30+
chat,
31+
isActive,
32+
onDelete,
33+
setOpenMobile,
34+
}: {
35+
chat: Chat;
36+
isActive: boolean;
37+
onDelete: (chatId: string) => void;
38+
setOpenMobile: (open: boolean) => void;
39+
}) => {
40+
const { visibilityType, setVisibilityType } = useChatVisibility({
41+
chatId: chat.id,
42+
initialVisibility: chat.visibility,
43+
});
44+
45+
return (
46+
<SidebarMenuItem>
47+
<SidebarMenuButton asChild isActive={isActive}>
48+
<Link href={`/chat/${chat.id}`} onClick={() => setOpenMobile(false)}>
49+
<span>{chat.title}</span>
50+
</Link>
51+
</SidebarMenuButton>
52+
53+
<DropdownMenu modal={true}>
54+
<DropdownMenuTrigger asChild>
55+
<SidebarMenuAction
56+
className="data-[state=open]:bg-sidebar-accent data-[state=open]:text-sidebar-accent-foreground mr-0.5"
57+
showOnHover={!isActive}
58+
>
59+
<MoreHorizontalIcon />
60+
<span className="sr-only">More</span>
61+
</SidebarMenuAction>
62+
</DropdownMenuTrigger>
63+
64+
<DropdownMenuContent side="bottom" align="end">
65+
<DropdownMenuSub>
66+
<DropdownMenuSubTrigger className="cursor-pointer">
67+
<ShareIcon />
68+
<span>Share</span>
69+
</DropdownMenuSubTrigger>
70+
<DropdownMenuPortal>
71+
<DropdownMenuSubContent>
72+
<DropdownMenuItem
73+
className="cursor-pointer flex-row justify-between"
74+
onClick={() => {
75+
setVisibilityType('private');
76+
}}
77+
>
78+
<div className="flex flex-row gap-2 items-center">
79+
<LockIcon size={12} />
80+
<span>Private</span>
81+
</div>
82+
{visibilityType === 'private' ? (
83+
<CheckCircleFillIcon />
84+
) : null}
85+
</DropdownMenuItem>
86+
<DropdownMenuItem
87+
className="cursor-pointer flex-row justify-between"
88+
onClick={() => {
89+
setVisibilityType('public');
90+
}}
91+
>
92+
<div className="flex flex-row gap-2 items-center">
93+
<GlobeIcon />
94+
<span>Public</span>
95+
</div>
96+
{visibilityType === 'public' ? <CheckCircleFillIcon /> : null}
97+
</DropdownMenuItem>
98+
</DropdownMenuSubContent>
99+
</DropdownMenuPortal>
100+
</DropdownMenuSub>
101+
102+
<DropdownMenuItem
103+
className="cursor-pointer text-destructive focus:bg-destructive/15 focus:text-destructive dark:text-red-500"
104+
onSelect={() => onDelete(chat.id)}
105+
>
106+
<TrashIcon />
107+
<span>Delete</span>
108+
</DropdownMenuItem>
109+
</DropdownMenuContent>
110+
</DropdownMenu>
111+
</SidebarMenuItem>
112+
);
113+
};
114+
115+
export const ChatItem = memo(PureChatItem, (prevProps, nextProps) => {
116+
if (prevProps.isActive !== nextProps.isActive) return false;
117+
return true;
118+
});

0 commit comments

Comments
 (0)