From ae01628a9bf8fe3c5ea88e137ff4481a7c7e0073 Mon Sep 17 00:00:00 2001 From: sondremann2015 <68125004+sondremann2015@users.noreply.github.com> Date: Tue, 22 Apr 2025 22:45:46 +0200 Subject: [PATCH 1/4] Added Chat history similar to Grok, with Chatpreview panel on Right side Have created a Chat preview chat history similar to grok, i have added a const MIN_LOADING_TIME_MS = 300; This could be lowered to preference, the action bar could be improved, and looks a bit ugly right now, but might be a starting point for a new chat history similar to grok, to improve UX --- app/components/chat/chat.tsx | 4 +- app/components/history/command-history.tsx | 881 ++++++++++++--------- app/components/history/history-trigger.tsx | 22 +- 3 files changed, 533 insertions(+), 374 deletions(-) diff --git a/app/components/chat/chat.tsx b/app/components/chat/chat.tsx index 19e9ff7..d5d7dbf 100644 --- a/app/components/chat/chat.tsx +++ b/app/components/chat/chat.tsx @@ -635,7 +635,7 @@ export function Chat() { ) -} +} \ No newline at end of file diff --git a/app/components/history/command-history.tsx b/app/components/history/command-history.tsx index 6090c4b..c05215a 100644 --- a/app/components/history/command-history.tsx +++ b/app/components/history/command-history.tsx @@ -1,431 +1,586 @@ "use client" -import { useChatSession } from "@/app/providers/chat-session-provider" import { Button } from "@/components/ui/button" -import { - Command, - CommandDialog, - CommandEmpty, - CommandGroup, - CommandInput, - CommandItem, - CommandList, -} from "@/components/ui/command" import { Input } from "@/components/ui/input" -import { - Tooltip, - TooltipContent, - TooltipTrigger, -} from "@/components/ui/tooltip" +// Removed Label import as it's no longer used +import { ScrollArea } from "@/components/ui/scroll-area" +// Removed Switch import import type { Chats } from "@/lib/chat-store/types" import { cn } from "@/lib/utils" -import { Check, PencilSimple, TrashSimple, X } from "@phosphor-icons/react" +// Import cache, fetch, AND the explicit cache function +import { getCachedMessages, fetchAndCacheMessages, cacheMessages } from "@/lib/chat-store/messages/api" +import { Check, CircleNotch, MagnifyingGlass, NotePencil, PencilSimple, TrashSimple, X } from "@phosphor-icons/react" // Added CircleNotch +import type { Message as MessageAISDK } from "ai" +import dynamic from "next/dynamic" +import Link from "next/link" import { useParams, useRouter } from "next/navigation" -import { useCallback, useEffect, useMemo, useState } from "react" +import React, { useCallback, useEffect, useMemo, useRef, useState } from "react" import { formatDate, groupChatsByDate } from "./utils" -type CommandHistoryProps = { - chatHistory: Chats[] - onSaveEdit: (id: string, newTitle: string) => Promise - onConfirmDelete: (id: string) => Promise - trigger: React.ReactNode - isOpen: boolean - setIsOpen: (open: boolean) => void -} +// Dynamically import Markdown component +const Markdown = dynamic( + () => import("@/components/prompt-kit/markdown").then((mod) => mod.Markdown), + { ssr: false } +) -type CommandItemEditProps = { - chat: Chats - editTitle: string - setEditTitle: (title: string) => void - onSave: (id: string) => void - onCancel: () => void -} +/* ------------------------------------------------------------------ + Hook: useChatMessages (Moved to CommandHistory component) + ------------------------------------------------------------------*/ +// Removed useChatMessages hook definition from here -type CommandItemDeleteProps = { - chat: Chats - onConfirm: (id: string) => void - onCancel: () => void +/* ------------------------------------------------------------------ + Component: ChatPreview (simplified) + ------------------------------------------------------------------*/ + +// Updated props for ChatPreview +type ChatPreviewProps = { + chat: Chats | null + messages: MessageAISDK[] + isLoading: boolean } -type CommandItemRowProps = { +const ChatPreview = React.memo(({ chat, messages, isLoading }) => { + // Removed useChatMessages hook call + const contentRef = useRef(null) + const messagesEndRef = useRef(null); // Ref for auto-scrolling + + // Auto‑scroll to bottom on new messages + useEffect(() => { + // Scroll when loading finishes or messages update + if (!isLoading && messagesEndRef.current) { + messagesEndRef.current.scrollIntoView({ behavior: 'auto' }); + } + }, [messages, isLoading]); // Depend on messages and isLoading + + // 1. Handle case where no chat is selected + if (!chat) { + return ( +
+ Select a conversation to preview +
+ ) + } + + // 2. Handle loading state (uses the same container structure as the "Select..." message) + if (isLoading) { + return ( +
+
+ +

Loading messages...

+
+
+ ) + } + + // 3. Handle loaded state (with messages or empty) + const empty = messages.length === 0 + + return ( + +
+ {/* Render Title and Date */} +

{chat.title || 'Untitled Chat'}

+
{formatDate(chat.created_at)}
+ + {/* Container for messages or empty state */} +
{/* Added overflow-y-auto */} + {empty ? ( +
+

No messages found in this chat.

+
+ ) : ( + <> + {messages.map((m) => ( +
+
+ {typeof m.content === 'string' ? ( + + {m.content} + + ) : ( + [Unsupported message content] + )} +
+
+ ))} +
{/* Element to scroll to */} + + )} +
+
+ + ) +}) +ChatPreview.displayName = 'ChatPreview' + +/* ------------------------------------------------------------------ + Component: ChatItem (memo with custom equality) + ------------------------------------------------------------------*/ + +type ChatItemProps = { chat: Chats + isSelected: boolean + isEditing: boolean + isDeleting: boolean + editTitle: string + onSetHovered: (id: string | null) => void onEdit: (chat: Chats) => void onDelete: (id: string) => void - editingId: string | null - deletingId: string | null + onSaveEdit: (id: string) => void + onCancelEdit: () => void + onConfirmDelete: (id: string) => void + onCancelDelete: () => void + onLinkClick: (e: React.MouseEvent) => void + onSetEditTitle: (title: string) => void } -// Component for editing a chat item -function CommandItemEdit({ - chat, - editTitle, - setEditTitle, - onSave, - onCancel, -}: CommandItemEditProps) { +function areEqual(prev: ChatItemProps, next: ChatItemProps) { return ( -
{ - e.preventDefault() - onSave(chat.id) + prev.chat === next.chat && + prev.isSelected === next.isSelected && + prev.isEditing === next.isEditing && + prev.isDeleting === next.isDeleting && + prev.editTitle === next.editTitle + ) +} + +// Export ChatItem so it can be reused by sidebar/drawer if needed later +export const ChatItem = React.memo(function ChatItem(props) { + const { + chat, + isSelected, + isEditing, + isDeleting, + editTitle, + onSetHovered, + onEdit, + onDelete, + onSaveEdit, + onCancelEdit, + onConfirmDelete, + onCancelDelete, + onLinkClick, + onSetEditTitle + } = props + + const stopPropagation = useCallback((e: React.SyntheticEvent) => { + e.stopPropagation() + if (e.type === 'click') (e as React.MouseEvent).preventDefault() + }, []) + + const handleSave = useCallback(() => onSaveEdit(chat.id), [chat.id, onSaveEdit]) + const handleConfirmDel = useCallback(() => onConfirmDelete(chat.id), [chat.id, onConfirmDelete]) + + const actionBtn = ( + icon: React.ReactNode, + onClick: () => void, + title: string, + extraCN = '' + ) => ( + - -
- + {icon} + ) -} -// Component for deleting a chat item -function CommandItemDelete({ - chat, - onConfirm, - onCancel, -}: CommandItemDeleteProps) { return ( -
{ - e.preventDefault() - onConfirm(chat.id) - }} - className="flex w-full items-center justify-between" +
!isEditing && !isDeleting && onSetHovered(chat.id)} > -
- {chat.title} - { - if (e.key === "Escape") { - e.preventDefault() - onCancel() - } else if (e.key === "Enter") { - e.preventDefault() - onConfirm(chat.id) - } - }} - /> +
+ {isEditing ? ( + { e.preventDefault(); handleSave() }}> + onSetEditTitle(e.target.value)} + className="h-8 w-full border-input bg-transparent px-2 text-sm" + autoFocus + onClick={stopPropagation} + onKeyDown={(e) => e.key === 'Enter' && handleSave()} + /> + + ) : isDeleting ? ( +
{ e.preventDefault(); handleConfirmDel() }}> + Delete this chat? + {/* hidden input keeps auto‑focus */} + +
+ ) : ( + + {chat.title || 'Untitled Chat'} + {formatDate(chat.created_at)} + + )}
-
- - + + {/* Action buttons */} +
+ {isEditing ? ( + <> + {actionBtn(, handleSave, 'Save')} + {actionBtn(, onCancelEdit, 'Cancel')} + + ) : isDeleting ? ( + <> + {actionBtn(, handleConfirmDel, 'Confirm Delete', 'text-destructive hover:bg-destructive/10')} + {actionBtn(, onCancelDelete, 'Cancel')} + + ) : ( +
+ {actionBtn(, () => onEdit(chat), 'Rename', 'text-muted-foreground hover:text-foreground')} + {actionBtn(, () => onDelete(chat.id), 'Delete', 'text-muted-foreground hover:text-destructive')} +
+ )}
- +
) +}, areEqual) +ChatItem.displayName = 'ChatItem' + +/* ------------------------------------------------------------------ + Component: LeftPanelHeader (memo) + ------------------------------------------------------------------*/ + +type LeftPanelHeaderProps = { + searchQuery: string + onSearchChange: (value: string) => void + showCreateNewChat: boolean + onCreateNewChat: () => void } -// Component for displaying a normal chat row -function CommandItemRow({ - chat, - onEdit, - onDelete, - editingId, - deletingId, -}: CommandItemRowProps) { +const LeftPanelHeader = React.memo(function LeftPanelHeader({ + searchQuery, + onSearchChange, + showCreateNewChat, + onCreateNewChat +}) { return ( - <> -
- - {chat?.title || "Untitled Chat"} - +
+
+ onSearchChange(e.target.value)} + /> +
- {/* Date and actions container */} -
- {/* Date that shows by default but hides on selection */} - - {formatDate(chat?.created_at)} - - - {/* Action buttons that appear on selection, positioned over the date */} -
+

