Kirjava commited on
Commit
41f3f20
·
unverified ·
1 Parent(s): be315f6

feat: initial chat history ui (#25)

Browse files
packages/bolt/app/components/chat/BaseChat.tsx CHANGED
@@ -5,6 +5,7 @@ import { IconButton } from '~/components/ui/IconButton';
5
  import { Workbench } from '~/components/workbench/Workbench.client';
6
  import { classNames } from '~/utils/classNames';
7
  import { Messages } from './Messages.client';
 
8
  import { SendButton } from './SendButton.client';
9
 
10
  interface BaseChatProps {
@@ -50,6 +51,7 @@ export const BaseChat = React.forwardRef<HTMLDivElement, BaseChatProps>(
50
 
51
  return (
52
  <div ref={ref} className="relative flex h-full w-full overflow-hidden ">
 
53
  <div ref={scrollRef} className="flex overflow-scroll w-full h-full">
54
  <div id="chat" className="flex flex-col w-full h-full px-6">
55
  {!chatStarted && (
 
5
  import { Workbench } from '~/components/workbench/Workbench.client';
6
  import { classNames } from '~/utils/classNames';
7
  import { Messages } from './Messages.client';
8
+ import { Menu } from '~/components/sidemenu/Menu.client';
9
  import { SendButton } from './SendButton.client';
10
 
11
  interface BaseChatProps {
 
51
 
52
  return (
53
  <div ref={ref} className="relative flex h-full w-full overflow-hidden ">
54
+ <ClientOnly>{() => <Menu />}</ClientOnly>
55
  <div ref={scrollRef} className="flex overflow-scroll w-full h-full">
56
  <div id="chat" className="flex flex-col w-full h-full px-6">
57
  {!chatStarted && (
packages/bolt/app/components/header/Header.tsx CHANGED
@@ -6,7 +6,9 @@ export function Header() {
6
  return (
7
  <header className="flex items-center bg-white p-4 border-b border-gray-200 h-[var(--header-height)]">
8
  <div className="flex items-center gap-2">
9
- <div className="text-2xl font-semibold text-accent">Bolt</div>
 
 
10
  </div>
11
  <div className="ml-auto flex gap-2">
12
  <ClientOnly>{() => <OpenStackBlitz />}</ClientOnly>
 
6
  return (
7
  <header className="flex items-center bg-white p-4 border-b border-gray-200 h-[var(--header-height)]">
8
  <div className="flex items-center gap-2">
9
+ <a href="/" className="text-2xl font-semibold text-accent">
10
+ Bolt
11
+ </a>
12
  </div>
13
  <div className="ml-auto flex gap-2">
14
  <ClientOnly>{() => <OpenStackBlitz />}</ClientOnly>
packages/bolt/app/components/sidemenu/HistoryItem.tsx ADDED
@@ -0,0 +1,88 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { toast } from 'react-toastify';
2
+ import { useCallback, useEffect, useState, useRef } from 'react';
3
+ import { motion, type Variants } from 'framer-motion';
4
+ import { cubicEasingFn } from '~/utils/easings';
5
+ import { IconButton } from '~/components/ui/IconButton';
6
+ import { db, deleteId, type ChatHistory } from '~/lib/persistence';
7
+
8
+ const iconVariants = {
9
+ closed: {
10
+ transform: 'translate(40px,0)',
11
+ opacity: 0,
12
+ transition: {
13
+ duration: 0.2,
14
+ ease: cubicEasingFn,
15
+ },
16
+ },
17
+ open: {
18
+ transform: 'translate(0,0)',
19
+ opacity: 1,
20
+ transition: {
21
+ duration: 0.2,
22
+ ease: cubicEasingFn,
23
+ },
24
+ },
25
+ } satisfies Variants;
26
+
27
+ export function HistoryItem({ item, loadEntries }: { item: ChatHistory; loadEntries: () => void }) {
28
+ const [requestingDelete, setRequestingDelete] = useState(false);
29
+ const [hovering, setHovering] = useState(false);
30
+ const hoverRef = useRef<HTMLDivElement>(null);
31
+
32
+ const deleteItem = useCallback(() => {
33
+ if (db) {
34
+ deleteId(db, item.id)
35
+ .then(() => loadEntries())
36
+ .catch((error) => toast.error(error.message));
37
+ }
38
+ }, []);
39
+
40
+ useEffect(() => {
41
+ let timeout: NodeJS.Timeout | undefined;
42
+
43
+ function mouseEnter() {
44
+ setHovering(true);
45
+
46
+ if (timeout) {
47
+ clearTimeout(timeout);
48
+ }
49
+ }
50
+
51
+ function mouseLeave() {
52
+ setHovering(false);
53
+
54
+ // wait for animation to finish before unsetting
55
+ timeout = setTimeout(() => {
56
+ setRequestingDelete(false);
57
+ }, 200);
58
+ }
59
+
60
+ hoverRef.current?.addEventListener('mouseenter', mouseEnter);
61
+ hoverRef.current?.addEventListener('mouseleave', mouseLeave);
62
+
63
+ return () => {
64
+ hoverRef.current?.removeEventListener('mouseenter', mouseEnter);
65
+ hoverRef.current?.removeEventListener('mouseleave', mouseLeave);
66
+ };
67
+ }, []);
68
+
69
+ return (
70
+ <div className="ml-3 mr-1">
71
+ <div
72
+ ref={hoverRef}
73
+ className="rounded-md hover:bg-gray-200 p-1 overflow-hidden flex justify-between items-center"
74
+ >
75
+ <a href={`/chat/${item.urlId}`} className="truncate block pr-1">
76
+ {item.description}
77
+ </a>
78
+ <motion.div initial="closed" animate={hovering ? 'open' : 'closed'} variants={iconVariants}>
79
+ {requestingDelete ? (
80
+ <IconButton icon="i-ph:check" onClick={deleteItem} />
81
+ ) : (
82
+ <IconButton icon="i-ph:trash" onClick={() => setRequestingDelete(true)} />
83
+ )}
84
+ </motion.div>
85
+ </div>
86
+ </div>
87
+ );
88
+ }
packages/bolt/app/components/sidemenu/Menu.client.tsx ADDED
@@ -0,0 +1,99 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { Fragment, useEffect, useState, useRef, useCallback } from 'react';
2
+ import { toast } from 'react-toastify';
3
+ import { motion, type Variants } from 'framer-motion';
4
+ import { cubicEasingFn } from '~/utils/easings';
5
+ import { db, getAll, type ChatHistory } from '~/lib/persistence';
6
+ import { HistoryItem } from './HistoryItem';
7
+ import { binDates } from './date-binning';
8
+
9
+ const menuVariants = {
10
+ closed: {
11
+ left: '-400px',
12
+ transition: {
13
+ duration: 0.2,
14
+ ease: cubicEasingFn,
15
+ },
16
+ },
17
+ open: {
18
+ left: 0,
19
+ transition: {
20
+ duration: 0.2,
21
+ ease: cubicEasingFn,
22
+ },
23
+ },
24
+ } satisfies Variants;
25
+
26
+ export function Menu() {
27
+ const menuRef = useRef<HTMLDivElement>(null);
28
+ const [list, setList] = useState<ChatHistory[]>([]);
29
+ const [open, setOpen] = useState(false);
30
+
31
+ const loadEntries = useCallback(() => {
32
+ if (db) {
33
+ getAll(db)
34
+ .then((list) => list.filter((item) => item.urlId && item.description))
35
+ .then(setList)
36
+ .catch((error) => toast.error(error.message));
37
+ }
38
+ }, []);
39
+
40
+ useEffect(() => {
41
+ if (open) {
42
+ loadEntries();
43
+ }
44
+ }, [open]);
45
+
46
+ useEffect(() => {
47
+ function onMouseMove(event: MouseEvent) {
48
+ if (event.pageX < 80) {
49
+ setOpen(true);
50
+ }
51
+ }
52
+
53
+ function onMouseLeave(_event: MouseEvent) {
54
+ setOpen(false);
55
+ }
56
+
57
+ menuRef.current?.addEventListener('mouseleave', onMouseLeave);
58
+ window.addEventListener('mousemove', onMouseMove);
59
+
60
+ return () => {
61
+ menuRef.current?.removeEventListener('mouseleave', onMouseLeave);
62
+ window.removeEventListener('mousemove', onMouseMove);
63
+ };
64
+ }, []);
65
+
66
+ return (
67
+ <motion.div
68
+ ref={menuRef}
69
+ initial="closed"
70
+ animate={open ? 'open' : 'closed'}
71
+ variants={menuVariants}
72
+ className="flex flex-col side-menu fixed left-[-400px] top-0 w-[400px] h-full bg-white border-r border-gray-200 z-max"
73
+ >
74
+ <div className="flex items-center border-b border-gray-200 bg-white p-4 h-[var(--header-height)]">
75
+ <a href="/" className="text-2xl font-semibold">
76
+ Bolt
77
+ </a>
78
+ </div>
79
+ <div className="m-4 ml-2 mt-7">
80
+ <a href="/" className="text-white rounded-md bg-accent-600 p-2 font-semibold">
81
+ Start new chat
82
+ </a>
83
+ </div>
84
+ <div className="font-semibold m-2 ml-4">Your Chats</div>
85
+ <div className="overflow-auto pb-2">
86
+ {list.length === 0 && <div className="ml-4 text-gray">No previous conversations</div>}
87
+ {binDates(list).map(({ category, items }) => (
88
+ <Fragment key={category}>
89
+ <div className="ml-4 text-gray m-2">{category}</div>
90
+ {items.map((item) => (
91
+ <HistoryItem key={item.id} item={item} loadEntries={loadEntries} />
92
+ ))}
93
+ </Fragment>
94
+ ))}
95
+ </div>
96
+ <div className="border-t border-gray-200 h-[var(--header-height)]" />
97
+ </motion.div>
98
+ );
99
+ }
packages/bolt/app/components/sidemenu/date-binning.ts ADDED
@@ -0,0 +1,55 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import type { ChatHistory } from '~/lib/persistence';
2
+ import { format, isToday, isYesterday, isThisWeek, isThisYear, subDays, isAfter } from 'date-fns';
3
+
4
+ type Bin = { category: string; items: ChatHistory[] };
5
+
6
+ export function binDates(_list: ChatHistory[]) {
7
+ const list = _list.toSorted((a, b) => Date.parse(b.timestamp) - Date.parse(a.timestamp));
8
+
9
+ const binLookup: Record<string, Bin> = {};
10
+ const bins: Array<Bin> = [];
11
+
12
+ list.forEach((item) => {
13
+ const category = dateCategory(new Date(item.timestamp));
14
+
15
+ if (!(category in binLookup)) {
16
+ const bin = {
17
+ category,
18
+ items: [item],
19
+ };
20
+
21
+ binLookup[category] = bin;
22
+ bins.push(bin);
23
+ } else {
24
+ binLookup[category].items.push(item);
25
+ }
26
+ });
27
+
28
+ return bins;
29
+ }
30
+
31
+ function dateCategory(date: Date) {
32
+ if (isToday(date)) {
33
+ return 'Today';
34
+ }
35
+
36
+ if (isYesterday(date)) {
37
+ return 'Yesterday';
38
+ }
39
+
40
+ if (isThisWeek(date)) {
41
+ return format(date, 'eeee'); // e.g. "Monday"
42
+ }
43
+
44
+ const thirtyDaysAgo = subDays(new Date(), 30);
45
+
46
+ if (isAfter(date, thirtyDaysAgo)) {
47
+ return 'Last 30 Days';
48
+ }
49
+
50
+ if (isThisYear(date)) {
51
+ return format(date, 'MMMM'); // e.g., "July"
52
+ }
53
+
54
+ return format(date, 'MMMM yyyy'); // e.g. "July 2023"
55
+ }
packages/bolt/app/lib/persistence/db.ts CHANGED
@@ -30,6 +30,17 @@ export async function openDatabase(): Promise<IDBDatabase | undefined> {
30
  });
31
  }
32
 
 
 
 
 
 
 
 
 
 
 
 
33
  export async function setMessages(
34
  db: IDBDatabase,
35
  id: string,
@@ -46,6 +57,7 @@ export async function setMessages(
46
  messages,
47
  urlId,
48
  description,
 
49
  });
50
 
51
  request.onsuccess = () => resolve();
@@ -80,13 +92,28 @@ export async function getMessagesById(db: IDBDatabase, id: string): Promise<Chat
80
  });
81
  }
82
 
 
 
 
 
 
 
 
 
 
 
 
83
  export async function getNextId(db: IDBDatabase): Promise<string> {
84
  return new Promise((resolve, reject) => {
85
  const transaction = db.transaction('chats', 'readonly');
86
  const store = transaction.objectStore('chats');
87
- const request = store.count();
 
 
 
 
 
88
 
89
- request.onsuccess = () => resolve(String(request.result));
90
  request.onerror = () => reject(request.error);
91
  });
92
  }
 
30
  });
31
  }
32
 
33
+ export async function getAll(db: IDBDatabase): Promise<ChatHistory[]> {
34
+ return new Promise((resolve, reject) => {
35
+ const transaction = db.transaction('chats', 'readonly');
36
+ const store = transaction.objectStore('chats');
37
+ const request = store.getAll();
38
+
39
+ request.onsuccess = () => resolve(request.result as ChatHistory[]);
40
+ request.onerror = () => reject(request.error);
41
+ });
42
+ }
43
+
44
  export async function setMessages(
45
  db: IDBDatabase,
46
  id: string,
 
57
  messages,
58
  urlId,
59
  description,
60
+ timestamp: new Date().toISOString(),
61
  });
62
 
63
  request.onsuccess = () => resolve();
 
92
  });
93
  }
94
 
95
+ export async function deleteId(db: IDBDatabase, id: string): Promise<void> {
96
+ return new Promise((resolve, reject) => {
97
+ const transaction = db.transaction('chats', 'readwrite');
98
+ const store = transaction.objectStore('chats');
99
+ const request = store.delete(id);
100
+
101
+ request.onsuccess = () => resolve(undefined);
102
+ request.onerror = () => reject(request.error);
103
+ });
104
+ }
105
+
106
  export async function getNextId(db: IDBDatabase): Promise<string> {
107
  return new Promise((resolve, reject) => {
108
  const transaction = db.transaction('chats', 'readonly');
109
  const store = transaction.objectStore('chats');
110
+ const request = store.getAllKeys();
111
+
112
+ request.onsuccess = () => {
113
+ const highestId = request.result.reduce((cur, acc) => Math.max(+cur, +acc), 0);
114
+ resolve(String(+highestId + 1));
115
+ };
116
 
 
117
  request.onerror = () => reject(request.error);
118
  });
119
  }
packages/bolt/app/lib/persistence/useChatHistory.ts CHANGED
@@ -10,6 +10,7 @@ export interface ChatHistory {
10
  urlId?: string;
11
  description?: string;
12
  messages: Message[];
 
13
  }
14
 
15
  const persistenceEnabled = !import.meta.env.VITE_DISABLE_PERSISTENCE;
 
10
  urlId?: string;
11
  description?: string;
12
  messages: Message[];
13
+ timestamp: string;
14
  }
