Skip to content
104 changes: 93 additions & 11 deletions echo/frontend/src/components/chat/ChatAccordion.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,11 @@
import { t } from "@lingui/core/macro";
import { Trans } from "@lingui/react/macro";
import { useDeleteChatMutation, useProjectChats, useUpdateChatMutation } from "./hooks";
import {
useDeleteChatMutation,
useInfiniteProjectChats,
useProjectChatsCount,
useUpdateChatMutation,
} from "./hooks";
import {
Accordion,
ActionIcon,
Expand All @@ -10,12 +15,17 @@ import {
Stack,
Text,
Title,
Center,
Loader,
} from "@mantine/core";
import { useParams } from "react-router-dom";
import { IconDotsVertical, IconPencil, IconTrash } from "@tabler/icons-react";
import { formatRelative } from "date-fns";
import { NavigationButton } from "../common/NavigationButton";
import { useI18nNavigate } from "@/hooks/useI18nNavigate";
import { Suspense, useEffect } from "react";
import { ChatSkeleton } from "./ChatSkeleton";
import { useInView } from "react-intersection-observer";

const ChatAccordionItemMenu = ({ chat }: { chat: Partial<ProjectChat> }) => {
const deleteChatMutation = useDeleteChatMutation();
Expand Down Expand Up @@ -53,7 +63,7 @@ const ChatAccordionItemMenu = ({ chat }: { chat: Partial<ProjectChat> }) => {
}
}}
>
<Trans>Rename</Trans>
<Trans id="project.sidebar.chat.rename">Rename</Trans>
</Menu.Item>
<Menu.Item
leftSection={<IconTrash />}
Expand All @@ -66,7 +76,7 @@ const ChatAccordionItemMenu = ({ chat }: { chat: Partial<ProjectChat> }) => {
navigate(`/projects/${chat.project_id}/overview`);
}}
>
<Trans>Delete</Trans>
<Trans id="project.sidebar.chat.delete">Delete</Trans>
</Menu.Item>
</Stack>
</Menu.Dropdown>
Expand All @@ -75,10 +85,44 @@ const ChatAccordionItemMenu = ({ chat }: { chat: Partial<ProjectChat> }) => {
};