Actions

+
+ {showCreateNewChat && ( + )} - > - -
- +
) +}) +LeftPanelHeader.displayName = 'LeftPanelHeader' + +/* ------------------------------------------------------------------ + Hook: useChatMessages (Moved here) + ------------------------------------------------------------------*/ + +const MIN_LOADING_TIME_MS = 300; + +type LoadingStatus = 'idle' | 'loading' | 'loaded' | 'error' + +// Now used within CommandHistory +function useChatMessages(chatId: string | null | undefined) { // Accept chatId directly + const [messages, setMessages] = useState([]) + const [isLoading, setIsLoading] = useState(false) + const statusRef = useRef>({}) + const currentIdRef = useRef(null) + + useEffect(() => { + // Reset when chat ID is cleared or undefined + if (!chatId) { + setMessages([]) + setIsLoading(false) + currentIdRef.current = null + return + } + + // Prevent duplicate work for the same chat + if (currentIdRef.current === chatId) return + + currentIdRef.current = chatId + + const load = async () => { + const start = Date.now() + statusRef.current[chatId] = 'loading' + setIsLoading(true) + + try { + let msgs = await getCachedMessages(chatId) + if (msgs.length === 0) { + msgs = await fetchAndCacheMessages(chatId) + if (msgs.length) { + cacheMessages(chatId, msgs).catch(() => {}) + } + } + statusRef.current[chatId] = 'loaded' + setMessages(msgs) + } catch (err) { + console.error(`useChatMessages: failed to load ${chatId}`, err) + statusRef.current[chatId] = 'error' + setMessages([]) // Clear messages on error + } finally { + const elapsed = Date.now() - start + const delay = Math.max(0, MIN_LOADING_TIME_MS - elapsed) + // Use setTimeout to ensure loading state persists for minimum duration + setTimeout(() => { + // Only set loading to false if the current chat ID hasn't changed during the delay + if (currentIdRef.current === chatId) { + setIsLoading(false) + } + }, delay) + } + } + + load() + // Effect dependency is now just the chatId + }, [chatId]) + + return { messages, isLoading } +} + +/* ------------------------------------------------------------------ + Component: CommandHistory (root) + ------------------------------------------------------------------*/ + +type CommandHistoryProps = { + chatHistory: Chats[] + onClose: () => void + onSaveEdit: (id: string, newTitle: string) => Promise + onConfirmDelete: (id: string) => Promise } -export function CommandHistory({ - chatHistory, - onSaveEdit, - onConfirmDelete, - trigger, - isOpen, - setIsOpen, -}: CommandHistoryProps) { - const { chatId } = useChatSession() +export function CommandHistory({ chatHistory, onClose, onSaveEdit, onConfirmDelete }: CommandHistoryProps) { const router = useRouter() - const [searchQuery, setSearchQuery] = useState("") + const params = useParams<{ chatId?: string }>() + + /* ----------------------------- local state ----------------------------- */ + const [searchQuery, setSearchQuery] = useState('') + const [hoveredChatId, setHoveredChatId] = useState(null) const [editingId, setEditingId] = useState(null) - const [editTitle, setEditTitle] = useState("") + const [editTitle, setEditTitle] = useState('') const [deletingId, setDeletingId] = useState(null) - const handleOpenChange = (open: boolean) => { - setIsOpen(open) - if (!open) { - setSearchQuery("") - setEditingId(null) - setEditTitle("") - setDeletingId(null) - } - } + /* --------------------------- prefetech routes -------------------------- */ + useEffect(() => { + chatHistory.forEach((chat) => router.prefetch(`/c/${chat.id}`)) + }, [chatHistory, router]) + + /* ------------------------------ memo data ------------------------------ */ + const filteredChat = useMemo(() => { + const q = searchQuery.toLowerCase() + return q ? chatHistory.filter((c) => (c.title || 'Untitled Chat').toLowerCase().includes(q)) : chatHistory + }, [chatHistory, searchQuery]) + + const groupedChats = useMemo(() => groupChatsByDate(filteredChat, searchQuery), [filteredChat, searchQuery]) + + // Determine the chat to preview (hovered or selected) + const previewChatId = useMemo(() => hoveredChatId || params.chatId, [hoveredChatId, params.chatId]); + const previewChat = useMemo(() => { + return previewChatId ? chatHistory.find((c) => c.id === previewChatId) || null : null + }, [previewChatId, chatHistory]); + + // Fetch messages for the preview chat using the hook + const { messages: previewMessages, isLoading: isPreviewLoading } = useChatMessages(previewChatId); + + /* --------------------------- handlers (memo) --------------------------- */ + const handleSearchChange = useCallback((v: string) => setSearchQuery(v), []) + const handleSetHoveredChatId = useCallback((id: string | null) => setHoveredChatId(id), []) + const handleSetEditTitle = useCallback((title: string) => setEditTitle(title), []) + + const handleCreateNewChat = useCallback(() => { + router.push('/') + onClose() + }, [router, onClose]) const handleEdit = useCallback((chat: Chats) => { setEditingId(chat.id) - setEditTitle(chat.title || "") + setEditTitle(chat.title || '') + setDeletingId(null) }, []) - const handleSaveEdit = useCallback( - async (id: string) => { - setEditingId(null) - await onSaveEdit(id, editTitle) - }, - [editTitle, onSaveEdit] - ) + const handleSaveEdit = useCallback(async (id: string) => { + setEditingId(null) + await onSaveEdit(id, editTitle) + }, [editTitle, onSaveEdit]) const handleCancelEdit = useCallback(() => { setEditingId(null) - setEditTitle("") + setEditTitle('') }, []) const handleDelete = useCallback((id: string) => { setDeletingId(id) + setEditingId(null) }, []) - const handleConfirmDelete = useCallback( - async (id: string) => { - setDeletingId(null) - await onConfirmDelete(id) - }, - [onConfirmDelete] - ) - - const handleCancelDelete = useCallback(() => { + const handleConfirmDelete = useCallback(async (id: string) => { setDeletingId(null) - }, []) - - const filteredChat = useMemo(() => { - const query = searchQuery.toLowerCase() - return query - ? chatHistory.filter((chat) => - (chat.title || "").toLowerCase().includes(query) - ) - : chatHistory - }, [chatHistory, searchQuery]) + await onConfirmDelete(id) + // If the deleted chat was the active one, navigate away (optional) + if (params.chatId === id) { + router.push('/'); + } + }, [onConfirmDelete, params.chatId, router]) - // Group chats by time periods - const groupedChats = useMemo( - () => groupChatsByDate(chatHistory, searchQuery), - [chatHistory, searchQuery] - ) + const handleCancelDelete = useCallback(() => setDeletingId(null), []) + const handleLinkClick = useCallback(() => onClose(), [onClose]) - const renderChatItem = useCallback( - (chat: Chats) => { - const isCurrentChatSession = chat.id === chatId - const isCurrentChatEditOrDelete = - chat.id === editingId || chat.id === deletingId - const isEditOrDeleteMode = editingId || deletingId - - return ( - { - if (isCurrentChatSession) { - setIsOpen(false) - return - } - if (!editingId && !deletingId) { - router.push(`/c/${chat.id}`) - } - }} - className={cn( - "group group data-[selected=true]:bg-accent flex w-full items-center justify-between rounded-md", - isCurrentChatEditOrDelete ? "!py-2" : "py-2", - isCurrentChatEditOrDelete && - "bg-accent data-[selected=true]:bg-accent", - !isCurrentChatEditOrDelete && - isEditOrDeleteMode && - "data-[selected=true]:bg-transparent" - )} - value={chat.id} - data-value-id={chat.id} - > - {editingId === chat.id ? ( - - ) : deletingId === chat.id ? ( - - ) : ( - - )} - - ) - }, - [ - editingId, - deletingId, - editTitle, - handleSaveEdit, - handleCancelEdit, - handleConfirmDelete, - handleCancelDelete, - handleEdit, - handleDelete, - ] - ) - - // Prefetch chat pages, later we will do pagination + infinite scroll + /* --------------------------- Handle Escape Key --------------------------- */ useEffect(() => { - if (!isOpen) return + const handleKeyDown = (event: KeyboardEvent) => { + if (event.key === 'Escape') { + if (editingId) { + handleCancelEdit(); + } else if (deletingId) { + handleCancelDelete(); + } else { + onClose(); + } + } + } - // Simply prefetch all the chat routes when dialog opens - chatHistory.forEach((chat) => { - router.prefetch(`/c/${chat.id}`) - }) - }, [isOpen, chatHistory, router]) + document.addEventListener('keydown', handleKeyDown) + return () => { + document.removeEventListener('keydown', handleKeyDown) + } + // Include dependencies for edit/delete cancellation + }, [onClose, editingId, deletingId, handleCancelEdit, handleCancelDelete]) + /* ----------------------------- render UI ------------------------------ */ return ( - <> - - {trigger} - History - - - - setSearchQuery(value)} +
+ {/* Backdrop */} +
+ + {/* Modal */} +
+ {/* Left Panel */} +
+ - - {filteredChat.length === 0 && ( - No chat history found. - )} - {searchQuery ? ( - // When searching, display a flat list without grouping - - {filteredChat.map((chat) => renderChatItem(chat))} - - ) : ( - // When not searching, display grouped by date - groupedChats?.map((group) => ( - - {group.chats.map((chat) => renderChatItem(chat))} - - )) - )} - - - - + {/* Chat list */} + +
+ {searchQuery ? ( + /* Search active */ + filteredChat.length === 0 ? ( +
No matching chats found.
+ ) : ( + filteredChat.map((chat) => ( + + )) + ) + ) : !groupedChats ? ( +
Loading history...
+ ) : groupedChats.length === 0 ? ( +
No chat history found.
+ ) : ( + groupedChats.map((group) => ( +
+

{group.name}

+ {group.chats.map((chat) => ( + + ))} +
+ )) + )} +
+
+
+ + {/* Preview Panel */} +
+ {/* Pass necessary props to ChatPreview */} + +
+
+
) } diff --git a/app/components/history/history-trigger.tsx b/app/components/history/history-trigger.tsx index 6010ee0..0723498 100644 --- a/app/components/history/history-trigger.tsx +++ b/app/components/history/history-trigger.tsx @@ -27,7 +27,7 @@ export function HistoryTrigger() { setIsOpen(false) } await deleteMessages() - await deleteChat(id, chatId!, () => router.push("/")) + await deleteChat(id, chatId ?? undefined, () => router.push("/")) // Use nullish coalescing for chatId } const trigger = ( @@ -53,14 +53,18 @@ export function HistoryTrigger() { ) } + // Conditionally render CommandHistory based on isOpen state for desktop return ( - + <> + {trigger} {/* Render the trigger button */} + {isOpen && !isMobile && ( // Only render when open and not mobile + setIsOpen(false)} // Pass the close handler + onSaveEdit={handleSaveEdit} // Pass edit handler + onConfirmDelete={handleConfirmDelete} // Pass delete handler + /> + )} + ) } From 433d54bc8f6e1297c23dbe6fdff193f9af97fb9c Mon Sep 17 00:00:00 2001 From: sondremann2015 <68125004+sondremann2015@users.noreply.github.com> Date: Wed, 7 May 2025 23:33:29 +0200 Subject: [PATCH 2/4] New command history - Fixed hover issue on edit chat title name and delete, where light mode would not show hover state, but dark mode did. - Fixed an edge case, where it was possible to enter both delete a chat, and edit a chat title name at the same time - Added support for choosing whether the user wants to have the chat preview available - More responsive command history, chat title edit and delete button should now be available on all screen types - When editing chat names, the input border is now transparent, and better integrates in the excisting chatitem card What needs to be done better - Currently the copy prompt, regenerate, delete, is available in the chatpreview, preferably we would either pass something to the components fetched from, so we can set them to false in the chatpreview, so they wont display, this is preferred, because eventuall changes in how messages look etc, is reflected in the chatpreview component, without having to manage 2 different components for the same thing. - Animations: The animations are really buggy, i dont quite like them - The way hover currently works is really annoying, when resizing its mostly fine, but when sizing up to chat preview panel it hovers over multiple chats for no reason, causing lag, and loading uneeded chats etc - I did enter an edge case where the chat item cards would shift a bit to the right, but i have not been able to reproduce it, and i have since made changes to the code, so unsure if this still persists - Could also reduce the amounts of components used to create, as there are a lot of components created, this was mostly done, so AI could more easily read the code thats been done, but should be no problem adding them back to the command-history.tsx component - A lot of this code has been created by AI cause im lazy, so have not quite properly read over the code, but i have implemented memoization to improve performance, and am unsure if there is much more to go on here. - Would like to improve the way loading messages when previewing a chat works, a bit unsure how this could be done more properly, but could be increased more than the current 50 ms which i have implemented as a default, to somewhat reduce if a user hovers over really fast over a lot of chat items to optimize performance, but would maybe consider increasing to 100 ms to not make the user preview unwanted chats. --- app/components/history/chat-preview-pane.tsx | 137 +++ app/components/history/command-history.tsx | 891 +++++++----------- .../history/command-item-delete.tsx | 67 ++ app/components/history/command-item-edit.tsx | 68 ++ app/components/history/command-item-row.tsx | 86 ++ app/components/history/history-trigger.tsx | 26 +- app/globals.css | 29 + 7 files changed, 753 insertions(+), 551 deletions(-) create mode 100644 app/components/history/chat-preview-pane.tsx create mode 100644 app/components/history/command-item-delete.tsx create mode 100644 app/components/history/command-item-edit.tsx create mode 100644 app/components/history/command-item-row.tsx diff --git a/app/components/history/chat-preview-pane.tsx b/app/components/history/chat-preview-pane.tsx new file mode 100644 index 0000000..5317fdb --- /dev/null +++ b/app/components/history/chat-preview-pane.tsx @@ -0,0 +1,137 @@ +"use client"; + +import { memo, useState, useEffect } from "react"; +import type { Chats } from "@/lib/chat-store/types"; +import { Message as MessageAISDK } from "@ai-sdk/react"; +import { getCachedMessages } from "@/lib/chat-store/messages/api"; +import { Loader2 } from "lucide-react"; +import { Chat, ChatCenteredText } from "@phosphor-icons/react"; +import { Message } from "@/app/components/chat/message"; + +export type ChatPreviewPaneProps = { + selectedChat: Chats | null; + activeChatId: string | null; + currentChatMessagesForActive: MessageAISDK[]; + searchTerm?: string; +}; + +export const ChatPreviewPane = memo(function ChatPreviewPane({ + selectedChat, + activeChatId, + currentChatMessagesForActive, + searchTerm, +}: ChatPreviewPaneProps) { + const [previewMessages, setPreviewMessages] = useState([]); + const [isLoading, setIsLoading] = useState(false); + + useEffect(() => { + if (!selectedChat) { + setPreviewMessages([]); + setIsLoading(false); + return; + } + + let isMounted = true; + const minLoadingTimePromise = new Promise(resolve => setTimeout(resolve, 50)); + + async function fetchMessages() { + setIsLoading(true); + + if (selectedChat?.id === activeChatId) { + await minLoadingTimePromise; + if (isMounted) { + setPreviewMessages(currentChatMessagesForActive || []); + setIsLoading(false); + } + return; + } + + try { + const cachedMsgs = await getCachedMessages(selectedChat!.id); + await minLoadingTimePromise; + if (isMounted) { + setPreviewMessages(cachedMsgs || []); + } + } catch (error) { + console.error("Error fetching cached messages for preview:", error); + if (isMounted) { + setPreviewMessages([]); + } + } finally { + if (isMounted) { + setIsLoading(false); + } + } + } + + fetchMessages(); + + return () => { + isMounted = false; + }; + }, [selectedChat, activeChatId, currentChatMessagesForActive]); + + if (isLoading) { + return ( +
+ + Loading Preview... + Fetching conversation messages. +
+ ); + } + + if (!selectedChat) { + return ( +
+ + + {searchTerm ? "No Matching Chats" : "No Chat Selected"} + + + {searchTerm + ? "Clear or change your search to see other chats, or select one if available." + : "Hover over a chat in the list to preview it here."} + +
+ ); + } + + if (previewMessages.length === 0) { + return ( +
+ + Chat is Empty + This conversation has no messages to display. +
+ ); + } + + return ( +
+
+ {previewMessages.map((message, index) => ( +
+ {}} // Preview is read-only + onEdit={() => {}} // Preview is read-only + onReload={() => {}} // Preview is read-only + parts={message.toolInvocations?.map(invocation => ({ + type: 'tool-invocation', + toolInvocation: invocation + }))} + > + {message.content} + +
+ ))} +
+
+ ); +}); +ChatPreviewPane.displayName = 'ChatPreviewPane'; diff --git a/app/components/history/command-history.tsx b/app/components/history/command-history.tsx index c05215a..9778271 100644 --- a/app/components/history/command-history.tsx +++ b/app/components/history/command-history.tsx @@ -1,586 +1,403 @@ "use client" +import { useChatSession } from "@/app/providers/chat-session-provider" import { Button } from "@/components/ui/button" +import { + Command, + CommandDialog, + CommandEmpty, + CommandGroup, + CommandInput, + CommandItem, + CommandList, +} from "@/components/ui/command" import { Input } from "@/components/ui/input" -// Removed Label import as it's no longer used -import { ScrollArea } from "@/components/ui/scroll-area" -// Removed Switch import +import { + Tooltip, + TooltipContent, + TooltipTrigger, +} from "@/components/ui/tooltip" import type { Chats } from "@/lib/chat-store/types" import { cn } from "@/lib/utils" -// Import cache, fetch, AND the explicit cache function -import { getCachedMessages, fetchAndCacheMessages, cacheMessages } from "@/lib/chat-store/messages/api" -import { Check, CircleNotch, MagnifyingGlass, NotePencil, PencilSimple, TrashSimple, X } from "@phosphor-icons/react" // Added CircleNotch -import type { Message as MessageAISDK } from "ai" -import dynamic from "next/dynamic" -import Link from "next/link" +import { Check, PencilSimple, TrashSimple, X, Chat, ChatCenteredText, ArrowsInSimple, ArrowsOutSimple } from "@phosphor-icons/react" import { useParams, useRouter } from "next/navigation" -import React, { useCallback, useEffect, useMemo, useRef, useState } from "react" +import { memo, useCallback, useEffect, useMemo, useRef, useState } from "react" import { formatDate, groupChatsByDate } from "./utils" - -// Dynamically import Markdown component -const Markdown = dynamic( - () => import("@/components/prompt-kit/markdown").then((mod) => mod.Markdown), - { ssr: false } -) - -/* ------------------------------------------------------------------ - Hook: useChatMessages (Moved to CommandHistory component) - ------------------------------------------------------------------*/ -// Removed useChatMessages hook definition from here - -/* ------------------------------------------------------------------ - Component: ChatPreview (simplified) - ------------------------------------------------------------------*/ - -// Updated props for ChatPreview -type ChatPreviewProps = { - chat: Chats | null - messages: MessageAISDK[] - isLoading: boolean -} - -const ChatPreview = React.memo(({ chat, messages, isLoading }) => { - // Removed useChatMessages hook call - const contentRef = useRef(null) - const messagesEndRef = useRef(null); // Ref for auto-scrolling - - // Auto‑scroll to bottom on new messages - useEffect(() => { - // Scroll when loading finishes or messages update - if (!isLoading && messagesEndRef.current) { - messagesEndRef.current.scrollIntoView({ behavior: 'auto' }); - } - }, [messages, isLoading]); // Depend on messages and isLoading - - // 1. Handle case where no chat is selected - if (!chat) { - return ( -
- Select a conversation to preview -
- ) - } - - // 2. Handle loading state (uses the same container structure as the "Select..." message) - if (isLoading) { - return ( -
-
- -

