Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

(Ready for review and merge) Add Chat Rewind, Duplicate, and Fork Features #305

Merged
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
140 changes: 108 additions & 32 deletions app/components/chat/Messages.client.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,11 @@ import React from 'react';
import { classNames } from '~/utils/classNames';
import { AssistantMessage } from './AssistantMessage';
import { UserMessage } from './UserMessage';
import * as Tooltip from '@radix-ui/react-tooltip';
import { useLocation, useNavigate } from '@remix-run/react';
import { db, chatId } from '~/lib/persistence/useChatHistory';
import { forkChat } from '~/lib/persistence/db';
import { toast } from 'react-toastify';

interface MessagesProps {
id?: string;
Expand All @@ -13,41 +18,112 @@ interface MessagesProps {

export const Messages = React.forwardRef<HTMLDivElement, MessagesProps>((props: MessagesProps, ref) => {
const { id, isStreaming = false, messages = [] } = props;
const location = useLocation();
const navigate = useNavigate();

const handleRewind = (messageId: string) => {
const searchParams = new URLSearchParams(location.search);
searchParams.set('rewindTo', messageId);
window.location.search = searchParams.toString();
};

const handleFork = async (messageId: string) => {
try {
if (!db || !chatId.get()) {
toast.error('Chat persistence is not available');
return;
}

const urlId = await forkChat(db, chatId.get()!, messageId);
window.location.href = `/chat/${urlId}`;
} catch (error) {
toast.error('Failed to fork chat: ' + (error as Error).message);
}
};

return (
<div id={id} ref={ref} className={props.className}>
{messages.length > 0
? messages.map((message, index) => {
const { role, content } = message;
const isUserMessage = role === 'user';
const isFirst = index === 0;
const isLast = index === messages.length - 1;

return (
<div
key={index}
className={classNames('flex gap-4 p-6 w-full rounded-[calc(0.75rem-1px)]', {
'bg-bolt-elements-messages-background': isUserMessage || !isStreaming || (isStreaming && !isLast),
'bg-gradient-to-b from-bolt-elements-messages-background from-30% to-transparent':
isStreaming && isLast,
'mt-4': !isFirst,
})}
>
{isUserMessage && (
<div className="flex items-center justify-center w-[34px] h-[34px] overflow-hidden bg-white text-gray-600 rounded-full shrink-0 self-start">
<div className="i-ph:user-fill text-xl"></div>
<Tooltip.Provider delayDuration={200}>
<div id={id} ref={ref} className={props.className}>
{messages.length > 0
? messages.map((message, index) => {
const { role, content, id: messageId } = message;
const isUserMessage = role === 'user';
const isFirst = index === 0;
const isLast = index === messages.length - 1;

return (
<div
key={index}
className={classNames('flex gap-4 p-6 w-full rounded-[calc(0.75rem-1px)]', {
'bg-bolt-elements-messages-background': isUserMessage || !isStreaming || (isStreaming && !isLast),
'bg-gradient-to-b from-bolt-elements-messages-background from-30% to-transparent':
isStreaming && isLast,
'mt-4': !isFirst,
})}
>
{isUserMessage && (
<div className="flex items-center justify-center w-[34px] h-[34px] overflow-hidden bg-white text-gray-600 rounded-full shrink-0 self-start">
<div className="i-ph:user-fill text-xl"></div>
</div>
)}
<div className="grid grid-col-1 w-full">
{isUserMessage ? <UserMessage content={content} /> : <AssistantMessage content={content} />}
</div>
)}
<div className="grid grid-col-1 w-full">
{isUserMessage ? <UserMessage content={content} /> : <AssistantMessage content={content} />}
{!isUserMessage && (<div className="flex gap-2">
<Tooltip.Root>
<Tooltip.Trigger asChild>
{messageId && (<button
onClick={() => handleRewind(messageId)}
key='i-ph:arrow-u-up-left'
className={classNames(
'i-ph:arrow-u-up-left',
'text-xl text-bolt-elements-textSecondary hover:text-bolt-elements-textPrimary transition-colors'
)}
/>)}
</Tooltip.Trigger>
<Tooltip.Portal>
<Tooltip.Content
className="bg-bolt-elements-tooltip-background text-bolt-elements-textPrimary px-3 py-2 rounded-lg text-sm shadow-lg"
sideOffset={5}
style={{zIndex: 1000}}
>
Revert to this message
<Tooltip.Arrow className="fill-bolt-elements-tooltip-background" />
</Tooltip.Content>
</Tooltip.Portal>
</Tooltip.Root>

<Tooltip.Root>
<Tooltip.Trigger asChild>
<button
onClick={() => handleFork(messageId)}
key='i-ph:git-fork'
className={classNames(
'i-ph:git-fork',
'text-xl text-bolt-elements-textSecondary hover:text-bolt-elements-textPrimary transition-colors'
)}
/>
</Tooltip.Trigger>
<Tooltip.Portal>
<Tooltip.Content
className="bg-bolt-elements-tooltip-background text-bolt-elements-textPrimary px-3 py-2 rounded-lg text-sm shadow-lg"
sideOffset={5}
style={{zIndex: 1000}}
>
Fork chat from this message
<Tooltip.Arrow className="fill-bolt-elements-tooltip-background" />
</Tooltip.Content>
</Tooltip.Portal>
</Tooltip.Root>
</div>)}
</div>
</div>
);
})
: null}
{isStreaming && (
<div className="text-center w-full text-bolt-elements-textSecondary i-svg-spinners:3-dots-fade text-4xl mt-4"></div>
)}
</div>
);
})
: null}
{isStreaming && (
<div className="text-center w-full text-bolt-elements-textSecondary i-svg-spinners:3-dots-fade text-4xl mt-4"></div>
)}
</div>
</Tooltip.Provider>
);
});
12 changes: 10 additions & 2 deletions app/components/sidebar/HistoryItem.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,9 +5,10 @@ import { type ChatHistoryItem } from '~/lib/persistence';
interface HistoryItemProps {
item: ChatHistoryItem;
onDelete?: (event: React.UIEvent) => void;
onDuplicate?: (id: string) => void;
}

export function HistoryItem({ item, onDelete }: HistoryItemProps) {
export function HistoryItem({ item, onDelete, onDuplicate }: HistoryItemProps) {
const [hovering, setHovering] = useState(false);
const hoverRef = useRef<HTMLDivElement>(null);

Expand Down Expand Up @@ -44,7 +45,14 @@ export function HistoryItem({ item, onDelete }: HistoryItemProps) {
{item.description}
<div className="absolute right-0 z-1 top-0 bottom-0 bg-gradient-to-l from-bolt-elements-background-depth-2 group-hover:from-bolt-elements-background-depth-3 to-transparent w-10 flex justify-end group-hover:w-15 group-hover:from-45%">
{hovering && (
<div className="flex items-center p-1 text-bolt-elements-textSecondary hover:text-bolt-elements-item-contentDanger">
<div className="flex items-center p-1 text-bolt-elements-textSecondary">
{onDuplicate && (
<button
className="i-ph:copy scale-110 mr-2"
onClick={() => onDuplicate?.(item.id)}
title="Duplicate chat"
/>
)}
<Dialog.Trigger asChild>
<button
className="i-ph:trash scale-110"
Expand Down
21 changes: 19 additions & 2 deletions app/components/sidebar/Menu.client.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ 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, deleteById, getAll, chatId, type ChatHistoryItem } from '~/lib/persistence';
import { db, deleteById, getAll, chatId, type ChatHistoryItem, useChatHistory } from '~/lib/persistence';
import { cubicEasingFn } from '~/utils/easings';
import { logger } from '~/utils/logger';
import { HistoryItem } from './HistoryItem';
Expand Down Expand Up @@ -34,6 +34,7 @@ const menuVariants = {
type DialogContent = { type: 'delete'; item: ChatHistoryItem } | null;

export function Menu() {
const { duplicateCurrentChat } = useChatHistory();
const menuRef = useRef<HTMLDivElement>(null);
const [list, setList] = useState<ChatHistoryItem[]>([]);
const [open, setOpen] = useState(false);
Expand Down Expand Up @@ -99,6 +100,17 @@ export function Menu() {
};
}, []);

const handleDeleteClick = (event: React.UIEvent, item: ChatHistoryItem) => {
event.preventDefault();

setDialogContent({ type: 'delete', item });
};

const handleDuplicate = async (id: string) => {
await duplicateCurrentChat(id);
loadEntries(); // Reload the list after duplication
};

return (
<motion.div
ref={menuRef}
Expand Down Expand Up @@ -128,7 +140,12 @@ export function Menu() {
{category}
</div>
{items.map((item) => (
<HistoryItem key={item.id} item={item} onDelete={() => setDialogContent({ type: 'delete', item })} />
<HistoryItem
key={item.id}
item={item}
onDelete={(event) => handleDeleteClick(event, item)}
onDuplicate={() => handleDuplicate(item.id)}
/>
))}
</div>
))}
Expand Down
47 changes: 47 additions & 0 deletions app/lib/persistence/db.ts
Original file line number Diff line number Diff line change
Expand Up @@ -158,3 +158,50 @@ async function getUrlIds(db: IDBDatabase): Promise<string[]> {
};
});
}

export async function forkChat(db: IDBDatabase, chatId: string, messageId: string): Promise<string> {
const chat = await getMessages(db, chatId);
if (!chat) throw new Error('Chat not found');

// Find the index of the message to fork at
const messageIndex = chat.messages.findIndex(msg => msg.id === messageId);
if (messageIndex === -1) throw new Error('Message not found');

// Get messages up to and including the selected message
const messages = chat.messages.slice(0, messageIndex + 1);

// Generate new IDs
const newId = await getNextId(db);
const urlId = await getUrlId(db, newId);

// Create the forked chat
await setMessages(
db,
newId,
messages,
urlId,
chat.description ? `${chat.description} (fork)` : 'Forked chat'
);

return urlId;
}

export async function duplicateChat(db: IDBDatabase, id: string): Promise<string> {
const chat = await getMessages(db, id);
if (!chat) {
throw new Error('Chat not found');
}

const newId = await getNextId(db);
const newUrlId = await getUrlId(db, newId); // Get a new urlId for the duplicated chat

await setMessages(
db,
newId,
chat.messages,
newUrlId, // Use the new urlId
`${chat.description || 'Chat'} (copy)`
);

return newUrlId; // Return the urlId instead of id for navigation
}
25 changes: 22 additions & 3 deletions app/lib/persistence/useChatHistory.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,10 @@
import { useLoaderData, useNavigate } from '@remix-run/react';
import { useLoaderData, useNavigate, useSearchParams } from '@remix-run/react';
import { useState, useEffect } from 'react';
import { atom } from 'nanostores';
import type { Message } from 'ai';
import { toast } from 'react-toastify';
import { workbenchStore } from '~/lib/stores/workbench';
import { getMessages, getNextId, getUrlId, openDatabase, setMessages } from './db';
import { getMessages, getNextId, getUrlId, openDatabase, setMessages, duplicateChat } from './db';

export interface ChatHistoryItem {
id: string;
Expand All @@ -24,6 +24,7 @@ export const description = atom<string | undefined>(undefined);
export function useChatHistory() {
const navigate = useNavigate();
const { id: mixedId } = useLoaderData<{ id?: string }>();
const [searchParams] = useSearchParams();

const [initialMessages, setInitialMessages] = useState<Message[]>([]);
const [ready, setReady] = useState<boolean>(false);
Expand All @@ -44,7 +45,12 @@ export function useChatHistory() {
getMessages(db, mixedId)
.then((storedMessages) => {
if (storedMessages && storedMessages.messages.length > 0) {
setInitialMessages(storedMessages.messages);
const rewindId = searchParams.get('rewindTo');
const filteredMessages = rewindId
? storedMessages.messages.slice(0, storedMessages.messages.findIndex((m) => m.id === rewindId) + 1)
: storedMessages.messages;

setInitialMessages(filteredMessages);
setUrlId(storedMessages.urlId);
description.set(storedMessages.description);
chatId.set(storedMessages.id);
Expand Down Expand Up @@ -93,6 +99,19 @@ export function useChatHistory() {

await setMessages(db, chatId.get() as string, messages, urlId, description.get());
},
duplicateCurrentChat: async (listItemId:string) => {
if (!db || (!mixedId && !listItemId)) {
return;
}

try {
const newId = await duplicateChat(db, mixedId || listItemId);
navigate(`/chat/${newId}`);
toast.success('Chat duplicated successfully');
} catch (error) {
toast.error('Failed to duplicate chat');
}
}
};
}

Expand Down
2 changes: 1 addition & 1 deletion eslint.config.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ import { getNamingConventionRule, tsFileExtensions } from '@blitz/eslint-plugin/

export default [
{
ignores: ['**/dist', '**/node_modules', '**/.wrangler', '**/bolt/build'],
ignores: ['**/dist', '**/node_modules', '**/.wrangler', '**/bolt/build', '**/.history'],
},
...blitzPlugin.configs.recommended(),
{
Expand Down
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,7 @@
"@openrouter/ai-sdk-provider": "^0.0.5",
"@radix-ui/react-dialog": "^1.1.1",
"@radix-ui/react-dropdown-menu": "^2.1.1",
"@radix-ui/react-tooltip": "^1.1.4",
"@remix-run/cloudflare": "^2.10.2",
"@remix-run/cloudflare-pages": "^2.10.2",
"@remix-run/react": "^2.10.2",
Expand Down
Loading