feat: add chat view new design (#1897)

Issue: https://github.com/StanGirard/quivr/issues/1888

- Add Spinner when history is loading
- Change chat messages fetching logic
- Add cha view new design

Demo:



https://github.com/StanGirard/quivr/assets/63923024/c4341ccf-bacd-4720-9aa1-127dd557a75c
This commit is contained in:
Mamadou DICKO 2023-12-14 16:22:09 +01:00 committed by GitHub
parent 992c67a2b9
commit e2c1a027b0
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
22 changed files with 140 additions and 68 deletions

View File

@ -4,6 +4,7 @@ import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { PropsWithChildren, useEffect } from "react";
import { Menu } from "@/lib/components/Menu/Menu";
import { useOutsideClickListener } from "@/lib/components/Menu/hooks/useOutsideClickListener";
import { NotificationBanner } from "@/lib/components/NotificationBanner";
import { BrainProvider } from "@/lib/context";
import { useBrainContext } from "@/lib/context/BrainProvider/hooks/useBrainContext";
@ -18,6 +19,7 @@ import "../lib/config/LocaleConfig/i18n";
const App = ({ children }: PropsWithChildren): JSX.Element => {
const { fetchAllBrains, fetchDefaultBrain, fetchPublicPrompts } =
useBrainContext();
const { onClickOutside } = useOutsideClickListener();
const { session } = useSupabase();
usePageTracking();
@ -35,7 +37,9 @@ const App = ({ children }: PropsWithChildren): JSX.Element => {
<NotificationBanner />
<div className="relative h-full w-full flex justify-stretch items-stretch overflow-auto">
<Menu />
<div className="flex-1">{children}</div>
<div onClick={onClickOutside} className="flex-1">
{children}
</div>
<UpdateMetadata />
</div>
</div>

View File

@ -20,17 +20,34 @@ import {
import SelectedChatPage from "../page";
const queryClient = new QueryClient();
vi.mock("@/lib/context/ChatProvider/ChatProvider", () => ({
ChatContext: ChatContextMock,
ChatProvider: ChatProviderMock,
}));
vi.mock("@/lib/context/ChatsProvider/hooks/useChatsContext", () => ({
useChatsContext: () => ({
allChats: [
{
chat_id: 1,
name: "Chat 1",
creation_time: new Date().toISOString(),
},
{
chat_id: 2,
name: "Chat 2",
creation_time: new Date().toISOString(),
},
],
deleteChat: vi.fn(),
setAllChats: vi.fn(),
setIsLoading: vi.fn(),
}),
}));
vi.mock("next/navigation", () => ({
useRouter: () => ({ replace: vi.fn() }),
useParams: () => ({ chatId: "1" }),
usePathname: () => "/chat/1",
}));
vi.mock("@/lib/context/SupabaseProvider/supabase-provider", () => ({
SupabaseContext: SupabaseContextMock,
}));
@ -38,13 +55,11 @@ vi.mock("@/lib/context/SupabaseProvider/supabase-provider", () => ({
vi.mock("@/lib/context/BrainProvider/brain-provider", () => ({
BrainContext: BrainContextMock,
}));
vi.mock("@/lib/api/chat/useChatApi", () => ({
useChatApi: () => ({
getHistory: () => [],
}),
}));
vi.mock("@/lib/hooks", async () => {
const actual = await vi.importActual<typeof import("@/lib/hooks")>(
"@/lib/hooks"
@ -59,7 +74,6 @@ vi.mock("@/lib/hooks", async () => {
}),
};
});
vi.mock("@tanstack/react-query", async () => {
const actual = await vi.importActual<typeof import("@tanstack/react-query")>(
"@tanstack/react-query"
@ -72,7 +86,6 @@ vi.mock("@tanstack/react-query", async () => {
}),
};
});
vi.mock(
"../components/ActionsBar/components/ChatInput/components/ChatEditor/ChatEditor",
() => ({
@ -80,6 +93,9 @@ vi.mock(
})
);
vi.mock("../hooks/useChatNotificationsSync", () => ({
useChatNotificationsSync: vi.fn(),
}));
describe("Chat page", () => {
it("should render chat page correctly", () => {
const { getByTestId } = render(

View File

@ -30,7 +30,7 @@ export const Button = forwardRef(
<div className="flex flex-row justify-between w-full items-center">
<div className="flex flex-row gap-2 items-center">
{startIcon}
<span className="hidden sm:block">{label}</span>
<span>{label}</span>
</div>
{endIcon}
</div>

View File

@ -1,6 +1,6 @@
/* eslint-disable max-lines */
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { act, render, screen } from "@testing-library/react";
import { render, screen } from "@testing-library/react";
import { afterEach, describe, expect, it, vi } from "vitest";
import {
@ -21,7 +21,6 @@ vi.mock("@/lib/context/SupabaseProvider/supabase-provider", () => ({
import { ChatsList } from "../index";
const getChatsMock = vi.fn(() => []);
const queryClient = new QueryClient();
vi.mock("next/navigation", async () => {
@ -121,30 +120,4 @@ describe("ChatsList", () => {
const chatItems = screen.getAllByTestId("chats-list-item");
expect(chatItems).toHaveLength(2);
});
it("should call getChats when the component mounts", async () => {
vi.mock("@/lib/api/chat/useChatApi", () => ({
useChatApi: () => ({
getChats: () => getChatsMock(),
}),
}));
await act(() =>
render(
<QueryClientProvider client={queryClient}>
<KnowledgeToFeedProvider>
<ChatProviderMock>
<BrainProviderMock>
<SideBarProvider>
<ChatsList />
</SideBarProvider>
</BrainProviderMock>
</ChatProviderMock>
</KnowledgeToFeedProvider>
</QueryClientProvider>
)
);
expect(getChatsMock).toHaveBeenCalledTimes(1);
});
});

View File

@ -4,12 +4,8 @@ import { ChatHistory } from "@/lib/components/ChatHistory/ChatHistory";
import { useOnboarding } from "@/lib/hooks/useOnboarding";
import { WelcomeChat } from "./components/WelcomeChat";
import { useChatNotificationsSync } from "./hooks/useChatNotificationsSync";
import { useChatsList } from "./hooks/useChatsList";
export const ChatsList = (): JSX.Element => {
useChatsList();
useChatNotificationsSync();
const { shouldDisplayWelcomeChat } = useOnboarding();
return (

View File

@ -7,9 +7,9 @@ import { useNotificationApi } from "@/lib/api/notification/useNotificationApi";
import { useChatContext } from "@/lib/context";
import { useKnowledgeToFeedContext } from "@/lib/context/KnowledgeToFeedProvider/hooks/useKnowledgeToFeedContext";
import { getChatNotificationsQueryKey } from "../../../../../../../../../../../utils/getChatNotificationsQueryKey";
import { getMessagesFromChatItems } from "../../../../../../../../../../../utils/getMessagesFromChatItems";
import { getNotificationsFromChatItems } from "../../../../../../../../../../../utils/getNotificationsFromChatItems";
import { getChatNotificationsQueryKey } from "../utils/getChatNotificationsQueryKey";
import { getMessagesFromChatItems } from "../utils/getMessagesFromChatItems";
import { getNotificationsFromChatItems } from "../utils/getNotificationsFromChatItems";
// eslint-disable-next-line @typescript-eslint/explicit-module-boundary-types
export const useChatNotificationsSync = () => {

View File

@ -11,7 +11,7 @@ import { useToast } from "@/lib/hooks";
export const useChatsList = () => {
const { t } = useTranslation(["chat"]);
const { setAllChats } = useChatsContext();
const { setAllChats, setIsLoading } = useChatsContext();
const { publish } = useToast();
const { getChats } = useChatApi();
@ -29,7 +29,7 @@ export const useChatsList = () => {
}
};
const { data: chats } = useQuery({
const { data: chats, isLoading } = useQuery({
queryKey: [CHATS_DATA_KEY],
queryFn: fetchAllChats,
});
@ -37,4 +37,8 @@ export const useChatsList = () => {
useEffect(() => {
setAllChats(chats ?? []);
}, [chats]);
useEffect(() => {
setIsLoading(isLoading);
}, [isLoading]);
};

View File

@ -1,30 +1,44 @@
"use client";
import { useMenuWidth } from "@/lib/components/Menu/hooks/useMenuWidth";
import { useKnowledgeToFeedContext } from "@/lib/context/KnowledgeToFeedProvider/hooks/useKnowledgeToFeedContext";
import { useCustomDropzone } from "@/lib/hooks/useDropzone";
import { cn } from "@/lib/utils";
import { ActionsBar } from "./components/ActionsBar";
import { ChatDialogueArea } from "./components/ChatDialogueArea/ChatDialogue";
import { useChatNotificationsSync } from "./hooks/useChatNotificationsSync";
import { useChatsList } from "./hooks/useChatsList";
const SelectedChatPage = (): JSX.Element => {
const { getRootProps } = useCustomDropzone();
const { shouldDisplayFeedCard } = useKnowledgeToFeedContext();
const { staticMenuWidth } = useMenuWidth();
useChatsList();
useChatNotificationsSync();
return (
<div
className={`flex flex-col flex-1 items-center justify-stretch w-full h-full overflow-hidden ${shouldDisplayFeedCard ? "bg-chat-bg-gray" : "bg-white"
} dark:bg-black transition-colors ease-out duration-500`}
data-testid="chat-page"
{...getRootProps()}
>
<div className="flex flex-1">
<div
className={`flex flex-col flex-1 w-full max-w-5xl h-full dark:shadow-primary/25 overflow-hidden p-2 sm:p-4 md:p-6 lg:p-8`}
className={cn(
"flex flex-col flex-1 items-center justify-stretch w-full h-full overflow-hidden",
shouldDisplayFeedCard ? "bg-chat-bg-gray" : "bg-tertiary",
"dark:bg-black transition-colors ease-out duration-500"
)}
data-testid="chat-page"
{...getRootProps()}
>
<div className="flex flex-1 flex-col overflow-y-auto">
<ChatDialogueArea />
<div
className={`flex flex-col flex-1 w-full max-w-5xl h-full dark:shadow-primary/25 overflow-hidden p-2 sm:p-4 md:p-6 lg:p-8`}
>
<div className="flex flex-1 flex-col overflow-y-auto">
<ChatDialogueArea />
</div>
<ActionsBar />
</div>
<ActionsBar />
</div>
<div className="h-full bg-highlight" style={{ width: staticMenuWidth }} />
</div>
);
};

View File

@ -9,9 +9,10 @@ import {
isWithinLast7Days,
isYesterday,
} from "./utils";
import Spinner from "../ui/Spinner";
export const ChatHistory = (): JSX.Element => {
const { allChats } = useChatsContext();
const { allChats, isLoading } = useChatsContext();
const { t } = useTranslation("chat");
const todayChats = allChats.filter((chat) =>
isToday(new Date(chat.creation_time))
@ -26,6 +27,14 @@ export const ChatHistory = (): JSX.Element => {
isWithinLast30Days(new Date(chat.creation_time))
);
if (isLoading) {
return (
<div className="flex justify-center align-center">
<Spinner />
</div>
);
}
return (
<div
data-testid="chats-list-items"

View File

@ -11,10 +11,12 @@ import { MenuHeader } from "./components/MenuHeader";
import { ParametersButton } from "./components/ParametersButton";
import { ProfileButton } from "./components/ProfileButton";
import { UpgradeToPlus } from "./components/UpgradeToPlus";
import { useMenuWidth } from "./hooks/useMenuWidth";
export const Menu = (): JSX.Element => {
const pathname = usePathname() ?? "";
const { staticMenuWidth } = useMenuWidth();
if (nonProtectedPaths.includes(pathname)) {
return <></>;
}
@ -27,7 +29,10 @@ export const Menu = (): JSX.Element => {
return (
<MotionConfig transition={{ mass: 1, damping: 10, duration: 0.2 }}>
<div className="flex flex-col fixed sm:sticky top-0 left-0 h-full overflow-visible z-30 border-r border-black/10 dark:border-white/25 bg-white dark:bg-black">
<div
className="flex flex-col fixed sm:sticky top-0 left-0 h-full overflow-visible z-30 border-r border-black/10 dark:border-white/25 bg-highlight bg-highlight"
style={{ width: staticMenuWidth }}
>
<AnimatedDiv>
<div className="flex flex-col flex-1 p-4 gap-4">
<MenuHeader />

View File

@ -2,25 +2,28 @@ import { motion } from "framer-motion";
import { useSideBarContext } from "@/lib/context/SidebarProvider/hooks/useSideBarContext";
import { useMenuWidth } from "../hooks/useMenuWidth";
type AnimatedDivProps = {
children: React.ReactNode;
};
export const AnimatedDiv = ({ children }: AnimatedDivProps): JSX.Element => {
const { isOpened } = useSideBarContext();
const { OPENED_MENU_WIDTH } = useMenuWidth();
return (
<motion.div
initial={{
width: isOpened ? "260px" : "0px",
width: isOpened ? OPENED_MENU_WIDTH : "0px",
}}
animate={{
width: isOpened ? "260px" : "0px",
width: isOpened ? OPENED_MENU_WIDTH : 0,
opacity: isOpened ? 1 : 0.5,
boxShadow: isOpened
? "10px 10px 16px rgba(0, 0, 0, 0)"
: "10px 10px 16px rgba(0, 0, 0, 0.5)",
}}
className={"overflow-hidden flex flex-col flex-1"}
className={"overflow-hidden flex flex-col flex-1 bg-white"}
>
{children}
</motion.div>

View File

@ -0,0 +1,23 @@
import { usePathname } from "next/navigation";
import { useDevice } from "@/lib/hooks/useDevice";
const OPENED_MENU_WIDTH = 260;
// eslint-disable-next-line @typescript-eslint/explicit-module-boundary-types
export const useMenuWidth = () => {
const pathname = usePathname() ?? "";
const { isMobile } = useDevice();
const isStaticSideBarActivated = !isMobile;
const shouldAddFixedPadding = pathname.startsWith("/chat");
const staticMenuWidth =
shouldAddFixedPadding && isStaticSideBarActivated ? OPENED_MENU_WIDTH : 0;
return {
OPENED_MENU_WIDTH,
staticMenuWidth,
};
};

View File

@ -0,0 +1,18 @@
import { useSideBarContext } from "@/lib/context/SidebarProvider/hooks/useSideBarContext";
import { useDevice } from "@/lib/hooks/useDevice";
// eslint-disable-next-line @typescript-eslint/explicit-module-boundary-types
export const useOutsideClickListener = () => {
const { isOpened, setIsOpened } = useSideBarContext();
const { isMobile } = useDevice();
const onClickOutside = () => {
if (isOpened && isMobile) {
setIsOpened(false);
}
};
return {
onClickOutside,
};
};

View File

@ -8,6 +8,8 @@ type ChatsContextType = {
allChats: ChatEntity[];
//set setAllChats is from the useState hook so it can take a function as params
setAllChats: React.Dispatch<React.SetStateAction<ChatEntity[]>>;
isLoading: boolean;
setIsLoading: React.Dispatch<React.SetStateAction<boolean>>;
};
export const ChatsContext = createContext<ChatsContextType | undefined>(
@ -20,12 +22,15 @@ export const ChatsProvider = ({
children: React.ReactNode;
}): JSX.Element => {
const [allChats, setAllChats] = useState<ChatEntity[]>([]);
const [isLoading, setIsLoading] = useState<boolean>(false);
return (
<ChatsContext.Provider
value={{
allChats,
setAllChats,
isLoading,
setIsLoading,
}}
>
{children}

View File

@ -7,7 +7,7 @@ export const useChatsContext = () => {
const context = useContext(ChatsContext);
if (context === undefined) {
throw new Error("useChatsStore must be used inside ChatsProvider");
throw new Error("useChatsContext must be used inside ChatsProvider");
}
return context;

View File

@ -51,7 +51,7 @@
"shortcut_go_to_shortcuts": "CMDK: Go to shortcuts",
"shortcut_go_to_user_page": "CMDU: Go to user page",
"shortcut_manage_brains": "CMDB: Manage your brains",
"shortcut_select_brain": "@: Select a brain to talk",
"shortcut_select_brain": "@: Select a brain",
"shortcut_select_file": "/: Select a file to talk to",
"subtitle": "Talk to a language model about your uploaded data",
"thinking": "Thinking...",

View File

@ -51,7 +51,7 @@
"shortcut_go_to_shortcuts": "CMDK: Ir a los atajos",
"shortcut_go_to_user_page": "CMDU: Ir a la página de usuario",
"shortcut_manage_brains": "CMDB: Administrar tus cerebros",
"shortcut_select_brain": "@: Seleccionar un cerebro para hablar",
"shortcut_select_brain": "@: Seleccionar un cerebro",
"shortcut_select_file": "/: Seleccionar un archivo para hablar",
"subtitle": "Habla con un modelo de lenguaje acerca de tus datos subidos",
"thinking": "Pensando...",

View File

@ -51,7 +51,7 @@
"shortcut_go_to_shortcuts": "CMDK: Accéder aux raccourcis",
"shortcut_go_to_user_page": "CMDU: Accéder à la page utilisateur",
"shortcut_manage_brains": "CMDB: Gérer vos cerveaux",
"shortcut_select_brain": "@: Sélectionner un cerveau pour discuter",
"shortcut_select_brain": "@: Sélectionnez un cerveau",
"shortcut_select_file": "/: Sélectionner un fichier pour discuter",
"subtitle": "Parlez à un modèle linguistique de vos données téléchargées",
"thinking": "Réflexion...",

View File

@ -51,7 +51,7 @@
"shortcut_go_to_shortcuts": "CMDA: Acesse os atalhos",
"shortcut_go_to_user_page": "CMDU: Acesse a página do usuário",
"shortcut_manage_brains": "CMGC: Gerencie seus cérebros",
"shortcut_select_brain": "@: Selecione um cérebro para conversar",
"shortcut_select_brain": "@: Selecione um cérebro",
"shortcut_select_file": "/: Selecione um arquivo para conversar",
"subtitle": "Converse com um modelo de linguagem sobre seus dados enviados",
"thinking": "Pensando...",

View File

@ -51,7 +51,7 @@
"shortcut_go_to_shortcuts": "CMDK: Перейти к ярлыкам",
"shortcut_go_to_user_page": "CMDU: Перейти на страницу пользователя",
"shortcut_manage_brains": "CMDB: Управление вашими мозгами",
"shortcut_select_brain": "@: Выберите мозг для общения",
"shortcut_select_brain": "@: Выберите мозг",
"shortcut_select_file": "/: Выберите файл для общения",
"subtitle": "Общайтесь с языковой моделью о ваших загруженных данных",
"thinking": "Думаю...",

View File

@ -52,7 +52,7 @@
"shortcut_go_to_shortcuts": "CMDK: 前往快捷方式",
"shortcut_go_to_user_page": "CMDU: 进入用户页面",
"shortcut_manage_brains": "CMDB: 管理大脑",
"shortcut_select_brain": "@: 选择一个大脑进行交流",
"shortcut_select_brain": "@: 选择一个大脑",
"shortcut_select_file": "/: 选择一个文件进行对话",
"subtitle": "与语言模型讨论您上传的数据",
"thinking": "思考中…",

View File

@ -18,7 +18,9 @@ module.exports = {
black: "#11243E",
primary: "#6142D4",
secondary: "#F3ECFF",
tertiary: "#F6F4FF",
accent: "#13ABBA",
highlight: "#FAFAFA",
"accent-hover": "#008491",
"chat-bg-gray": "#D9D9D9",
"msg-gray": "#9B9B9B",