Loading messages...

-
-
- ) - } - - // 3. Handle loaded state (with messages or empty) - const empty = messages.length === 0 - - return ( - -
- {/* Render Title and Date */} -

{chat.title || 'Untitled Chat'}

-
{formatDate(chat.created_at)}
- - {/* Container for messages or empty state */} -
{/* Added overflow-y-auto */} - {empty ? ( -
-

No messages found in this chat.

-
- ) : ( - <> - {messages.map((m) => ( -
-
- {typeof m.content === 'string' ? ( - - {m.content} - - ) : ( - [Unsupported message content] - )} -
-
- ))} -
{/* Element to scroll to */} - - )} -
-
- - ) -}) -ChatPreview.displayName = 'ChatPreview' - -/* ------------------------------------------------------------------ - Component: ChatItem (memo with custom equality) - ------------------------------------------------------------------*/ - -type ChatItemProps = { - chat: Chats - isSelected: boolean - isEditing: boolean - isDeleting: boolean - editTitle: string - onSetHovered: (id: string | null) => void - onEdit: (chat: Chats) => void - onDelete: (id: string) => void - onSaveEdit: (id: string) => void - onCancelEdit: () => void - onConfirmDelete: (id: string) => void - onCancelDelete: () => void - onLinkClick: (e: React.MouseEvent) => void - onSetEditTitle: (title: string) => void -} - -function areEqual(prev: ChatItemProps, next: ChatItemProps) { - return ( - prev.chat === next.chat && - prev.isSelected === next.isSelected && - prev.isEditing === next.isEditing && - prev.isDeleting === next.isDeleting && - prev.editTitle === next.editTitle - ) -} - -// Export ChatItem so it can be reused by sidebar/drawer if needed later -export const ChatItem = React.memo(function ChatItem(props) { - const { - chat, - isSelected, - isEditing, - isDeleting, - editTitle, - onSetHovered, - onEdit, - onDelete, - onSaveEdit, - onCancelEdit, - onConfirmDelete, - onCancelDelete, - onLinkClick, - onSetEditTitle - } = props - - const stopPropagation = useCallback((e: React.SyntheticEvent) => { - e.stopPropagation() - if (e.type === 'click') (e as React.MouseEvent).preventDefault() - }, []) - - const handleSave = useCallback(() => onSaveEdit(chat.id), [chat.id, onSaveEdit]) - const handleConfirmDel = useCallback(() => onConfirmDelete(chat.id), [chat.id, onConfirmDelete]) - - const actionBtn = ( - icon: React.ReactNode, - onClick: () => void, - title: string, - extraCN = '' - ) => ( - - ) - - return ( -
!isEditing && !isDeleting && onSetHovered(chat.id)} - > -
- {isEditing ? ( -
{ e.preventDefault(); handleSave() }}> - onSetEditTitle(e.target.value)} - className="h-8 w-full border-input bg-transparent px-2 text-sm" - autoFocus - onClick={stopPropagation} - onKeyDown={(e) => e.key === 'Enter' && handleSave()} - /> -
- ) : isDeleting ? ( -
{ e.preventDefault(); handleConfirmDel() }}> - Delete this chat? - {/* hidden input keeps auto‑focus */} - -
- ) : ( - - {chat.title || 'Untitled Chat'} - {formatDate(chat.created_at)} - - )} -
- - {/* Action buttons */} -
- {isEditing ? ( - <> - {actionBtn(, handleSave, 'Save')} - {actionBtn(, onCancelEdit, 'Cancel')} - - ) : isDeleting ? ( - <> - {actionBtn(, handleConfirmDel, 'Confirm Delete', 'text-destructive hover:bg-destructive/10')} - {actionBtn(, onCancelDelete, 'Cancel')} - - ) : ( -
- {actionBtn(, () => onEdit(chat), 'Rename', 'text-muted-foreground hover:text-foreground')} - {actionBtn(, () => onDelete(chat.id), 'Delete', 'text-muted-foreground hover:text-destructive')} -
- )} -
-
- ) -}, areEqual) -ChatItem.displayName = 'ChatItem' - -/* ------------------------------------------------------------------ - Component: LeftPanelHeader (memo) - ------------------------------------------------------------------*/ - -type LeftPanelHeaderProps = { - searchQuery: string - onSearchChange: (value: string) => void - showCreateNewChat: boolean - onCreateNewChat: () => void -} - -const LeftPanelHeader = React.memo(function LeftPanelHeader({ - searchQuery, - onSearchChange, - showCreateNewChat, - onCreateNewChat -}) { - return ( -
-
- onSearchChange(e.target.value)} - /> - -
- -
-

Actions

-
- {showCreateNewChat && ( - - )} -
-
-
- ) -}) -LeftPanelHeader.displayName = 'LeftPanelHeader' - -/* ------------------------------------------------------------------ - Hook: useChatMessages (Moved here) - ------------------------------------------------------------------*/ - -const MIN_LOADING_TIME_MS = 300; - -type LoadingStatus = 'idle' | 'loading' | 'loaded' | 'error' - -// Now used within CommandHistory -function useChatMessages(chatId: string | null | undefined) { // Accept chatId directly - const [messages, setMessages] = useState([]) - const [isLoading, setIsLoading] = useState(false) - const statusRef = useRef>({}) - const currentIdRef = useRef(null) - - useEffect(() => { - // Reset when chat ID is cleared or undefined - if (!chatId) { - setMessages([]) - setIsLoading(false) - currentIdRef.current = null - return - } - - // Prevent duplicate work for the same chat - if (currentIdRef.current === chatId) return - - currentIdRef.current = chatId - - const load = async () => { - const start = Date.now() - statusRef.current[chatId] = 'loading' - setIsLoading(true) - - try { - let msgs = await getCachedMessages(chatId) - if (msgs.length === 0) { - msgs = await fetchAndCacheMessages(chatId) - if (msgs.length) { - cacheMessages(chatId, msgs).catch(() => {}) - } - } - statusRef.current[chatId] = 'loaded' - setMessages(msgs) - } catch (err) { - console.error(`useChatMessages: failed to load ${chatId}`, err) - statusRef.current[chatId] = 'error' - setMessages([]) // Clear messages on error - } finally { - const elapsed = Date.now() - start - const delay = Math.max(0, MIN_LOADING_TIME_MS - elapsed) - // Use setTimeout to ensure loading state persists for minimum duration - setTimeout(() => { - // Only set loading to false if the current chat ID hasn't changed during the delay - if (currentIdRef.current === chatId) { - setIsLoading(false) - } - }, delay) - } - } - - load() - // Effect dependency is now just the chatId - }, [chatId]) - - return { messages, isLoading } -} - -/* ------------------------------------------------------------------ - Component: CommandHistory (root) - ------------------------------------------------------------------*/ +import { Message as MessageAISDK } from "@ai-sdk/react"; +import { Message } from "@/app/components/chat/message"; +import { Loader2 } from "lucide-react"; // For loading state +import { getCachedMessages } from "@/lib/chat-store/messages/api"; // Import for fetching cached messages +import { CommandItemEdit, CommandItemEditProps } from "./command-item-edit"; // Added import +import { CommandItemDelete, CommandItemDeleteProps } from "./command-item-delete"; // Added import +import { CommandItemRow, CommandItemRowProps } from "./command-item-row"; // Added import +import { ChatPreviewPane, ChatPreviewPaneProps } from "./chat-preview-pane"; // Added import type CommandHistoryProps = { chatHistory: Chats[] - onClose: () => void + currentChatMessages: MessageAISDK[] // For active chat, passed to ChatPreviewPane + activeChatId: string | null // For active chat ID onSaveEdit: (id: string, newTitle: string) => Promise onConfirmDelete: (id: string) => Promise + trigger: React.ReactNode + isOpen: boolean + setIsOpen: (open: boolean) => void } -export function CommandHistory({ chatHistory, onClose, onSaveEdit, onConfirmDelete }: CommandHistoryProps) { +export function CommandHistory({ + chatHistory, + currentChatMessages, + activeChatId, + onSaveEdit, + onConfirmDelete, + trigger, + isOpen, + setIsOpen, +}: CommandHistoryProps) { const router = useRouter() - const params = useParams<{ chatId?: string }>() - - /* ----------------------------- local state ----------------------------- */ - const [searchQuery, setSearchQuery] = useState('') - const [hoveredChatId, setHoveredChatId] = useState(null) + const params = useParams() + useChatSession() + const [searchQuery, setSearchQuery] = useState("") const [editingId, setEditingId] = useState(null) - const [editTitle, setEditTitle] = useState('') const [deletingId, setDeletingId] = useState(null) - - /* --------------------------- prefetech routes -------------------------- */ - useEffect(() => { - chatHistory.forEach((chat) => router.prefetch(`/c/${chat.id}`)) - }, [chatHistory, router]) - - /* ------------------------------ memo data ------------------------------ */ - const filteredChat = useMemo(() => { - const q = searchQuery.toLowerCase() - return q ? chatHistory.filter((c) => (c.title || 'Untitled Chat').toLowerCase().includes(q)) : chatHistory - }, [chatHistory, searchQuery]) - - const groupedChats = useMemo(() => groupChatsByDate(filteredChat, searchQuery), [filteredChat, searchQuery]) - - // Determine the chat to preview (hovered or selected) - const previewChatId = useMemo(() => hoveredChatId || params.chatId, [hoveredChatId, params.chatId]); - const previewChat = useMemo(() => { - return previewChatId ? chatHistory.find((c) => c.id === previewChatId) || null : null - }, [previewChatId, chatHistory]); - - // Fetch messages for the preview chat using the hook - const { messages: previewMessages, isLoading: isPreviewLoading } = useChatMessages(previewChatId); - - /* --------------------------- handlers (memo) --------------------------- */ - const handleSearchChange = useCallback((v: string) => setSearchQuery(v), []) - const handleSetHoveredChatId = useCallback((id: string | null) => setHoveredChatId(id), []) - const handleSetEditTitle = useCallback((title: string) => setEditTitle(title), []) - - const handleCreateNewChat = useCallback(() => { - router.push('/') - onClose() - }, [router, onClose]) + const [editTitle, setEditTitle] = useState("") + const [selectedChatId, setSelectedChatId] = useState(null); + const [hoveredChatId, setHoveredChatId] = useState(null); + const [showPreview, setShowPreview] = useState(true); // Default to true + + const handleOpenChange = useCallback((open: boolean) => { + setIsOpen(open) + if (!open) { + setEditingId(null) + setDeletingId(null) + setSelectedChatId(null); // Clear selection on close + setSearchQuery(""); // Reset search query on close + } + }, [setIsOpen]); const handleEdit = useCallback((chat: Chats) => { setEditingId(chat.id) - setEditTitle(chat.title || '') - setDeletingId(null) - }, []) - - const handleSaveEdit = useCallback(async (id: string) => { - setEditingId(null) - await onSaveEdit(id, editTitle) - }, [editTitle, onSaveEdit]) + setEditTitle(chat.title || "") + setDeletingId(null) // Ensure delete mode is reset + }, [setEditTitle]) + + const handleSaveEdit = useCallback( + async (id: string) => { + setEditingId(null) + await onSaveEdit(id, editTitle) + }, + [editTitle, onSaveEdit] + ) const handleCancelEdit = useCallback(() => { setEditingId(null) - setEditTitle('') - }, []) + setEditTitle("") + }, [setEditTitle]) const handleDelete = useCallback((id: string) => { setDeletingId(id) - setEditingId(null) - }, []) + setEditingId(null) // Ensure edit mode is reset + setEditTitle("") // Clear any lingering edit title + }, [setEditTitle]) + + const handleConfirmDelete = useCallback( + async (id: string) => { + setDeletingId(null) + await onConfirmDelete(id) + }, + [onConfirmDelete] + ) - const handleConfirmDelete = useCallback(async (id: string) => { + const handleCancelDelete = useCallback(() => { setDeletingId(null) - await onConfirmDelete(id) - // If the deleted chat was the active one, navigate away (optional) - if (params.chatId === id) { - router.push('/'); + }, []) + + const filteredChat = useMemo(() => { + if (searchQuery) { + const lowercasedQuery = searchQuery.toLowerCase() + return chatHistory.filter((chat) => { + const titleMatch = chat.title ? chat.title.toLowerCase().includes(lowercasedQuery) : false; + const idMatch = chat.id.toLowerCase().includes(lowercasedQuery); + return titleMatch || idMatch; + }) + } else { + return chatHistory; } - }, [onConfirmDelete, params.chatId, router]) + }, [chatHistory, searchQuery]); - const handleCancelDelete = useCallback(() => setDeletingId(null), []) - const handleLinkClick = useCallback(() => onClose(), [onClose]) + const groupedChats = useMemo(() => groupChatsByDate(filteredChat, searchQuery), [filteredChat, searchQuery]); - /* --------------------------- Handle Escape Key --------------------------- */ - useEffect(() => { - const handleKeyDown = (event: KeyboardEvent) => { - if (event.key === 'Escape') { - if (editingId) { - handleCancelEdit(); - } else if (deletingId) { - handleCancelDelete(); - } else { - onClose(); - } + // Determine the chat to preview (hovered or selected) + const selectedChatForPreview = useMemo(() => { + const idToUse = selectedChatId || hoveredChatId; + if (!idToUse) return null; + + if (Array.isArray(groupedChats)) { // Check if groupedChats is an array + for (const group of groupedChats) { + // Ensure group exists and group.chats is an array before trying to find + if (group && Array.isArray(group.chats)) { + const chat = group.chats.find(c => c.id === idToUse); + if (chat) return chat; + } } } + return null; + }, [selectedChatId, hoveredChatId, groupedChats]); - document.addEventListener('keydown', handleKeyDown) - return () => { - document.removeEventListener('keydown', handleKeyDown) + useEffect(() => { + if (selectedChatId) { + const isSelectedChatVisible = groupedChats?.some(group => // Added optional chaining + group.chats.some(chat => chat.id === selectedChatId) + ) ?? false; // Ensure boolean + if (!isSelectedChatVisible) { + setSelectedChatId(null); + } + } + if (hoveredChatId) { + const isHoveredChatVisible = groupedChats?.some(group => // Added optional chaining + group.chats.some(chat => chat.id === hoveredChatId) + ) ?? false; // Ensure boolean + if (!isHoveredChatVisible) { + setHoveredChatId(null); + } } - // Include dependencies for edit/delete cancellation - }, [onClose, editingId, deletingId, handleCancelEdit, handleCancelDelete]) + }, [groupedChats, selectedChatId, hoveredChatId]); + + const isSearchResultEmptyInPreview = useMemo(() => { + return showPreview && searchQuery.length > 0 && filteredChat.length === 0; + }, [showPreview, searchQuery, filteredChat]); + + const renderChatItem = useCallback( + (chat: Chats) => { + const isItemBeingEdited = editingId === chat.id; + const isItemBeingDeleted = deletingId === chat.id; + + const commandItemRowProps = { + chat, + onEdit: handleEdit, + onDelete: handleDelete, + editingId, + deletingId, + }; + + return ( + { + if (!editingId && !deletingId && selectedChatId !== chat.id) { + setSelectedChatId(chat.id); + } + }} + onSelect={() => { + if (!editingId && !deletingId) { + router.push(`/c/${chat.id}`); + setIsOpen(false); + } + }} + className={cn( + "group data-[selected=true]:bg-accent flex w-full items-center justify-between rounded-md cursor-pointer", + (isItemBeingEdited || isItemBeingDeleted) ? "!py-2 bg-accent data-[selected=true]:bg-accent" : "py-2" + )} + value={chat.id} + data-value-id={chat.id} + > + {isItemBeingEdited ? ( + + ) : isItemBeingDeleted ? ( + + ) : ( + + )} + + ); + }, + [ + editingId, + deletingId, + selectedChatId, + editTitle, + handleEdit, + handleDelete, + handleSaveEdit, + handleCancelEdit, + handleConfirmDelete, + handleCancelDelete, + router, + setIsOpen, + setEditTitle, + setSelectedChatId + ] + ); + + const togglePreview = useCallback(() => { + setShowPreview(prev => !prev); + }, []); + + const dialogBaseClasses = "flex flex-col transition-all duration-300 ease-in-out overflow-hidden"; + const widthClassesWithPreview = "w-[85vw] max-w-[1200px] lg:max-w-6xl"; + const widthClassesWithoutPreview = "w-[30vw] !max-w-[900px]"; - /* ----------------------------- render UI ------------------------------ */ return ( -
- {/* Backdrop */} -
+ <> + {/* Tooltip for the trigger button that opens the command history dialog */} + + {trigger} + History + + + {/* CommandDialog: Main container for the entire chat history interface. + - Styling: Controlled by 'dialogContentClassName' and 'className'. + - Behavior: 'open' and 'onOpenChange' manage its visibility. + - 'title' and 'description' are for accessibility and headers (if not overridden). + - 'hasCloseButton={false}' means we rely on other mechanisms or 'onOpenChange' for closing. + - 'commandProps={{ shouldFilter: false }}' disables CommandDialog's built-in filtering, as we use a custom 'CommandInput' and filtering logic. + - To change overall dialog size/shape: Modify 'dialogContentClassName' with Tailwind classes for width, height, max-width, etc. + Example: `cn(dialogBaseClasses, showPreview ? widthClassesWithPreview : widthClassesWithoutPreview, showPreview ? "min-h-[85vh]" : "h-[50vh]")` dynamically changes width and height. + */} + + {/* CommandInput: Search bar for filtering chat history. + - Styling: 'className="flex-shrink-0"' prevents it from shrinking. + - To change look: Modify Tailwind classes here for padding, background, text style, etc. + */} + setSearchQuery(value)} + className="flex-shrink-0" + /> - {/* Modal */} -
- {/* Left Panel */} -
- + {/* Main Content Area: This div establishes the two-panel layout (list and preview). + - Styling: 'flex-grow flex items-stretch min-w-0 overflow-hidden'. + - 'flex-grow': Allows this area to expand and fill available vertical space within the dialog. + - 'flex': Establishes a flex container for the left and right panels (defaults to row direction). + - 'items-stretch': Ensures that the left and right panels stretch to fill the height of this container. + - 'min-w-0': Essential for nested flex items that might overflow, preventing them from expanding their parent. + - 'overflow-hidden': Clips content that exceeds the bounds, works with 'min-w-0'. + - To change overall layout: Modify 'flex' (e.g., to 'flex-col' for vertical stacking of panels). + */} +
+ {/* Left Panel (Chat List Container): + - Styling: 'flex flex-col min-h-0 min-w-0 overflow-hidden transition-all duration-300 ease-in-out'. + - 'flex flex-col': Children (CommandList) stack vertically. + - 'min-w-0': Crucial for proper flexbox sizing within a container, allows shrinking. + - 'overflow-hidden': Parent handles overflow, actual scroll is on CommandList. + - 'transition-all...': For smooth animation when the preview pane is toggled. + - Dynamic basis: Adjust left panel visibility and size based on search results in preview mode. + - To change width ratio: Adjust 'basis-1/3'. + */} +
+ {/* CommandList is responsible for listing chat items or showing an empty state. + - Base styling: 'flex-grow min-h-0 !max-h-none overflow-y-scroll command-history-scrollbar-target' ensures it grows and scrolls. + - Conditional styling (when empty & no preview): Adds flex properties to center the CommandEmpty component itself. + */} + + {/* Show CommandEmpty only if NOT in preview mode and list is empty */} + {filteredChat.length === 0 && !showPreview && ( + + + No Chat History Found + + Your chat conversations will appear here. + + + )} - {/* Chat list */} - -
+ {/* Conditional rendering: If searchQuery exists, show a flat list. Otherwise, show grouped by date. */} {searchQuery ? ( - /* Search active */ - filteredChat.length === 0 ? ( -
No matching chats found.
- ) : ( - filteredChat.map((chat) => ( - - )) - ) - ) : !groupedChats ? ( -
Loading history...
- ) : groupedChats.length === 0 ? ( -
No chat history found.
+ {/* Padding for the group when searching */} + {filteredChat.map((chat) => renderChatItem(chat))} + ) : ( - groupedChats.map((group) => ( -
-

{group.name}

- {group.chats.map((chat) => ( - - ))} -
+ groupedChats?.map((group) => ( + + {group.chats.map((chat) => renderChatItem(chat))} + )) )} -
-
+
+
+ + {/* Right Panel (Chat Preview Container): + - Styling: 'flex flex-col min-h-0 min-w-0 overflow-hidden transition-all duration-300 ease-in-out'. + - Similar to left panel for flex behavior and transitions. + - Dynamic visibility/sizing: Adjust right panel visibility and size based on search results in preview mode. + - To change width ratio: Adjust 'basis-2/3'. + */} +
+ {/* ChatPreviewPane: Component responsible for showing messages of the selected/hovered chat. + - Styling of the content WITHIN this pane is handled by the ChatPreviewPane component itself. + */} + +
- {/* Preview Panel */} -
- {/* Pass necessary props to ChatPreview */} - + {/* Footer Section: Contains the toggle button for the preview pane. + - Styling: 'flex-shrink-0 p-2 border-t flex items-center'. + - 'flex-shrink-0': Prevents footer from shrinking. + - 'p-2 border-t': Padding and top border for separation. + - 'flex items-center': Aligns button vertically. + - To change position/look: Modify these Tailwind classes. + */} +
+ + + + + +

{showPreview ? "Hide Preview Pane" : "Show Preview Pane"}

+
+
-
-
+ + ) } diff --git a/app/components/history/command-item-delete.tsx b/app/components/history/command-item-delete.tsx new file mode 100644 index 0000000..88cd78b --- /dev/null +++ b/app/components/history/command-item-delete.tsx @@ -0,0 +1,67 @@ +"use client" + +import { memo } from "react"; +import { Button } from "@/components/ui/button"; +import { Check, X } from "@phosphor-icons/react"; +import type { Chats } from "@/lib/chat-store/types"; + +export type CommandItemDeleteProps = { + chat: Chats; + onConfirm: (id: string) => void; + onCancel: () => void; +}; + +// Component for deleting a chat item +export const CommandItemDelete = memo(function CommandItemDelete({ + chat, + onConfirm, + onCancel, +}: CommandItemDeleteProps) { + return ( +
{ + e.preventDefault(); + onConfirm(chat.id); + }} + className="flex w-full items-center justify-between" + > +
+ {chat.title} + { + if (e.key === "Escape") { + e.preventDefault(); + onCancel(); + } else if (e.key === "Enter") { + e.preventDefault(); + onConfirm(chat.id); + } + }} + /> +
+
+ + +
+
+ ); +}); +CommandItemDelete.displayName = 'CommandItemDelete'; diff --git a/app/components/history/command-item-edit.tsx b/app/components/history/command-item-edit.tsx new file mode 100644 index 0000000..323cdd1 --- /dev/null +++ b/app/components/history/command-item-edit.tsx @@ -0,0 +1,68 @@ +"use client" + +import { memo } from "react"; +import { Input } from "@/components/ui/input"; +import { Button } from "@/components/ui/button"; +import { Check, X } from "@phosphor-icons/react"; +import type { Chats } from "@/lib/chat-store/types"; + +export type CommandItemEditProps = { + chat: Chats; + editTitle: string; + setEditTitle: (title: string) => void; + onSave: (id: string) => void; + onCancel: () => void; +}; + +// Component for editing a chat item +export const CommandItemEdit = memo(function CommandItemEdit({ + chat, + editTitle, + setEditTitle, + onSave, + onCancel, +}: CommandItemEditProps) { + return ( +
{ + e.preventDefault(); + onSave(chat.id); + }} + > + setEditTitle(e.target.value)} + className="box-border flex-1 appearance-none bg-transparent dark:bg-transparent p-0 font-normal border-0 ring-0 shadow-none focus-visible:ring-0 focus-visible:ring-offset-0 focus-visible:outline-none focus:ring-0" + style={{ fontSize: '1rem', height: '1.5rem', lineHeight: '1.5rem', backgroundColor: 'transparent' }} + autoFocus + onKeyDown={(e) => { + if (e.key === "Enter") { + e.preventDefault(); + onSave(chat.id); + } + }} + /> +
+ + +
+
+ ); +}); +CommandItemEdit.displayName = 'CommandItemEdit'; diff --git a/app/components/history/command-item-row.tsx b/app/components/history/command-item-row.tsx new file mode 100644 index 0000000..4d86863 --- /dev/null +++ b/app/components/history/command-item-row.tsx @@ -0,0 +1,86 @@ +"use client" + +import { memo } from "react"; +import { Button } from "@/components/ui/button"; +import { cn } from "@/lib/utils"; +import { PencilSimple, TrashSimple } from "@phosphor-icons/react"; +import type { Chats } from "@/lib/chat-store/types"; +import { formatDate } from "./utils"; + +export type CommandItemRowProps = { + chat: Chats; + onEdit: (chat: Chats) => void; + onDelete: (id: string) => void; + editingId: string | null; + deletingId: string | null; +}; + +// Component for displaying a normal chat row +export const CommandItemRow = memo(function CommandItemRow({ + chat, + onEdit, + onDelete, + editingId, + deletingId, +}: CommandItemRowProps) { + return ( + <> +
+ + {chat?.title || "Untitled Chat"} + +
+ + {/* Date and actions container */} +
+ {/* Date that shows by default but hides on selection */} + + {formatDate(chat?.created_at)} + + + {/* Action buttons that appear on selection, positioned over the date */} +
+ + +
+
+ + ); +}); +CommandItemRow.displayName = 'CommandItemRow'; diff --git a/app/components/history/history-trigger.tsx b/app/components/history/history-trigger.tsx index 0723498..45b5014 100644 --- a/app/components/history/history-trigger.tsx +++ b/app/components/history/history-trigger.tsx @@ -14,7 +14,7 @@ export function HistoryTrigger() { const isMobile = useBreakpoint(768) const router = useRouter() const { chats, updateTitle, deleteChat } = useChats() - const { deleteMessages } = useMessages() + const { messages: currentChatMessages, deleteMessages } = useMessages() const [isOpen, setIsOpen] = useState(false) const { chatId } = useChatSession() @@ -27,7 +27,7 @@ export function HistoryTrigger() { setIsOpen(false) } await deleteMessages() - await deleteChat(id, chatId ?? undefined, () => router.push("/")) // Use nullish coalescing for chatId + await deleteChat(id, chatId ?? undefined, () => router.push("/")) } const trigger = ( @@ -53,18 +53,16 @@ export function HistoryTrigger() { ) } - // Conditionally render CommandHistory based on isOpen state for desktop return ( - <> - {trigger} {/* Render the trigger button */} - {isOpen && !isMobile && ( // Only render when open and not mobile - setIsOpen(false)} // Pass the close handler - onSaveEdit={handleSaveEdit} // Pass edit handler - onConfirmDelete={handleConfirmDelete} // Pass delete handler - /> - )} - + ) } diff --git a/app/globals.css b/app/globals.css index da2766b..5416b89 100644 --- a/app/globals.css +++ b/app/globals.css @@ -160,3 +160,32 @@ code { [data-sonner-toaster] > li { width: 100%; } + +/* Custom scrollbar styles for Command History */ +.command-history-scrollbar-target::-webkit-scrollbar { + width: 6px; +} + +.command-history-scrollbar-target::-webkit-scrollbar-track { + background: transparent; +} + +/* Default (Light mode) scrollbar thumb */ +.command-history-scrollbar-target::-webkit-scrollbar-thumb { + background-color: rgba(0,0,0,0.2); + border-radius: 3px; +} + +.command-history-scrollbar-target { + scrollbar-width: thin; + scrollbar-color: rgba(0,0,0,0.2) transparent; /* thumb track */ +} + +/* Dark mode scrollbar thumb */ +html.dark .command-history-scrollbar-target::-webkit-scrollbar-thumb { + background-color: rgba(255,255,255,0.25); +} + +html.dark .command-history-scrollbar-target { + scrollbar-color: rgba(255,255,255,0.25) transparent; /* thumb track */ +} From 0475cb9da5a4f788fbe0d90915e8a3fdb0e4bf89 Mon Sep 17 00:00:00 2001 From: sondremann2015 <68125004+sondremann2015@users.noreply.github.com> Date: Wed, 7 May 2025 23:46:23 +0200 Subject: [PATCH 3/4] Forgot file Forgot file --- components/ui/command.tsx | 35 +++++++++++++++++++++++++++-------- 1 file changed, 27 insertions(+), 8 deletions(-) diff --git a/components/ui/command.tsx b/components/ui/command.tsx index 5d83b51..b75dc36 100644 --- a/components/ui/command.tsx +++ b/components/ui/command.tsx @@ -12,6 +12,15 @@ import { MagnifyingGlass } from "@phosphor-icons/react" import { Command as CommandPrimitive } from "cmdk" import * as React from "react" +interface CommandDialogPropsExternal extends React.ComponentProps { + title?: string + description?: string + dialogContentClassName?: string + className?: string + hasCloseButton?: boolean + commandProps?: React.ComponentProps +} + function Command({ className, ...props @@ -32,19 +41,29 @@ function CommandDialog({ title = "Command Palette", description = "Search for a command to run...", children, + dialogContentClassName, + className, + hasCloseButton, + commandProps, ...props -}: React.ComponentProps & { - title?: string - description?: string -}) { +}: CommandDialogPropsExternal) { return ( {title} {description} - - + + {children} @@ -59,9 +78,8 @@ function CommandInput({ return (
- +
) } From 88c3ded1fa1671fef58b0bdc6f76fae9a3516823 Mon Sep 17 00:00:00 2001 From: sondremann2015 <68125004+sondremann2015@users.noreply.github.com> Date: Thu, 8 May 2025 01:09:25 +0200 Subject: [PATCH 4/4] Updated to newest Google Gemini 2.5 pro model Updated gemini 2.5 pro to newest version number, think this should be done automatically, but why not Also added gemini 2.5 flash, did this really quickly because i prefer this models when testing myself, should add a thinking version using thinkingbudet as well, but did this in 2 secs, for myself. Have not seen how the provider map or types is supposed to work, but just throwed them in there. --- lib/config.ts | 19 +++++++++++++++++-- lib/openproviders/provider-map.ts | 2 ++ lib/openproviders/types.ts | 2 ++ 3 files changed, 21 insertions(+), 2 deletions(-) diff --git a/lib/config.ts b/lib/config.ts index 4accd1e..c569caa 100644 --- a/lib/config.ts +++ b/lib/config.ts @@ -167,7 +167,22 @@ export const MODELS_PRO = [ icon: OpenAI, }, { - id: "gemini-2.5-pro-preview-03-25", + id: "gemini-2.5-flash-preview-04-17", + name: "Gemini 2.5 Flash", + provider: "gemini", + features: [ + { + id: "file-upload", + enabled: true, + }, + ], + creator: "google", + api_sdk: openproviders("gemini-2.5-flash-preview-04-17"), + description: "Fast and cost-efficient with streaming and real-time output.", + icon: Gemini, + }, + { + id: "gemini-2.5-pro-preview-05-06", name: "Gemini 2.5 Pro", provider: "gemini", features: [ @@ -177,7 +192,7 @@ export const MODELS_PRO = [ }, ], creator: "google", - api_sdk: openproviders("gemini-2.5-pro-exp-03-25"), + api_sdk: openproviders("gemini-2.5-pro-preview-05-06"), description: "Advanced reasoning, coding, and multimodal understanding.", icon: Gemini, }, diff --git a/lib/openproviders/provider-map.ts b/lib/openproviders/provider-map.ts index 5a0b648..5d80def 100644 --- a/lib/openproviders/provider-map.ts +++ b/lib/openproviders/provider-map.ts @@ -67,6 +67,8 @@ const MODEL_PROVIDER_MAP: Record = { "gemini-1.5-pro-001": "google", "gemini-1.5-pro-002": "google", "gemini-2.5-pro-exp-03-25": "google", + "gemini-2.5-pro-preview-05-06": "google", + "gemini-2.5-flash-preview-04-17": "google", "gemini-2.0-flash-lite-preview-02-05": "google", "gemini-2.0-pro-exp-02-05": "google", "gemini-2.0-flash-thinking-exp-01-21": "google", diff --git a/lib/openproviders/types.ts b/lib/openproviders/types.ts index c799f5a..df258fa 100644 --- a/lib/openproviders/types.ts +++ b/lib/openproviders/types.ts @@ -51,6 +51,7 @@ export type MistralModel = | "open-mixtral-8x22b" export type GeminiModel = + | "gemini-2.5-flash-preview-04-17" | "gemini-2.0-flash-001" | "gemini-1.5-flash" | "gemini-1.5-flash-latest" @@ -64,6 +65,7 @@ export type GeminiModel = | "gemini-1.5-pro-001" | "gemini-1.5-pro-002" | "gemini-2.5-pro-exp-03-25" + | "gemini-2.5-pro-preview-05-06" | "gemini-2.0-flash-lite-preview-02-05" | "gemini-2.0-pro-exp-02-05" | "gemini-2.0-flash-thinking-exp-01-21"