fix(frontend): history to threads (#2201)

# Description

Please include a summary of the changes and the related issue. Please
also include relevant motivation and context.

## Checklist before requesting a review

Please delete options that are not relevant.

- [ ] My code follows the style guidelines of this project
- [ ] I have performed a self-review of my code
- [ ] I have commented hard-to-understand areas
- [ ] I have ideally added tests that prove my fix is effective or that
my feature works
- [ ] New and existing unit tests pass locally with my changes
- [ ] Any dependent changes have been merged

## Screenshots (if appropriate):
This commit is contained in:
Antoine Dewez 2024-02-16 16:28:27 -08:00 committed by GitHub
parent ef6ee14440
commit 6ae529f614
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
23 changed files with 322 additions and 192 deletions

View File

@ -13,8 +13,11 @@ RUN apt-get clean && apt-get update && apt-get install -y \
pandoc \
curl \
git \
poppler-utils \
tesseract-ocr \
autoconf \
automake \
build-essential \
libtool \
python-dev \
build-essential \
@ -23,6 +26,8 @@ RUN apt-get clean && apt-get update && apt-get install -y \
poppler-utils \
tesseract-ocr \
libreoffice \
libpq-dev \
gcc \
pandoc && \
rm -rf /var/lib/apt/lists/* && apt-get clean

View File

@ -3,6 +3,7 @@
@use "@/styles/ScreenSizes.module.scss";
@use "@/styles/Spacings.module.scss";
@use "@/styles/Typography.module.scss";
@use "@/styles/ZIndexes.module.scss";
.brain_item_wrapper {
padding-inline: Spacings.$spacing05;
@ -16,6 +17,8 @@
border: 1px solid Colors.$lightest-black;
border-radius: Radius.$normal;
padding-block: Spacings.$spacing03;
position: relative;
overflow: visible;
&:hover {
border-color: Colors.$primary;
@ -53,31 +56,11 @@
}
}
.options_menu {
.options_modal {
position: absolute;
background-color: Colors.$highlight;
border-radius: Radius.$normal;
right: Spacings.$spacing07;
border-radius: Radius.$normal;
box-shadow: 0 1px 2px rgb(0, 0, 0, 0.25);
.option {
padding: Spacings.$spacing03;
padding-inline: Spacings.$spacing05;
display: flex;
gap: Spacings.$spacing05;
align-items: center;
cursor: pointer;
justify-content: space-between;
overflow: hidden;
&:not(:first-child) {
border-top: 1px solid Colors.$light-grey;
}
&:hover {
background-color: Colors.$primary-lightest;
}
}
right: Spacings.$spacing02;
top: Spacings.$spacing08;
z-index: ZIndexes.$modal;
padding-bottom: Spacings.$spacing01;
}
}

View File

@ -5,8 +5,10 @@ import { DeleteOrUnsubscribeConfirmationModal } from "@/app/studio/[brainId]/com
import { useBrainManagementTabs } from "@/app/studio/[brainId]/components/BrainManagementTabs/hooks/useBrainManagementTabs";
import { getBrainPermissions } from "@/app/studio/[brainId]/components/BrainManagementTabs/utils/getBrainPermissions";
import Icon from "@/lib/components/ui/Icon/Icon";
import { OptionsModal } from "@/lib/components/ui/OptionsModal/OptionsModal";
import { useBrainContext } from "@/lib/context/BrainProvider/hooks/useBrainContext";
import { MinimalBrainForUser } from "@/lib/context/BrainProvider/types";
import { Option } from "@/lib/types/Options";
import styles from "./BrainItem.module.scss";
@ -17,34 +19,49 @@ type BrainItemProps = {
export const BrainItem = ({ brain, even }: BrainItemProps): JSX.Element => {
const [optionsOpened, setOptionsOpened] = useState<boolean>(false);
const [deleteHovered, setDeleteHovered] = useState<boolean>(false);
const [editHovered, setEditHovered] = useState<boolean>(false);
const {
handleUnsubscribeOrDeleteBrain,
isDeleteOrUnsubscribeModalOpened,
setIsDeleteOrUnsubscribeModalOpened,
isDeleteOrUnsubscribeRequestPending,
} = useBrainManagementTabs(brain.id);
const { allBrains } = useBrainContext();
const { isOwnedByCurrentUser } = getBrainPermissions({
brainId: brain.id,
userAccessibleBrains: allBrains,
});
const iconRef = useRef<HTMLDivElement | null>(null);
const optionsRef = useRef<HTMLDivElement | null>(null);
const options: Option[] = [
{
label: "Edit",
onClick: () => (window.location.href = `/studio/${brain.id}`),
iconName: "edit",
iconColor: "primary",
},
{
label: "Delete",
onClick: () => void setIsDeleteOrUnsubscribeModalOpened(true),
iconName: "delete",
iconColor: "dangerous",
},
];
useEffect(() => {
const handleClickOutside = (event: MouseEvent) => {
if (
iconRef.current &&
!iconRef.current.contains(event.target as Node) &&
optionsRef.current &&
!optionsRef.current.contains(event.target as Node)
) {
setOptionsOpened(false);
event.preventDefault();
}
};
document.addEventListener("mousedown", handleClickOutside);
return () => {
@ -53,68 +70,50 @@ export const BrainItem = ({ brain, even }: BrainItemProps): JSX.Element => {
}, []);
return (
<div
className={`
<>
<div
className={`
${even ? styles.even : styles.odd}
${styles.brain_item_wrapper}
`}
>
<Link className={styles.brain_info_wrapper} href={`/studio/${brain.id}`}>
<span className={styles.name}>{brain.name}</span>
<span className={styles.description}>{brain.description}</span>
</Link>
<div>
<div
onClick={(event: React.MouseEvent<HTMLElement>) => {
event.stopPropagation();
setOptionsOpened(!optionsOpened);
}}
>
<Link
className={styles.brain_info_wrapper}
href={`/studio/${brain.id}`}
>
<Icon name="options" size="normal" color="black" handleHover={true} />
</div>
{optionsOpened && (
<div className={styles.options_menu} ref={optionsRef}>
<div
className={styles.option}
onClick={() => setIsDeleteOrUnsubscribeModalOpened(true)}
onMouseEnter={() => setDeleteHovered(true)}
onMouseLeave={() => setDeleteHovered(false)}
>
<span>Delete</span>
<Icon
name="delete"
size="normal"
color="dangerous"
hovered={deleteHovered}
/>
</div>
<div
className={styles.option}
onClick={() => (window.location.href = `/studio/${brain.id}`)}
onMouseEnter={() => setEditHovered(true)}
onMouseLeave={() => setEditHovered(false)}
>
<span>Edit</span>
<Icon
name="edit"
size="normal"
color="black"
hovered={editHovered}
/>
</div>
<span className={styles.name}>{brain.name}</span>
<span className={styles.description}>{brain.description}</span>
</Link>
<div>
<div
ref={iconRef}
onClick={(event: React.MouseEvent<HTMLElement>) => {
event.nativeEvent.stopImmediatePropagation();
setOptionsOpened(!optionsOpened);
}}
>
<Icon
name="options"
size="normal"
color="black"
handleHover={true}
/>
</div>
)}
<DeleteOrUnsubscribeConfirmationModal
isOpen={isDeleteOrUnsubscribeModalOpened}
setOpen={setIsDeleteOrUnsubscribeModalOpened}
onConfirm={() => void handleUnsubscribeOrDeleteBrain()}
isOwnedByCurrentUser={isOwnedByCurrentUser}
isDeleteOrUnsubscribeRequestPending={
isDeleteOrUnsubscribeRequestPending
}
/>
</div>
<div ref={optionsRef} className={styles.options_modal}>
{optionsOpened && <OptionsModal options={options} />}
</div>
</div>
<DeleteOrUnsubscribeConfirmationModal
isOpen={isDeleteOrUnsubscribeModalOpened}
setOpen={setIsDeleteOrUnsubscribeModalOpened}
onConfirm={() => void handleUnsubscribeOrDeleteBrain()}
isOwnedByCurrentUser={isOwnedByCurrentUser}
isDeleteOrUnsubscribeRequestPending={
isDeleteOrUnsubscribeRequestPending
}
/>
</div>
</>
);
};

View File

@ -11,10 +11,10 @@ import { useMenuContext } from "@/lib/context/MenuProvider/hooks/useMenuContext"
import styles from "./Menu.module.scss";
import { AnimatedDiv } from "./components/AnimationDiv";
import { DiscussionButton } from "./components/DiscussionButton/DiscussionButton";
import { HistoryButton } from "./components/HistoryButton/HistoryButton";
import { HomeButton } from "./components/HomeButton/HomeButton";
import { ProfileButton } from "./components/ProfileButton/ProfileButton";
import { StudioButton } from "./components/StudioButton/StudioButton";
import { ThreadsButton } from "./components/ThreadsButton/ThreadsButton";
import { UpgradeToPlusButton } from "./components/UpgradeToPlusButton/UpgradeToPlusButton";
export const Menu = (): JSX.Element => {
@ -61,7 +61,7 @@ export const Menu = (): JSX.Element => {
<DiscussionButton />
<HomeButton />
<StudioButton />
<HistoryButton />
<ThreadsButton />
</div>
<div className={styles.block}>
<UpgradeToPlusButton />

View File

@ -1,69 +0,0 @@
import Link from "next/link";
import { ChatEntity } from "@/app/chat/[chatId]/types";
import Icon from "@/lib/components/ui/Icon/Icon";
import styles from "./ChatHistoryItem.module.scss";
import { useChatsListItem } from "../../hooks/useChatsListItem";
type ChatHistoryItemProps = {
chatHistoryItem: ChatEntity;
};
export const ChatHistoryItem = ({
chatHistoryItem,
}: ChatHistoryItemProps): JSX.Element => {
const {
chatName,
deleteChat,
editingName,
handleEditNameClick,
setChatName,
} = useChatsListItem(chatHistoryItem);
const onNameEdited = () => {
handleEditNameClick();
};
return (
<div className={styles.chat_item_wrapper}>
{editingName ? (
<input
className={styles.edit_chat_name}
onChange={(event) => setChatName(event.target.value)}
value={chatName}
onKeyDown={(event) => {
if (event.key === "Enter") {
onNameEdited();
}
}}
autoFocus
/>
) : (
<Link
className={styles.chat_item_name}
href={`/chat/${chatHistoryItem.chat_id}`}
>
{chatName.trim()}
</Link>
)}
<div className={styles.icons_wrapper}>
<Icon
name={editingName ? "check" : "edit"}
size="normal"
color="black"
handleHover={true}
onClick={() => onNameEdited()}
/>
<Icon
name="delete"
size="normal"
color="dangerous"
handleHover={true}
onClick={() => void deleteChat()}
/>
</div>
</div>
);
};

View File

@ -9,19 +9,8 @@
padding-top: 0;
color: Colors.$dark-grey;
font-size: Typography.$small;
max-height: 200px;
max-height: 300px;
overflow-y: scroll;
display: flex;
flex-direction: column;
&.fade_out {
mask-image: -webkit-gradient(
linear,
left top,
left bottom,
from(black),
to(transparent)
);
mask-image: linear-gradient(to bottom, black 50%, transparent 100%);
}
}

View File

@ -5,11 +5,11 @@ import { useTranslation } from "react-i18next";
import { FoldableSection } from "@/lib/components/ui/FoldableSection/FoldableSection";
import { useChatsContext } from "@/lib/context/ChatsProvider/hooks/useChatsContext";
import { ChatsSection } from "./ChatsSection/ChatsSection";
import styles from "./HistoryButton.module.scss";
import styles from "./ThreadsButton.module.scss";
import { ThreadsSection } from "./ThreadsSection/ThreadsSection";
import { isWithinLast30Days, isWithinLast7Days, isYesterday } from "./utils";
export const HistoryButton = (): JSX.Element => {
export const ThreadsButton = (): JSX.Element => {
const [canScrollDown, setCanScrollDown] = useState<boolean>(false);
const { allChats } = useChatsContext();
const { t } = useTranslation("chat");
@ -49,10 +49,10 @@ export const HistoryButton = (): JSX.Element => {
return (
<FoldableSection
label={t("history")}
label={t("threads")}
icon="history"
foldedByDefault={true}
hideBorderIfUnfolded={true}
hideBorder={true}
>
<div
className={`
@ -60,10 +60,10 @@ export const HistoryButton = (): JSX.Element => {
${canScrollDown ? styles.fade_out : ""}
`}
>
<ChatsSection chats={todayChats} title={t("today")} />
<ChatsSection chats={yesterdayChats} title={t("yesterday")} />
<ChatsSection chats={last7DaysChats} title={t("last7Days")} />
<ChatsSection chats={last30DaysChats} title={t("last30Days")} />
<ThreadsSection chats={todayChats} title={t("today")} />
<ThreadsSection chats={yesterdayChats} title={t("yesterday")} />
<ThreadsSection chats={last7DaysChats} title={t("last7Days")} />
<ThreadsSection chats={last30DaysChats} title={t("last30Days")} />
</div>
</FoldableSection>
);

View File

@ -2,8 +2,9 @@
@use "@/styles/Radius.module.scss";
@use "@/styles/Spacings.module.scss";
@use "@/styles/Typography.module.scss";
@use "@/styles/ZIndexes.module.scss";
.chat_item_wrapper {
.thread_item_wrapper {
color: Colors.$black;
display: flex;
justify-content: space-between;
@ -11,7 +12,7 @@
align-items: center;
overflow: hidden;
.edit_chat_name {
.edit_thread_name {
@include Typography.EllipsisOverflow;
color: Colors.$black;
@ -28,7 +29,7 @@
}
}
.chat_item_name {
.thread_item_name {
@include Typography.EllipsisOverflow;
&:hover {
@ -36,10 +37,10 @@
}
}
.icons_wrapper {
visibility: hidden;
display: flex;
gap: Spacings.$spacing02;
.options_modal {
position: absolute;
right: Spacings.$spacing02;
z-index: ZIndexes.$modal;
}
&:hover {

View File

@ -0,0 +1,118 @@
import Link from "next/link";
import { useEffect, useRef, useState } from "react";
import { ChatEntity } from "@/app/chat/[chatId]/types";
import Icon from "@/lib/components/ui/Icon/Icon";
import { OptionsModal } from "@/lib/components/ui/OptionsModal/OptionsModal";
import { Option } from "@/lib/types/Options";
import styles from "./ThreadItem.module.scss";
import { useChatsListItem } from "../../hooks/useChatsListItem";
type ChatHistoryItemProps = {
chatHistoryItem: ChatEntity;
};
export const ThreadItem = ({
chatHistoryItem,
}: ChatHistoryItemProps): JSX.Element => {
const [optionsOpened, setOptionsOpened] = useState<boolean>(false);
const {
chatName,
deleteChat,
editingName,
handleEditNameClick,
setChatName,
} = useChatsListItem(chatHistoryItem);
const onNameEdited = () => {
handleEditNameClick();
};
const optionsRef = useRef<HTMLDivElement | null>(null);
const iconRef = useRef<HTMLDivElement | null>(null);
const options: Option[] = [
{
label: "Edit",
onClick: () => onNameEdited(),
iconName: "edit",
iconColor: "primary",
},
{
label: "Delete",
onClick: () => void deleteChat(),
iconName: "delete",
iconColor: "dangerous",
},
];
useEffect(() => {
const handleClickOutside = (event: MouseEvent) => {
if (
iconRef.current &&
!iconRef.current.contains(event.target as Node) &&
optionsRef.current &&
!optionsRef.current.contains(event.target as Node)
) {
setOptionsOpened(false);
event.preventDefault();
}
};
document.addEventListener("mousedown", handleClickOutside);
return () => {
document.removeEventListener("mousedown", handleClickOutside);
};
}, []);
return (
<>
<div className={styles.thread_item_wrapper}>
{editingName ? (
<input
className={styles.edit_thread_name}
onChange={(event) => setChatName(event.target.value)}
value={chatName}
onKeyDown={(event) => {
if (event.key === "Enter") {
onNameEdited();
}
}}
autoFocus
/>
) : (
<Link
className={styles.thread_item_name}
href={`/chat/${chatHistoryItem.chat_id}`}
>
{chatName.trim()}
</Link>
)}
<div
ref={iconRef}
onClick={(event: React.MouseEvent<HTMLElement>) => {
event.nativeEvent.stopImmediatePropagation();
if (editingName) {
onNameEdited();
} else {
setOptionsOpened(!optionsOpened);
}
}}
>
<Icon
name={editingName ? "check" : "options"}
size="small"
color="black"
handleHover={true}
/>
<div ref={optionsRef} className={styles.options_modal}>
{optionsOpened && <OptionsModal options={options} />}
</div>
</div>
</div>
</>
);
};

View File

@ -1,14 +1,14 @@
import { ChatEntity } from "@/app/chat/[chatId]/types";
import { ChatHistoryItem } from "./ChatHistoryItem/ChatHistoryItem";
import styles from "./ChatsSection.module.scss";
import { ThreadItem } from "./ThreadItem/ThreadItem";
import styles from "./ThreadsSection.module.scss";
type ChatSectionProps = {
chats: ChatEntity[];
title: string;
};
export const ChatsSection = (props: ChatSectionProps): JSX.Element => {
export const ThreadsSection = (props: ChatSectionProps): JSX.Element => {
if (props.chats.length === 0) {
return <></>;
}
@ -18,7 +18,7 @@ export const ChatsSection = (props: ChatSectionProps): JSX.Element => {
<div>{props.title}</div>
<div className={styles.chats_wrapper}>
{props.chats.map((chat) => (
<ChatHistoryItem key={chat.chat_id} chatHistoryItem={chat} />
<ThreadItem key={chat.chat_id} chatHistoryItem={chat} />
))}
</div>
</div>

View File

@ -11,7 +11,7 @@ interface FoldableSectionProps {
icon: keyof typeof iconList;
children: React.ReactNode;
foldedByDefault?: boolean;
hideBorderIfUnfolded?: boolean;
hideBorder?: boolean;
}
export const FoldableSection = (props: FoldableSectionProps): JSX.Element => {
@ -26,7 +26,7 @@ export const FoldableSection = (props: FoldableSectionProps): JSX.Element => {
className={`
${styles.foldable_section_wrapper}
${!folded ? styles.unfolded : ""}
${props.hideBorderIfUnfolded && folded ? styles.hide_border : ""}
${props.hideBorder ? styles.hide_border : ""}
`}
>
<div className={styles.header_wrapper} onClick={() => setFolded(!folded)}>

View File

@ -0,0 +1,31 @@
@use "@/styles/Colors.module.scss";
@use "@/styles/Radius.module.scss";
@use "@/styles/Spacings.module.scss";
@use "@/styles/ZIndexes.module.scss";
.options_modal_wrapper {
background-color: Colors.$highlight;
border-radius: Radius.$normal;
border-radius: Radius.$normal;
box-shadow: 0 1px 2px rgb(0, 0, 0, 0.25);
width: fit-content;
.option {
padding: Spacings.$spacing03;
padding-inline: Spacings.$spacing05;
display: flex;
gap: Spacings.$spacing05;
align-items: center;
cursor: pointer;
justify-content: space-between;
overflow: hidden;
&:not(:first-child) {
border-top: 1px solid Colors.$light-grey;
}
&:hover {
background-color: Colors.$primary-lightest;
}
}
}

View File

@ -0,0 +1,56 @@
import { useEffect, useRef, useState } from "react";
import { Option } from "@/lib/types/Options";
import styles from "./OptionsModal.module.scss";
import { Icon } from "../Icon/Icon";
type OptionsModalProps = {
options: Option[];
};
export const OptionsModal = ({ options }: OptionsModalProps): JSX.Element => {
const [hovered, setHovered] = useState<boolean[]>(
new Array(options.length).fill(false)
);
const handleMouseEnter = (index: number) => {
setHovered((prevHovered) =>
prevHovered.map((h, i) => (i === index ? true : h))
);
};
const handleMouseLeave = (index: number) => {
setHovered((prevHovered) =>
prevHovered.map((h, i) => (i === index ? false : h))
);
};
const modalRef = useRef<HTMLDivElement>(null);
useEffect(() => {
modalRef.current?.focus();
}, []);
return (
<div className={styles.options_modal_wrapper} ref={modalRef} tabIndex={-1}>
{options.map((option, index) => (
<div
className={styles.option}
key={index}
onClick={option.onClick}
onMouseEnter={() => handleMouseEnter(index)}
onMouseLeave={() => handleMouseLeave(index)}
>
<span>{option.label}</span>
<Icon
name={option.iconName}
color={hovered[index] ? option.iconColor : "black"}
size="normal"
/>
</div>
))}
</div>
);
};

View File

@ -0,0 +1,10 @@
import { iconList } from "@/lib/helpers/iconList";
import { Color } from "./Colors";
export type Option = {
label: string;
iconName: keyof typeof iconList;
onClick: () => void;
iconColor: Color;
};

View File

@ -58,6 +58,7 @@
"subtitle": "Talk to a language model about your uploaded data",
"successfully_deleted": "Successfully deleted",
"thinking": "Thinking...",
"threads": "Threads",
"title": "Chat with {{brain}}",
"today": "Today",
"tooManyRequests": "You have exceeded the number of requests per day. To continue chatting, please upgrade your account or come back tomorrow.",

View File

@ -58,6 +58,7 @@
"subtitle": "Habla con un modelo de lenguaje acerca de tus datos subidos",
"successfully_deleted": "Chat borrado correctamente",
"thinking": "Pensando...",
"threads": "Hilos",
"title": "Conversa con {{brain}}",
"today": "Hoy",
"tooManyRequests": "Has excedido el número de solicitudes por día. Para continuar chateando, por favor ingresa una clave de API de OpenAI en tu perfil o en el cerebro utilizado.",

View File

@ -58,6 +58,7 @@
"subtitle": "Parlez à un modèle linguistique de vos données téléchargées",
"successfully_deleted": "Chat supprimé avec succès",
"thinking": "Réflexion...",
"threads": "Fils",
"title": "Discuter avec {{brain}}",
"today": "Aujourd'hui",
"tooManyRequests": "Vous avez dépassé le nombre de requêtes par jour. Pour continuer à discuter, veuillez entrer une clé d'API OpenAI dans votre profil ou dans le cerveau utilisé.",

View File

@ -58,6 +58,7 @@
"subtitle": "Converse com um modelo de linguagem sobre seus dados enviados",
"successfully_deleted": "Conversa excluída com sucesso",
"thinking": "Pensando...",
"threads": "Tópicos",
"title": "Converse com {{brain}}",
"today": "Hoje",
"tooManyRequests": "Você excedeu o número de solicitações por dia. Para continuar conversando, insira uma chave de API da OpenAI em seu perfil ou no cérebro utilizado.",

View File

@ -58,6 +58,7 @@
"subtitle": "Общайтесь с языковой моделью о ваших загруженных данных",
"successfully_deleted": "Успешно удалено",
"thinking": "Думаю...",
"threads": "Обсуждения",
"title": "Чат с {{brain}}",
"today": "Сегодня",
"tooManyRequests": "Вы превысили количество запросов в день. Чтобы продолжить чат, введите ключ OpenAI API в вашем профиле или в использованном мозге.",

View File

@ -58,6 +58,7 @@
"subtitle": "与语言模型讨论您上传的数据",
"successfully_deleted": "成功删除",
"thinking": "思考中…",
"threads": "讨论",
"title": "与 {{brain}} 聊天",
"today": "今天",
"tooManyRequests": "您已超过每天的请求次数。想要继续聊天,请在您的个人资料中或为当前大脑配置 OpenAI API 密钥。",