diff --git a/app/components/chat/Messages.client.tsx b/app/components/chat/Messages.client.tsx index 2f35f49..fca6fbd 100644 --- a/app/components/chat/Messages.client.tsx +++ b/app/components/chat/Messages.client.tsx @@ -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; @@ -13,41 +18,112 @@ interface MessagesProps { export const Messages = React.forwardRef((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 ( -
- {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; + +
+ {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 ( -
- {isUserMessage && ( -
-
+ return ( +
+ {isUserMessage && ( +
+
+
+ )} +
+ {isUserMessage ? : }
- )} -
- {isUserMessage ? : } + {!isUserMessage && (
+ + + {messageId && (
)}
-
- ); - }) - : null} - {isStreaming && ( -
- )} -
+ ); + }) + : null} + {isStreaming && ( +
+ )} +
+ ); }); diff --git a/app/components/sidebar/HistoryItem.tsx b/app/components/sidebar/HistoryItem.tsx index 8022e4d..df270c8 100644 --- a/app/components/sidebar/HistoryItem.tsx +++ b/app/components/sidebar/HistoryItem.tsx @@ -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(null); @@ -44,7 +45,14 @@ export function HistoryItem({ item, onDelete }: HistoryItemProps) { {item.description}
{hovering && ( -
+
+ {onDuplicate && ( +
))} diff --git a/app/lib/persistence/db.ts b/app/lib/persistence/db.ts index 7a952e3..3aa2004 100644 --- a/app/lib/persistence/db.ts +++ b/app/lib/persistence/db.ts @@ -158,3 +158,50 @@ async function getUrlIds(db: IDBDatabase): Promise { }; }); } + +export async function forkChat(db: IDBDatabase, chatId: string, messageId: string): Promise { + 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 { + 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 +} diff --git a/app/lib/persistence/useChatHistory.ts b/app/lib/persistence/useChatHistory.ts index e562753..f5e8138 100644 --- a/app/lib/persistence/useChatHistory.ts +++ b/app/lib/persistence/useChatHistory.ts @@ -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; @@ -24,6 +24,7 @@ export const description = atom(undefined); export function useChatHistory() { const navigate = useNavigate(); const { id: mixedId } = useLoaderData<{ id?: string }>(); + const [searchParams] = useSearchParams(); const [initialMessages, setInitialMessages] = useState([]); const [ready, setReady] = useState(false); @@ -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); @@ -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'); + } + } }; } diff --git a/eslint.config.mjs b/eslint.config.mjs index 95df41e..123aaf1 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -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(), { diff --git a/package.json b/package.json index ce8e95d..40ede0f 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 82e14c1..4158d19 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -95,6 +95,9 @@ importers: '@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) + '@radix-ui/react-tooltip': + specifier: ^1.1.4 + version: 1.1.4(@types/react-dom@18.3.0)(@types/react@18.3.3)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) '@remix-run/cloudflare': specifier: ^2.10.2 version: 2.10.2(@cloudflare/workers-types@4.20240620.0)(typescript@5.5.2) @@ -1377,6 +1380,15 @@ packages: '@types/react': optional: true + '@radix-ui/react-context@1.1.1': + resolution: {integrity: sha512-UASk9zi+crv9WteK/NU4PLvOoL3OuE6BWVKNF6hPRBtYBDXQ2u5iu3O59zUlJiTVvkyuycnqrztsHVJwcK9K+Q==} + peerDependencies: + '@types/react': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@radix-ui/react-dialog@1.1.1': resolution: {integrity: sha512-zysS+iU4YP3STKNS6USvFVqI4qqx8EpiwmT5TuCApVEBca+eRCbONi4EgzfNSuVnOXvC5UPHHMjs8RXO6DH9Bg==} peerDependencies: @@ -1412,6 +1424,19 @@ packages: '@types/react-dom': optional: true + '@radix-ui/react-dismissable-layer@1.1.1': + resolution: {integrity: sha512-QSxg29lfr/xcev6kSz7MAlmDnzbP1eI/Dwn3Tp1ip0KT5CUELsxkekFEMVBEoykI3oV39hKT4TKZzBNMbcTZYQ==} + 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-dropdown-menu@2.1.1': resolution: {integrity: sha512-y8E+x9fBq9qvteD2Zwa4397pUVhYsh9iq44b5RD5qu1GMJWBCBuVg1hMyItbc6+zH00TxGRqd9Iot4wzf3OoBQ==} peerDependencies: @@ -1495,6 +1520,19 @@ packages: '@types/react-dom': optional: true + '@radix-ui/react-portal@1.1.2': + resolution: {integrity: sha512-WeDYLGPxJb/5EGBoedyJbT0MpoULmwnIPMJMSldkuiMsBAv7N1cRdsTWZWht9vpPOiN3qyiGAtbK2is47/uMFg==} + 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-presence@1.1.0': resolution: {integrity: sha512-Gq6wuRN/asf9H/E/VzdKoUtT8GC9PQc9z40/vEr0VCJ4u5XvvhWIrSsCB6vD2/cH7ugTdSfYq9fLJCcM00acrQ==} peerDependencies: @@ -1508,6 +1546,19 @@ packages: '@types/react-dom': optional: true + '@radix-ui/react-presence@1.1.1': + resolution: {integrity: sha512-IeFXVi4YS1K0wVZzXNrbaaUvIJ3qdY+/Ih4eHFhWA9SwGR9UDX7Ck8abvL57C4cv3wwMvUE0OG69Qc3NCcTe/A==} + 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-primitive@2.0.0': resolution: {integrity: sha512-ZSpFm0/uHa8zTvKBDjLFWLo8dkr4MBsiDLz0g3gMUwqgLHz9rTaRRGYDgvZPtBJgYCBKXkS9fzmoySgr8CO6Cw==} peerDependencies: @@ -1543,6 +1594,19 @@ packages: '@types/react': optional: true + '@radix-ui/react-tooltip@1.1.4': + resolution: {integrity: sha512-QpObUH/ZlpaO4YgHSaYzrLO2VuO+ZBFFgGzjMUPwtiYnAzzNNDPJeEGRrT7qNOrWm/Jr08M1vlp+vTHtnSQ0Uw==} + 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-use-callback-ref@1.1.0': resolution: {integrity: sha512-CasTfvsy+frcFkbXtSJ2Zu9JHpN8TYKxkgJGWbjiZhFivxaeW7rMeZt7QELGVLaYVfFMsKHjb7Ak0nMEe+2Vfw==} peerDependencies: @@ -1597,6 +1661,19 @@ packages: '@types/react': optional: true + '@radix-ui/react-visually-hidden@1.1.0': + resolution: {integrity: sha512-N8MDZqtgCgG5S3aV60INAB475osJousYpZ4cTJ2cFbMpdHS5Y6loLTH8LPtkj2QN0x93J30HT/M3qJXM0+lyeQ==} + 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/rect@1.1.0': resolution: {integrity: sha512-A9+lCBZoaMJlVKcRBz2YByCG+Cp2t6nAnMnNba+XiWxnj6r4JUFqfsgwocMBZU9LPtdxC6wB56ySYpc7LQIoJg==} @@ -6712,6 +6789,12 @@ snapshots: optionalDependencies: '@types/react': 18.3.3 + '@radix-ui/react-context@1.1.1(@types/react@18.3.3)(react@18.3.1)': + dependencies: + react: 18.3.1 + 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 @@ -6753,6 +6836,19 @@ snapshots: '@types/react': 18.3.3 '@types/react-dom': 18.3.0 + '@radix-ui/react-dismissable-layer@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-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-use-callback-ref': 1.1.0(@types/react@18.3.3)(react@18.3.1) + '@radix-ui/react-use-escape-keydown': 1.1.0(@types/react@18.3.3)(react@18.3.1) + react: 18.3.1 + react-dom: 18.3.1(react@18.3.1) + optionalDependencies: + '@types/react': 18.3.3 + '@types/react-dom': 18.3.0 + '@radix-ui/react-dropdown-menu@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)': dependencies: '@radix-ui/primitive': 1.1.0 @@ -6846,6 +6942,16 @@ snapshots: '@types/react': 18.3.3 '@types/react-dom': 18.3.0 + '@radix-ui/react-portal@1.1.2(@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/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-use-layout-effect': 1.1.0(@types/react@18.3.3)(react@18.3.1) + react: 18.3.1 + react-dom: 18.3.1(react@18.3.1) + optionalDependencies: + '@types/react': 18.3.3 + '@types/react-dom': 18.3.0 + '@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)': dependencies: '@radix-ui/react-compose-refs': 1.1.0(@types/react@18.3.3)(react@18.3.1) @@ -6856,6 +6962,16 @@ snapshots: '@types/react': 18.3.3 '@types/react-dom': 18.3.0 + '@radix-ui/react-presence@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/react-compose-refs': 1.1.0(@types/react@18.3.3)(react@18.3.1) + '@radix-ui/react-use-layout-effect': 1.1.0(@types/react@18.3.3)(react@18.3.1) + react: 18.3.1 + react-dom: 18.3.1(react@18.3.1) + optionalDependencies: + '@types/react': 18.3.3 + '@types/react-dom': 18.3.0 + '@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)': dependencies: '@radix-ui/react-slot': 1.1.0(@types/react@18.3.3)(react@18.3.1) @@ -6889,6 +7005,26 @@ snapshots: optionalDependencies: '@types/react': 18.3.3 + '@radix-ui/react-tooltip@1.1.4(@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.1(@types/react@18.3.3)(react@18.3.1) + '@radix-ui/react-dismissable-layer': 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-id': 1.1.0(@types/react@18.3.3)(react@18.3.1) + '@radix-ui/react-popper': 1.2.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-portal': 1.1.2(@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.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-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) + '@radix-ui/react-visually-hidden': 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) + react: 18.3.1 + react-dom: 18.3.1(react@18.3.1) + optionalDependencies: + '@types/react': 18.3.3 + '@types/react-dom': 18.3.0 + '@radix-ui/react-use-callback-ref@1.1.0(@types/react@18.3.3)(react@18.3.1)': dependencies: react: 18.3.1 @@ -6929,6 +7065,15 @@ snapshots: optionalDependencies: '@types/react': 18.3.3 + '@radix-ui/react-visually-hidden@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)': + dependencies: + '@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) + react: 18.3.1 + react-dom: 18.3.1(react@18.3.1) + optionalDependencies: + '@types/react': 18.3.3 + '@types/react-dom': 18.3.0 + '@radix-ui/rect@1.1.0': {} '@remix-run/cloudflare-pages@2.10.2(@cloudflare/workers-types@4.20240620.0)(typescript@5.5.2)':