From 41f3f202ec67bce9eaa984e4f43223fb7b8942ed Mon Sep 17 00:00:00 2001 From: Kirjava Date: Wed, 31 Jul 2024 22:21:40 +0100 Subject: [PATCH] feat: initial chat history ui (#25) --- .../bolt/app/components/chat/BaseChat.tsx | 2 + .../bolt/app/components/header/Header.tsx | 4 +- .../app/components/sidemenu/HistoryItem.tsx | 88 +++++++++++++++++ .../app/components/sidemenu/Menu.client.tsx | 99 +++++++++++++++++++ .../app/components/sidemenu/date-binning.ts | 55 +++++++++++ packages/bolt/app/lib/persistence/db.ts | 31 +++++- .../app/lib/persistence/useChatHistory.ts | 1 + packages/bolt/app/styles/variables.scss | 2 - packages/bolt/app/styles/z-index.scss | 4 + packages/bolt/package.json | 1 + pnpm-lock.yaml | 3 + 11 files changed, 285 insertions(+), 5 deletions(-) create mode 100644 packages/bolt/app/components/sidemenu/HistoryItem.tsx create mode 100644 packages/bolt/app/components/sidemenu/Menu.client.tsx create mode 100644 packages/bolt/app/components/sidemenu/date-binning.ts diff --git a/packages/bolt/app/components/chat/BaseChat.tsx b/packages/bolt/app/components/chat/BaseChat.tsx index 6e79dee..7bbbeed 100644 --- a/packages/bolt/app/components/chat/BaseChat.tsx +++ b/packages/bolt/app/components/chat/BaseChat.tsx @@ -5,6 +5,7 @@ import { IconButton } from '~/components/ui/IconButton'; import { Workbench } from '~/components/workbench/Workbench.client'; import { classNames } from '~/utils/classNames'; import { Messages } from './Messages.client'; +import { Menu } from '~/components/sidemenu/Menu.client'; import { SendButton } from './SendButton.client'; interface BaseChatProps { @@ -50,6 +51,7 @@ export const BaseChat = React.forwardRef( return (
+ {() => }
{!chatStarted && ( diff --git a/packages/bolt/app/components/header/Header.tsx b/packages/bolt/app/components/header/Header.tsx index c5da1f6..f7a7940 100644 --- a/packages/bolt/app/components/header/Header.tsx +++ b/packages/bolt/app/components/header/Header.tsx @@ -6,7 +6,9 @@ export function Header() { return (
-
Bolt
+ + Bolt +
{() => } diff --git a/packages/bolt/app/components/sidemenu/HistoryItem.tsx b/packages/bolt/app/components/sidemenu/HistoryItem.tsx new file mode 100644 index 0000000..a09795a --- /dev/null +++ b/packages/bolt/app/components/sidemenu/HistoryItem.tsx @@ -0,0 +1,88 @@ +import { toast } from 'react-toastify'; +import { useCallback, useEffect, useState, useRef } from 'react'; +import { motion, type Variants } from 'framer-motion'; +import { cubicEasingFn } from '~/utils/easings'; +import { IconButton } from '~/components/ui/IconButton'; +import { db, deleteId, type ChatHistory } from '~/lib/persistence'; + +const iconVariants = { + closed: { + transform: 'translate(40px,0)', + opacity: 0, + transition: { + duration: 0.2, + ease: cubicEasingFn, + }, + }, + open: { + transform: 'translate(0,0)', + opacity: 1, + transition: { + duration: 0.2, + ease: cubicEasingFn, + }, + }, +} satisfies Variants; + +export function HistoryItem({ item, loadEntries }: { item: ChatHistory; loadEntries: () => void }) { + const [requestingDelete, setRequestingDelete] = useState(false); + const [hovering, setHovering] = useState(false); + const hoverRef = useRef(null); + + const deleteItem = useCallback(() => { + if (db) { + deleteId(db, item.id) + .then(() => loadEntries()) + .catch((error) => toast.error(error.message)); + } + }, []); + + useEffect(() => { + let timeout: NodeJS.Timeout | undefined; + + function mouseEnter() { + setHovering(true); + + if (timeout) { + clearTimeout(timeout); + } + } + + function mouseLeave() { + setHovering(false); + + // wait for animation to finish before unsetting + timeout = setTimeout(() => { + setRequestingDelete(false); + }, 200); + } + + hoverRef.current?.addEventListener('mouseenter', mouseEnter); + hoverRef.current?.addEventListener('mouseleave', mouseLeave); + + return () => { + hoverRef.current?.removeEventListener('mouseenter', mouseEnter); + hoverRef.current?.removeEventListener('mouseleave', mouseLeave); + }; + }, []); + + return ( +
+
+ + {item.description} + + + {requestingDelete ? ( + + ) : ( + setRequestingDelete(true)} /> + )} + +
+
+ ); +} diff --git a/packages/bolt/app/components/sidemenu/Menu.client.tsx b/packages/bolt/app/components/sidemenu/Menu.client.tsx new file mode 100644 index 0000000..8c77c49 --- /dev/null +++ b/packages/bolt/app/components/sidemenu/Menu.client.tsx @@ -0,0 +1,99 @@ +import { Fragment, useEffect, useState, useRef, useCallback } from 'react'; +import { toast } from 'react-toastify'; +import { motion, type Variants } from 'framer-motion'; +import { cubicEasingFn } from '~/utils/easings'; +import { db, getAll, type ChatHistory } from '~/lib/persistence'; +import { HistoryItem } from './HistoryItem'; +import { binDates } from './date-binning'; + +const menuVariants = { + closed: { + left: '-400px', + transition: { + duration: 0.2, + ease: cubicEasingFn, + }, + }, + open: { + left: 0, + transition: { + duration: 0.2, + ease: cubicEasingFn, + }, + }, +} satisfies Variants; + +export function Menu() { + const menuRef = useRef(null); + const [list, setList] = useState([]); + const [open, setOpen] = useState(false); + + const loadEntries = useCallback(() => { + if (db) { + getAll(db) + .then((list) => list.filter((item) => item.urlId && item.description)) + .then(setList) + .catch((error) => toast.error(error.message)); + } + }, []); + + useEffect(() => { + if (open) { + loadEntries(); + } + }, [open]); + + useEffect(() => { + function onMouseMove(event: MouseEvent) { + if (event.pageX < 80) { + setOpen(true); + } + } + + function onMouseLeave(_event: MouseEvent) { + setOpen(false); + } + + menuRef.current?.addEventListener('mouseleave', onMouseLeave); + window.addEventListener('mousemove', onMouseMove); + + return () => { + menuRef.current?.removeEventListener('mouseleave', onMouseLeave); + window.removeEventListener('mousemove', onMouseMove); + }; + }, []); + + return ( + + + +
Your Chats
+
+ {list.length === 0 &&
No previous conversations
} + {binDates(list).map(({ category, items }) => ( + +
{category}
+ {items.map((item) => ( + + ))} +
+ ))} +
+
+ + ); +} diff --git a/packages/bolt/app/components/sidemenu/date-binning.ts b/packages/bolt/app/components/sidemenu/date-binning.ts new file mode 100644 index 0000000..b69cd84 --- /dev/null +++ b/packages/bolt/app/components/sidemenu/date-binning.ts @@ -0,0 +1,55 @@ +import type { ChatHistory } from '~/lib/persistence'; +import { format, isToday, isYesterday, isThisWeek, isThisYear, subDays, isAfter } from 'date-fns'; + +type Bin = { category: string; items: ChatHistory[] }; + +export function binDates(_list: ChatHistory[]) { + const list = _list.toSorted((a, b) => Date.parse(b.timestamp) - Date.parse(a.timestamp)); + + const binLookup: Record = {}; + const bins: Array = []; + + list.forEach((item) => { + const category = dateCategory(new Date(item.timestamp)); + + if (!(category in binLookup)) { + const bin = { + category, + items: [item], + }; + + binLookup[category] = bin; + bins.push(bin); + } else { + binLookup[category].items.push(item); + } + }); + + return bins; +} + +function dateCategory(date: Date) { + if (isToday(date)) { + return 'Today'; + } + + if (isYesterday(date)) { + return 'Yesterday'; + } + + if (isThisWeek(date)) { + return format(date, 'eeee'); // e.g. "Monday" + } + + const thirtyDaysAgo = subDays(new Date(), 30); + + if (isAfter(date, thirtyDaysAgo)) { + return 'Last 30 Days'; + } + + if (isThisYear(date)) { + return format(date, 'MMMM'); // e.g., "July" + } + + return format(date, 'MMMM yyyy'); // e.g. "July 2023" +} diff --git a/packages/bolt/app/lib/persistence/db.ts b/packages/bolt/app/lib/persistence/db.ts index e304dc1..fd35e1c 100644 --- a/packages/bolt/app/lib/persistence/db.ts +++ b/packages/bolt/app/lib/persistence/db.ts @@ -30,6 +30,17 @@ export async function openDatabase(): Promise { }); } +export async function getAll(db: IDBDatabase): Promise { + return new Promise((resolve, reject) => { + const transaction = db.transaction('chats', 'readonly'); + const store = transaction.objectStore('chats'); + const request = store.getAll(); + + request.onsuccess = () => resolve(request.result as ChatHistory[]); + request.onerror = () => reject(request.error); + }); +} + export async function setMessages( db: IDBDatabase, id: string, @@ -46,6 +57,7 @@ export async function setMessages( messages, urlId, description, + timestamp: new Date().toISOString(), }); request.onsuccess = () => resolve(); @@ -80,13 +92,28 @@ export async function getMessagesById(db: IDBDatabase, id: string): Promise { + return new Promise((resolve, reject) => { + const transaction = db.transaction('chats', 'readwrite'); + const store = transaction.objectStore('chats'); + const request = store.delete(id); + + request.onsuccess = () => resolve(undefined); + request.onerror = () => reject(request.error); + }); +} + export async function getNextId(db: IDBDatabase): Promise { return new Promise((resolve, reject) => { const transaction = db.transaction('chats', 'readonly'); const store = transaction.objectStore('chats'); - const request = store.count(); + const request = store.getAllKeys(); + + request.onsuccess = () => { + const highestId = request.result.reduce((cur, acc) => Math.max(+cur, +acc), 0); + resolve(String(+highestId + 1)); + }; - request.onsuccess = () => resolve(String(request.result)); request.onerror = () => reject(request.error); }); } diff --git a/packages/bolt/app/lib/persistence/useChatHistory.ts b/packages/bolt/app/lib/persistence/useChatHistory.ts index 526e1a6..1a7c8a3 100644 --- a/packages/bolt/app/lib/persistence/useChatHistory.ts +++ b/packages/bolt/app/lib/persistence/useChatHistory.ts @@ -10,6 +10,7 @@ export interface ChatHistory { urlId?: string; description?: string; messages: Message[]; + timestamp: string; } const persistenceEnabled = !import.meta.env.VITE_DISABLE_PERSISTENCE; diff --git a/packages/bolt/app/styles/variables.scss b/packages/bolt/app/styles/variables.scss index 8e275d5..7f807f3 100644 --- a/packages/bolt/app/styles/variables.scss +++ b/packages/bolt/app/styles/variables.scss @@ -96,8 +96,6 @@ :root { --header-height: 65px; - --z-index-max: 999; - /* App */ --bolt-elements-app-backgroundColor: var(--bolt-background-primary); --bolt-elements-app-borderColor: var(--bolt-border-primary); diff --git a/packages/bolt/app/styles/z-index.scss b/packages/bolt/app/styles/z-index.scss index 8f5dc59..04465ad 100644 --- a/packages/bolt/app/styles/z-index.scss +++ b/packages/bolt/app/styles/z-index.scss @@ -1 +1,5 @@ $zIndexMax: 999; + +.z-max { + z-index: $zIndexMax; +} diff --git a/packages/bolt/package.json b/packages/bolt/package.json index 854c5e6..4457c17 100644 --- a/packages/bolt/package.json +++ b/packages/bolt/package.json @@ -44,6 +44,7 @@ "@xterm/addon-web-links": "^0.11.0", "@xterm/xterm": "^5.5.0", "ai": "^3.2.27", + "date-fns": "^3.6.0", "diff": "^5.2.0", "framer-motion": "^11.2.12", "isbot": "^4.1.0", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 8c0224e..11ad83e 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -119,6 +119,9 @@ importers: ai: specifier: ^3.2.27 version: 3.2.27(react@18.3.1)(svelte@4.2.18)(vue@3.4.30(typescript@5.5.2))(zod@3.23.8) + date-fns: + specifier: ^3.6.0 + version: 3.6.0 diff: specifier: ^5.2.0 version: 5.2.0