diff --git a/packages/bolt/app/components/sidebar/HistoryItem.tsx b/packages/bolt/app/components/sidebar/HistoryItem.tsx index 9848620..8022e4d 100644 --- a/packages/bolt/app/components/sidebar/HistoryItem.tsx +++ b/packages/bolt/app/components/sidebar/HistoryItem.tsx @@ -1,26 +1,16 @@ -import { useCallback, useEffect, useRef, useState } from 'react'; -import { toast } from 'react-toastify'; -import { db, deleteId, type ChatHistory } from '~/lib/persistence'; -import { logger } from '~/utils/logger'; +import * as Dialog from '@radix-ui/react-dialog'; +import { useEffect, useRef, useState } from 'react'; +import { type ChatHistoryItem } from '~/lib/persistence'; -export function HistoryItem({ item, loadEntries }: { item: ChatHistory; loadEntries: () => void }) { - const [requestingDelete, setRequestingDelete] = useState(false); +interface HistoryItemProps { + item: ChatHistoryItem; + onDelete?: (event: React.UIEvent) => void; +} + +export function HistoryItem({ item, onDelete }: HistoryItemProps) { const [hovering, setHovering] = useState(false); const hoverRef = useRef(null); - const deleteItem = useCallback((event: React.UIEvent) => { - event.preventDefault(); - - if (db) { - deleteId(db, item.id) - .then(() => loadEntries()) - .catch((error) => { - toast.error('Failed to delete conversation'); - logger.error(error); - }); - } - }, []); - useEffect(() => { let timeout: NodeJS.Timeout | undefined; @@ -34,11 +24,6 @@ export function HistoryItem({ item, loadEntries }: { item: ChatHistory; loadEntr function mouseLeave() { setHovering(false); - - // wait for animation to finish before unsetting - timeout = setTimeout(() => { - setRequestingDelete(false); - }, 200); } hoverRef.current?.addEventListener('mouseenter', mouseEnter); @@ -59,18 +44,17 @@ export function HistoryItem({ item, loadEntries }: { item: ChatHistory; loadEntr {item.description}
{hovering && ( -
- {requestingDelete ? ( -
)}
diff --git a/packages/bolt/app/components/sidebar/Menu.client.tsx b/packages/bolt/app/components/sidebar/Menu.client.tsx index 7b00200..3438214 100644 --- a/packages/bolt/app/components/sidebar/Menu.client.tsx +++ b/packages/bolt/app/components/sidebar/Menu.client.tsx @@ -1,10 +1,12 @@ import { motion, type Variants } from 'framer-motion'; import { useCallback, useEffect, useRef, useState } from 'react'; import { toast } from 'react-toastify'; +import { Dialog, DialogButton, DialogDescription, DialogRoot, DialogTitle } from '~/components/ui/Dialog'; import { IconButton } from '~/components/ui/IconButton'; import { ThemeSwitch } from '~/components/ui/ThemeSwitch'; -import { db, getAll, type ChatHistory } from '~/lib/persistence'; +import { db, deleteId, getAll, type ChatHistoryItem } from '~/lib/persistence'; import { cubicEasingFn } from '~/utils/easings'; +import { logger } from '~/utils/logger'; import { HistoryItem } from './HistoryItem'; import { binDates } from './date-binning'; @@ -29,10 +31,13 @@ const menuVariants = { }, } satisfies Variants; +type DialogContent = { type: 'delete'; item: ChatHistoryItem } | null; + export function Menu() { const menuRef = useRef(null); - const [list, setList] = useState([]); + const [list, setList] = useState([]); const [open, setOpen] = useState(false); + const [dialogContent, setDialogContent] = useState(null); const loadEntries = useCallback(() => { if (db) { @@ -43,6 +48,23 @@ export function Menu() { } }, []); + const deleteItem = useCallback((event: React.UIEvent, item: ChatHistoryItem) => { + event.preventDefault(); + + if (db) { + deleteId(db, item.id) + .then(() => loadEntries()) + .catch((error) => { + toast.error('Failed to delete conversation'); + logger.error(error); + }); + } + }, []); + + const closeDialog = () => { + setDialogContent(null); + }; + useEffect(() => { if (open) { loadEntries(); @@ -92,16 +114,47 @@ export function Menu() {
Your Chats
{list.length === 0 &&
No previous conversations
} - {binDates(list).map(({ category, items }) => ( -
-
- {category} + + {binDates(list).map(({ category, items }) => ( +
+
+ {category} +
+ {items.map((item) => ( + setDialogContent({ type: 'delete', item })} /> + ))}
- {items.map((item) => ( - - ))} -
- ))} + ))} + + {dialogContent?.type === 'delete' && ( + <> + Delete Chat? + +
+

+ You are about to delete {dialogContent.item.description}. +

+

Are you sure you want to delete this chat?

+
+
+
+ + Cancel + + { + deleteItem(event, dialogContent.item); + closeDialog(); + }} + > + Delete + +
+ + )} +
+
diff --git a/packages/bolt/app/components/sidebar/date-binning.ts b/packages/bolt/app/components/sidebar/date-binning.ts index 35cf453..0a364ff 100644 --- a/packages/bolt/app/components/sidebar/date-binning.ts +++ b/packages/bolt/app/components/sidebar/date-binning.ts @@ -1,9 +1,9 @@ import { format, isAfter, isThisWeek, isThisYear, isToday, isYesterday, subDays } from 'date-fns'; -import type { ChatHistory } from '~/lib/persistence'; +import type { ChatHistoryItem } from '~/lib/persistence'; -type Bin = { category: string; items: ChatHistory[] }; +type Bin = { category: string; items: ChatHistoryItem[] }; -export function binDates(_list: ChatHistory[]) { +export function binDates(_list: ChatHistoryItem[]) { const list = _list.toSorted((a, b) => Date.parse(b.timestamp) - Date.parse(a.timestamp)); const binLookup: Record = {}; diff --git a/packages/bolt/app/components/ui/Dialog.tsx b/packages/bolt/app/components/ui/Dialog.tsx new file mode 100644 index 0000000..a808c77 --- /dev/null +++ b/packages/bolt/app/components/ui/Dialog.tsx @@ -0,0 +1,133 @@ +import * as RadixDialog from '@radix-ui/react-dialog'; +import { motion, type Variants } from 'framer-motion'; +import React, { memo, type ReactNode } from 'react'; +import { classNames } from '~/utils/classNames'; +import { cubicEasingFn } from '~/utils/easings'; +import { IconButton } from './IconButton'; + +export { Close as DialogClose, Root as DialogRoot } from '@radix-ui/react-dialog'; + +const transition = { + duration: 0.15, + ease: cubicEasingFn, +}; + +export const dialogBackdropVariants = { + closed: { + opacity: 0, + transition, + }, + open: { + opacity: 1, + transition, + }, +} satisfies Variants; + +export const dialogVariants = { + closed: { + x: '-50%', + y: '-40%', + scale: 0.96, + opacity: 0, + transition, + }, + open: { + x: '-50%', + y: '-50%', + scale: 1, + opacity: 1, + transition, + }, +} satisfies Variants; + +interface DialogButtonProps { + type: 'primary' | 'secondary' | 'danger'; + children: ReactNode; + onClick?: (event: React.UIEvent) => void; +} + +export const DialogButton = memo(({ type, children, onClick }: DialogButtonProps) => { + return ( + + ); +}); + +export const DialogTitle = memo(({ className, children, ...props }: RadixDialog.DialogTitleProps) => { + return ( + + {children} + + ); +}); + +export const DialogDescription = memo(({ className, children, ...props }: RadixDialog.DialogDescriptionProps) => { + return ( + + {children} + + ); +}); + +interface DialogProps { + children: ReactNode | ReactNode[]; + className?: string; + onBackdrop?: (event: React.UIEvent) => void; + onClose?: (event: React.UIEvent) => void; +} + +export const Dialog = memo(({ className, children, onBackdrop, onClose }: DialogProps) => { + return ( + + + + + + + {children} + + + + + + + ); +}); diff --git a/packages/bolt/app/lib/persistence/db.ts b/packages/bolt/app/lib/persistence/db.ts index fd35e1c..786ad91 100644 --- a/packages/bolt/app/lib/persistence/db.ts +++ b/packages/bolt/app/lib/persistence/db.ts @@ -1,6 +1,6 @@ import type { Message } from 'ai'; -import type { ChatHistory } from './useChatHistory'; import { createScopedLogger } from '~/utils/logger'; +import type { ChatHistoryItem } from './useChatHistory'; const logger = createScopedLogger('ChatHistory'); @@ -30,13 +30,13 @@ export async function openDatabase(): Promise { }); } -export async function getAll(db: IDBDatabase): 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.onsuccess = () => resolve(request.result as ChatHistoryItem[]); request.onerror = () => reject(request.error); }); } @@ -65,29 +65,29 @@ export async function setMessages( }); } -export async function getMessages(db: IDBDatabase, id: string): Promise { +export async function getMessages(db: IDBDatabase, id: string): Promise { return (await getMessagesById(db, id)) || (await getMessagesByUrlId(db, id)); } -export async function getMessagesByUrlId(db: IDBDatabase, id: string): Promise { +export async function getMessagesByUrlId(db: IDBDatabase, id: string): Promise { return new Promise((resolve, reject) => { const transaction = db.transaction('chats', 'readonly'); const store = transaction.objectStore('chats'); const index = store.index('urlId'); const request = index.get(id); - request.onsuccess = () => resolve(request.result as ChatHistory); + request.onsuccess = () => resolve(request.result as ChatHistoryItem); request.onerror = () => reject(request.error); }); } -export async function getMessagesById(db: IDBDatabase, id: string): Promise { +export async function getMessagesById(db: IDBDatabase, id: string): Promise { return new Promise((resolve, reject) => { const transaction = db.transaction('chats', 'readonly'); const store = transaction.objectStore('chats'); const request = store.get(id); - request.onsuccess = () => resolve(request.result as ChatHistory); + request.onsuccess = () => resolve(request.result as ChatHistoryItem); request.onerror = () => reject(request.error); }); } diff --git a/packages/bolt/app/lib/persistence/useChatHistory.ts b/packages/bolt/app/lib/persistence/useChatHistory.ts index 8634aac..3b5e181 100644 --- a/packages/bolt/app/lib/persistence/useChatHistory.ts +++ b/packages/bolt/app/lib/persistence/useChatHistory.ts @@ -1,12 +1,12 @@ -import { useNavigate, useLoaderData } from '@remix-run/react'; -import { useState, useEffect } from 'react'; +import { useLoaderData, useNavigate } from '@remix-run/react'; import type { Message } from 'ai'; -import { openDatabase, setMessages, getMessages, getNextId, getUrlId } from './db'; +import { useEffect, useState } from 'react'; import { toast } from 'react-toastify'; +import { AnalyticsAction, sendAnalyticsEvent } from '~/lib/analytics'; import { workbenchStore } from '~/lib/stores/workbench'; -import { sendAnalyticsEvent, AnalyticsAction } from '~/lib/analytics'; +import { getMessages, getNextId, getUrlId, openDatabase, setMessages } from './db'; -export interface ChatHistory { +export interface ChatHistoryItem { id: string; urlId?: string; description?: string; diff --git a/packages/bolt/app/styles/variables.scss b/packages/bolt/app/styles/variables.scss index 78412de..38967b1 100644 --- a/packages/bolt/app/styles/variables.scss +++ b/packages/bolt/app/styles/variables.scss @@ -16,6 +16,18 @@ --bolt-elements-code-background: theme('colors.gray.100'); --bolt-elements-code-text: theme('colors.gray.950'); + --bolt-elements-button-primary-background: theme('colors.alpha.accent.10'); + --bolt-elements-button-primary-backgroundHover: theme('colors.alpha.accent.20'); + --bolt-elements-button-primary-text: theme('colors.accent.500'); + + --bolt-elements-button-secondary-background: theme('colors.alpha.gray.5'); + --bolt-elements-button-secondary-backgroundHover: theme('colors.alpha.gray.10'); + --bolt-elements-button-secondary-text: theme('colors.gray.950'); + + --bolt-elements-button-danger-background: theme('colors.alpha.red.10'); + --bolt-elements-button-danger-backgroundHover: theme('colors.alpha.red.20'); + --bolt-elements-button-danger-text: theme('colors.red.500'); + --bolt-elements-item-contentDefault: theme('colors.alpha.gray.50'); --bolt-elements-item-contentActive: theme('colors.gray.950'); --bolt-elements-item-contentAccent: theme('colors.accent.700'); @@ -110,6 +122,18 @@ --bolt-elements-code-background: theme('colors.gray.800'); --bolt-elements-code-text: theme('colors.white'); + --bolt-elements-button-primary-background: theme('colors.alpha.accent.10'); + --bolt-elements-button-primary-backgroundHover: theme('colors.alpha.accent.20'); + --bolt-elements-button-primary-text: theme('colors.accent.500'); + + --bolt-elements-button-secondary-background: theme('colors.alpha.white.5'); + --bolt-elements-button-secondary-backgroundHover: theme('colors.alpha.white.10'); + --bolt-elements-button-secondary-text: theme('colors.white'); + + --bolt-elements-button-danger-background: theme('colors.alpha.red.10'); + --bolt-elements-button-danger-backgroundHover: theme('colors.alpha.red.20'); + --bolt-elements-button-danger-text: theme('colors.red.500'); + --bolt-elements-item-contentDefault: theme('colors.alpha.white.50'); --bolt-elements-item-contentActive: theme('colors.white'); --bolt-elements-item-contentAccent: theme('colors.accent.500'); diff --git a/packages/bolt/package.json b/packages/bolt/package.json index 857ba1d..e685b8c 100644 --- a/packages/bolt/package.json +++ b/packages/bolt/package.json @@ -36,6 +36,7 @@ "@iconify-json/svg-spinners": "^1.1.2", "@lezer/highlight": "^1.2.0", "@nanostores/react": "^0.7.2", + "@radix-ui/react-dialog": "^1.1.1", "@radix-ui/react-dropdown-menu": "^2.1.1", "@remix-run/cloudflare": "^2.10.2", "@remix-run/cloudflare-pages": "^2.10.2", diff --git a/packages/bolt/uno.config.ts b/packages/bolt/uno.config.ts index 664d058..503e1af 100644 --- a/packages/bolt/uno.config.ts +++ b/packages/bolt/uno.config.ts @@ -133,6 +133,23 @@ export default defineConfig({ background: 'var(--bolt-elements-code-background)', text: 'var(--bolt-elements-code-text)', }, + button: { + primary: { + background: 'var(--bolt-elements-button-primary-background)', + backgroundHover: 'var(--bolt-elements-button-primary-backgroundHover)', + text: 'var(--bolt-elements-button-primary-text)', + }, + secondary: { + background: 'var(--bolt-elements-button-secondary-background)', + backgroundHover: 'var(--bolt-elements-button-secondary-backgroundHover)', + text: 'var(--bolt-elements-button-secondary-text)', + }, + danger: { + background: 'var(--bolt-elements-button-danger-background)', + backgroundHover: 'var(--bolt-elements-button-danger-backgroundHover)', + text: 'var(--bolt-elements-button-danger-text)', + }, + }, item: { contentDefault: 'var(--bolt-elements-item-contentDefault)', contentActive: 'var(--bolt-elements-item-contentActive)', diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 50a1718..a6481ed 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -95,6 +95,9 @@ importers: '@nanostores/react': specifier: ^0.7.2 version: 0.7.2(nanostores@0.10.3)(react@18.3.1) + '@radix-ui/react-dialog': + specifier: ^1.1.1 + version: 1.1.1(@types/react-dom@18.3.0)(@types/react@18.3.3)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) '@radix-ui/react-dropdown-menu': specifier: ^2.1.1 version: 2.1.1(@types/react-dom@18.3.0)(@types/react@18.3.3)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) @@ -1296,6 +1299,19 @@ packages: '@types/react': optional: true + '@radix-ui/react-dialog@1.1.1': + resolution: {integrity: sha512-zysS+iU4YP3STKNS6USvFVqI4qqx8EpiwmT5TuCApVEBca+eRCbONi4EgzfNSuVnOXvC5UPHHMjs8RXO6DH9Bg==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + '@radix-ui/react-direction@1.1.0': resolution: {integrity: sha512-BUuBvgThEiAXh2DWu93XsT+a3aWrGqolGlqqw5VU1kG7p/ZH2cuDlM1sRLNnY3QcBS69UIz2mcKhMxDsdewhjg==} peerDependencies: @@ -6597,6 +6613,28 @@ snapshots: optionalDependencies: '@types/react': 18.3.3 + '@radix-ui/react-dialog@1.1.1(@types/react-dom@18.3.0)(@types/react@18.3.3)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': + dependencies: + '@radix-ui/primitive': 1.1.0 + '@radix-ui/react-compose-refs': 1.1.0(@types/react@18.3.3)(react@18.3.1) + '@radix-ui/react-context': 1.1.0(@types/react@18.3.3)(react@18.3.1) + '@radix-ui/react-dismissable-layer': 1.1.0(@types/react-dom@18.3.0)(@types/react@18.3.3)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@radix-ui/react-focus-guards': 1.1.0(@types/react@18.3.3)(react@18.3.1) + '@radix-ui/react-focus-scope': 1.1.0(@types/react-dom@18.3.0)(@types/react@18.3.3)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@radix-ui/react-id': 1.1.0(@types/react@18.3.3)(react@18.3.1) + '@radix-ui/react-portal': 1.1.1(@types/react-dom@18.3.0)(@types/react@18.3.3)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@radix-ui/react-presence': 1.1.0(@types/react-dom@18.3.0)(@types/react@18.3.3)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@radix-ui/react-primitive': 2.0.0(@types/react-dom@18.3.0)(@types/react@18.3.3)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@radix-ui/react-slot': 1.1.0(@types/react@18.3.3)(react@18.3.1) + '@radix-ui/react-use-controllable-state': 1.1.0(@types/react@18.3.3)(react@18.3.1) + aria-hidden: 1.2.4 + react: 18.3.1 + react-dom: 18.3.1(react@18.3.1) + react-remove-scroll: 2.5.7(@types/react@18.3.3)(react@18.3.1) + optionalDependencies: + '@types/react': 18.3.3 + '@types/react-dom': 18.3.0 + '@radix-ui/react-direction@1.1.0(@types/react@18.3.3)(react@18.3.1)': dependencies: react: 18.3.1