// Chat Accordion
export const ChatAccordion = ({ projectId }: { projectId: string }) => {
export const ChatAccordionMain = ({ projectId }: { projectId: string }) => {
const { chatId: activeChatId } = useParams();
const { ref: loadMoreRef, inView } = useInView();

const chatsQuery = useInfiniteProjectChats(
projectId,
{
filter: {
project_id: {
_eq: projectId,
},
_or: [
// @ts-ignore
...(activeChatId
? [
{
id: {
_eq: activeChatId,
},
},
]
: []),
// @ts-ignore
{
"count(project_chat_messages)": {
_gt: 0,
},
},
],
},
},
{
initialLimit: 15,
},
);

const chatsQuery = useProjectChats(projectId, {
// Get total count of chats for display
const chatsCountQuery = useProjectChatsCount(projectId, {
filter: {
project_id: {
_eq: projectId,
Expand All @@ -104,37 +148,53 @@ export const ChatAccordion = ({ projectId }: { projectId: string }) => {
},
});

// Load more chats when user scrolls to bottom
useEffect(() => {
if (inView && chatsQuery.hasNextPage && !chatsQuery.isFetchingNextPage) {
chatsQuery.fetchNextPage();
}
}, [
inView,
chatsQuery.hasNextPage,
chatsQuery.isFetchingNextPage,
chatsQuery.fetchNextPage,
]);

// Flatten all chats from all pages
const allChats = chatsQuery.data?.pages.flatMap((page) => page.chats) ?? [];
const totalChats = Number(chatsCountQuery.data) ?? 0;

return (
<Accordion.Item value="chat">
<Accordion.Control>
<Group justify="space-between">
<Title order={3}>
<span className="min-w-[48px] pr-2 font-normal text-gray-500">
{chatsQuery.data?.length ?? 0}
{totalChats}
</span>
<Trans>Chats</Trans>
<Trans id="project.sidebar.chat.title">Chats</Trans>
</Title>
</Group>
</Accordion.Control>

<Accordion.Panel>
<Stack gap="xs">
<LoadingOverlay visible={chatsQuery.isLoading} />
{chatsQuery.data?.length === 0 && (
{totalChats === 0 && (
<Text size="sm">
<Trans>
<Trans id="project.sidebar.chat.empty.description">
No chats found. Start a chat using the "Ask" button.
</Trans>
</Text>
)}
{chatsQuery.data?.map((item) => (
{allChats.map((item, index) => (
<NavigationButton
key={item.id}
to={`/projects/${projectId}/chats/${item.id}`}
active={item.id === activeChatId}
rightSection={
<ChatAccordionItemMenu chat={item as ProjectChat} />
}
ref={index === allChats.length - 1 ? loadMoreRef : undefined}
>
<Text size="xs">
{item.name
Expand All @@ -146,8 +206,30 @@ export const ChatAccordion = ({ projectId }: { projectId: string }) => {
</Text>
</NavigationButton>
))}
{chatsQuery.isFetchingNextPage && (
<Center py="md">
<Loader size="sm" />
</Center>
)}
{!chatsQuery.hasNextPage && allChats.length > 0 && (
<Center py="md">
<Text size="xs" c="dimmed" ta="center" fs="italic">
<Trans id="project.sidebar.chat.end.description">
End of list • All {totalChats} chats loaded
</Trans>
</Text>
</Center>
)}
</Stack>
</Accordion.Panel>
</Accordion.Item>
);
};

export const ChatAccordion = ({ projectId }: { projectId: string }) => {
return (
<Suspense fallback={<ChatSkeleton />}>
<ChatAccordionMain projectId={projectId} />
</Suspense>
);
};
Original file line number Diff line number Diff line change
Expand Up @@ -3,22 +3,20 @@ import { Accordion, Group, Skeleton, Stack, Title } from "@mantine/core";
import { BaseSkeleton } from "../common/BaseSkeleton";
import { LoadingSpinner } from "../common/LoadingSpinner";

export const ConversationSkeleton = () => {
export const ChatSkeleton = () => {
return (
<Accordion.Item value="conversations">
<Accordion.Item value="chat">
<Accordion.Control>
<Group gap="sm" align="baseline">
<LoadingSpinner size="xs" />

<Title order={3}>
<Trans id="conversation.accordion.skeleton.title">
Conversations
</Trans>
<Trans id="chat.accordion.skeleton.title">Chats</Trans>
</Title>
</Group>
</Accordion.Control>
<Accordion.Panel>
<BaseSkeleton count={3} height="80px" width="100%" radius="xs" />
<BaseSkeleton count={3} height="40px" width="100%" radius="xs" />
</Accordion.Panel>
</Accordion.Item>
);
Expand Down
77 changes: 75 additions & 2 deletions echo/frontend/src/components/chat/hooks/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,13 +6,20 @@ import {
import { directus } from "@/lib/directus";
import {
Query,
aggregate,
createItem,
deleteItem,
readItem,
readItems,
updateItem,
} from "@directus/sdk";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import {
useMutation,
useQuery,
useQueryClient,
useSuspenseQuery,
useInfiniteQuery,
} from "@tanstack/react-query";
import { toast } from "@/components/common/Toaster";

export const useChatHistory = (chatId: string) => {
Expand Down Expand Up @@ -130,7 +137,7 @@ export const useProjectChats = (
projectId: string,
query?: Partial<Query<CustomDirectusTypes, ProjectChat>>,
) => {
return useQuery({
return useSuspenseQuery({
queryKey: ["projects", projectId, "chats", query],
queryFn: () =>
directus.request(
Expand All @@ -147,3 +154,69 @@ export const useProjectChats = (
),
});
};

export const useInfiniteProjectChats = (
projectId: string,
query?: Partial<Query<CustomDirectusTypes, ProjectChat>>,
options?: {
initialLimit?: number;
},
) => {
const { initialLimit = 15 } = options ?? {};

return useInfiniteQuery({
queryKey: ["projects", projectId, "chats", "infinite", query],
queryFn: async ({ pageParam = 0 }) => {
const response = await directus.request(
readItems("project_chat", {
fields: ["id", "project_id", "date_created", "date_updated", "name"],
sort: "-date_created",
filter: {
project_id: {
_eq: projectId,
},
...(query?.filter && query.filter),
},
limit: initialLimit,
offset: pageParam * initialLimit,
...query,
}),
);

return {
chats: response,
nextOffset:
response.length === initialLimit ? pageParam + 1 : undefined,
};
},
initialPageParam: 0,
getNextPageParam: (lastPage) => lastPage.nextOffset,
});
};

export const useProjectChatsCount = (
projectId: string,
query?: Partial<Query<CustomDirectusTypes, ProjectChat>>,
) => {
return useSuspenseQuery({
queryKey: ["projects", projectId, "chats", "count", query],
queryFn: async () => {
const response = await directus.request(
aggregate("project_chat", {
aggregate: {
count: "*",
},
query: {
filter: {
project_id: {
_eq: projectId,
},
...(query?.filter && query.filter),
},
},
}),
);
return response[0].count;
},
});
};
Loading