15
 
16
  const persistenceEnabled = !import.meta.env.VITE_DISABLE_PERSISTENCE;
packages/bolt/app/styles/variables.scss CHANGED
@@ -96,8 +96,6 @@
96
  :root {
97
  --header-height: 65px;
98
 
99
- --z-index-max: 999;
100
-
101
  /* App */
102
  --bolt-elements-app-backgroundColor: var(--bolt-background-primary);
103
  --bolt-elements-app-borderColor: var(--bolt-border-primary);
 
96
  :root {
97
  --header-height: 65px;
98
 
 
 
99
  /* App */
100
  --bolt-elements-app-backgroundColor: var(--bolt-background-primary);
101
  --bolt-elements-app-borderColor: var(--bolt-border-primary);
packages/bolt/app/styles/z-index.scss CHANGED
@@ -1 +1,5 @@
1
  $zIndexMax: 999;
 
 
 
 
 
1
  $zIndexMax: 999;
2
+
3
+ .z-max {
4
+ z-index: $zIndexMax;
5
+ }
packages/bolt/package.json CHANGED
@@ -44,6 +44,7 @@
44
  "@xterm/addon-web-links": "^0.11.0",
45
  "@xterm/xterm": "^5.5.0",
46
  "ai": "^3.2.27",
 
47
  "diff": "^5.2.0",
48
  "framer-motion": "^11.2.12",
49
  "isbot": "^4.1.0",
 
44
  "@xterm/addon-web-links": "^0.11.0",
45
  "@xterm/xterm": "^5.5.0",
46
  "ai": "^3.2.27",
47
+ "date-fns": "^3.6.0",
48
  "diff": "^5.2.0",
49
  "framer-motion": "^11.2.12",
50
  "isbot": "^4.1.0",
pnpm-lock.yaml CHANGED
@@ -119,6 +119,9 @@ importers:
119
  ai:
120
  specifier: ^3.2.27
121
 
 
 
122
  diff:
123
  specifier: ^5.2.0
124
  version: 5.2.0
 
119
  ai:
120
  specifier: ^3.2.27
121
122
+ date-fns:
123
+ specifier: ^3.6.0
124
+ version: 3.6.0
125
  diff:
126
  specifier: ^5.2.0
127
  version: 5.2.0