From 1519e263f621921859693c5164abdd9fecd029e1 Mon Sep 17 00:00:00 2001 From: FoxTale-Labs <72300200+FoxTale-Labs@users.noreply.github.com> Date: Thu, 19 Jun 2025 23:47:51 +0200 Subject: [PATCH 01/17] hot fix --- src/app/components/ChatPalette.tsx | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/src/app/components/ChatPalette.tsx b/src/app/components/ChatPalette.tsx index ffb19a0..4386479 100644 --- a/src/app/components/ChatPalette.tsx +++ b/src/app/components/ChatPalette.tsx @@ -126,7 +126,7 @@ export default function ChatPalette({ className, hidden: hiddenOuter, onDismiss return; } - const chat = localChats.chats[selected[0]]; + const chat = filteredChats[selected[0]]; if (chat && pendingDeleteId === chat.id && !deletingId) { setPendingDeleteId(""); if (pendingDeleteTimeout.current) { @@ -164,7 +164,8 @@ export default function ChatPalette({ className, hidden: hiddenOuter, onDismiss return; } - const chat = localChats.chats[selected[0]]; + // Use filteredChats for selection actions + const chat = filteredChats[selected[0]]; if (!chat) return; if (pendingDeleteId === chat.id && !deletingId) { if (pendingDeleteTimeout.current) { @@ -185,7 +186,7 @@ export default function ChatPalette({ className, hidden: hiddenOuter, onDismiss return; } - const chat = localChats.chats[selected[0]]; + const chat = filteredChats[selected[0]]; if (!chat) return; if (pendingDeleteId === chat.id && !deletingId) { if (pendingDeleteTimeout.current) { From 8fbd00f92be7504f43bcbdc408ac517deb05a880 Mon Sep 17 00:00:00 2001 From: FoxTale-Labs <72300200+FoxTale-Labs@users.noreply.github.com> Date: Fri, 20 Jun 2025 00:36:19 +0200 Subject: [PATCH 02/17] increase chat palette size --- src/app/components/ChatPalette.tsx | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) diff --git a/src/app/components/ChatPalette.tsx b/src/app/components/ChatPalette.tsx index 4386479..f04fe99 100644 --- a/src/app/components/ChatPalette.tsx +++ b/src/app/components/ChatPalette.tsx @@ -568,7 +568,7 @@ export default function ChatPalette({ className, hidden: hiddenOuter, onDismiss > . -
+
-
    +
      {!bulkDeleteMode && !isTouchDevice && (
      Loading... please wait )} - {chatsWithSections.map((section, sectionIdx) => ( + {chatsWithSections.map((section, _) => (
    • {section.section}
    • - {section.chats.map((chat, idxInSection) => { + {section.chats.map((chat, _) => { // Flat index in filteredChats const flatIdx = filteredChats.findIndex(c => c.id === chat.id); const isSelected = selectedChatIds.has(chat.id); From 72ce09e763fb821c4c773b328cbd2f6c40802474 Mon Sep 17 00:00:00 2001 From: FoxTale-Labs <72300200+FoxTale-Labs@users.noreply.github.com> Date: Fri, 20 Jun 2025 22:39:03 +0200 Subject: [PATCH 03/17] renaming chats and hiding selected when 0 results --- src/app/api/chat/[id]/route.ts | 54 ++++- src/app/api/chat/[id]/send/route.ts | 2 +- src/app/components/ChatPalette.tsx | 336 ++++++++++++++++++++-------- 3 files changed, 294 insertions(+), 98 deletions(-) diff --git a/src/app/api/chat/[id]/route.ts b/src/app/api/chat/[id]/route.ts index f125b21..3a23c32 100644 --- a/src/app/api/chat/[id]/route.ts +++ b/src/app/api/chat/[id]/route.ts @@ -45,6 +45,48 @@ export async function GET(_: NextRequest, { params }: { params: Promise<{ id: st } as ChatResponse, { status: 200 }); } +export async function POST(req: NextRequest, { params }: { params: Promise<{ id: string }> }) { + if (!redis) { + return NextResponse.json({ error: "Redis connection failure" } as ApiError, { status: 200 }); + } + + try { + const updateBody = await req.json() as { + label?: string; + }; + + // Check if no fields are provided in the body + if (!updateBody.label) { // It's just label atm, model/provider can't be changed via updating the chat only by sending + return NextResponse.json({ error: "At least one field is required (label)" } as ApiError, { status: 400 }); + } + const user = await currentUser(); + if (!user) return NextResponse.json({ error: "Unauthorized" } as ApiError, { status: 200 }); + if (user.banned) return NextResponse.json({ error: "Unauthorized" } as ApiError, { status: 200 }); + + const { id } = await params; + + const rawChat = await redis.hget(USER_CHATS_KEY(user.id), id); + if (!rawChat) return NextResponse.json({ error: "Chat not found" } as ApiError, { status: 200 }); + const chat = JSON.parse(rawChat); + + // If label is provided, update label + if (updateBody.label) { + chat.label = updateBody.label; + } + + // Update the chat + const updated = await redis.hset(USER_CHATS_KEY(user.id), id, JSON.stringify(chat)); + if (!updated) { + return NextResponse.json({ error: "Failed to update chat" } as ApiError, { status: 500 }); + } + + return NextResponse.json({ success: "Updated chat successfully" }, { status: 200 }); + } catch (error) { + console.error("Unknown error occurred while updating a chat: ", (error as Error).message); + return NextResponse.json({ error: "An unknown error occurred" }, { status: 500 }); + } +} + export async function DELETE(_: NextRequest, { params }: { params: Promise<{ id: string }> }) { if (!redis) { return NextResponse.json({ error: "Redis connection failure" } as ApiError, { status: 500 }); @@ -54,9 +96,9 @@ export async function DELETE(_: NextRequest, { params }: { params: Promise<{ id: const user = await currentUser(); if (!user) return NextResponse.json({ error: "Unauthorized" } as ApiError, { status: 401 }); if (user.banned) return NextResponse.json({ error: "Unauthorized" } as ApiError, { status: 401 }); - + const { id } = await params; - + // Delete all files belonging to this chat const USER_FILES_KEY_CONST = USER_FILES_KEY(user.id); const files = await redis.hgetall(USER_FILES_KEY_CONST); @@ -76,24 +118,24 @@ export async function DELETE(_: NextRequest, { params }: { params: Promise<{ id: console.error(`Failed to delete file ${randomName} for chat ${id}:`, error); } } - + // Use a transaction to delete both chat data and messages const result = await redis.multi() .hdel(USER_CHATS_KEY(user.id), id) .del(CHAT_MESSAGES_KEY(id)) .zrem(USER_CHATS_INDEX_KEY(user.id), id) .exec(); - + // Clean the redis stream to prevent duplicates await redis.del(MESSAGE_STREAM_KEY(id)).catch((err) => { console.error("Failed to trim message stream:", err); }); - + // Check if chat deletion was successful (first operation) if (!result || result[0][1] === 0) { return NextResponse.json({ error: "Failed to delete chat" } as ApiError, { status: 404 }); } - + return NextResponse.json({ success: "Chat deleted" }, { status: 200 }); } catch (error) { console.error("Error deleting chat:", error); diff --git a/src/app/api/chat/[id]/send/route.ts b/src/app/api/chat/[id]/send/route.ts index 0a29042..a5eefd8 100644 --- a/src/app/api/chat/[id]/send/route.ts +++ b/src/app/api/chat/[id]/send/route.ts @@ -70,7 +70,7 @@ export async function POST(req: NextRequest, { params }: { params: Promise<{ id: if (requestedModel !== chatJson.model || requestedProvider !== chatJson.provider) { // If the requested model or provider does not match the chat's model/provider, update the chat - const chatCopy = Object.assign({}, chatJson as any); + const chatCopy = { ...chatJson } as any; chatCopy.model = chatModel; chatCopy.provider = chatProvider; delete chatCopy.id; diff --git a/src/app/components/ChatPalette.tsx b/src/app/components/ChatPalette.tsx index f04fe99..7c504be 100644 --- a/src/app/components/ChatPalette.tsx +++ b/src/app/components/ChatPalette.tsx @@ -1,6 +1,6 @@ "use client"; -import React from "react"; +import React, { useCallback } from "react"; import useSWR from "swr"; import { ApiError, ChatResponse, GetChatsResponse } from "../../internal-lib/types/api"; import { FormEventHandler, useEffect, useRef, useState, useMemo } from "react"; @@ -32,8 +32,6 @@ export default function ChatPalette({ className, hidden: hiddenOuter, onDismiss page: 0, total: 0 }); - const [pendingDeleteId, setPendingDeleteId] = useState(null); - const pendingDeleteTimeout = useRef(null); const [selectedChatIds, setSelectedChatIds] = useState>(new Set()); const [bulkDeleteMode, setBulkDeleteMode] = useState(false); const [isTouchDevice, setIsTouchDevice] = useState(false); @@ -42,6 +40,12 @@ export default function ChatPalette({ className, hidden: hiddenOuter, onDismiss const [longPressActive, setLongPressActive] = useState(null); const [searchQuery, setSearchQuery] = useState(""); + // Rename and delete + const [pendingDeleteId, setPendingDeleteId] = useState(null); + const pendingDeleteTimeout = useRef(null); + const [renameId, setRenameId] = useState(null); + const [chatTitleRename, setChatTitleRename] = useState(null); + const { data, isLoading, mutate } = useSWR("/api/chat", async path => { return fetch(path).then(res => res.json() as Promise); }); @@ -120,6 +124,12 @@ export default function ChatPalette({ className, hidden: hiddenOuter, onDismiss e.preventDefault(); e.stopPropagation(); + if (renameId) { + setRenameId(null); + setChatTitleRename(null); + return; + } + if (bulkDeleteMode || selectedChatIds.size > 0) { setBulkDeleteMode(false); setSelectedChatIds(new Set()); @@ -140,6 +150,8 @@ export default function ChatPalette({ className, hidden: hiddenOuter, onDismiss if (e.key == "ArrowDown") { e.preventDefault(); e.stopPropagation(); + if (renameId) return; + let i = selected[0] + 1; if (i >= filteredChats.length) { i = filteredChats.length - 1 < 0 ? 0 : filteredChats.length - 1; @@ -149,30 +161,49 @@ export default function ChatPalette({ className, hidden: hiddenOuter, onDismiss if (e.key == "ArrowUp") { e.preventDefault(); e.stopPropagation(); + if (renameId) return; + let i = selected[0] - 1; if (i < 0) { i = 0; } setSelected([i, 1]); } + if (e.ctrlKey && e.key == "r") { + e.preventDefault(); + e.stopPropagation(); + + setPendingDeleteId(null); + if (pendingDeleteTimeout.current) clearTimeout(pendingDeleteTimeout.current); + + const chat = filteredChats[selected[0]]; + setRenameId(chat.id); + } if (e.key == "Enter") { e.preventDefault(); e.stopPropagation(); + // Use filteredChats for selection actions + const chat = filteredChats[selected[0]]; + + // Rename takes priority + if (renameId && chatTitleRename) { + handleRenameSave(chat.id, selected[0]); + return; + } + if (bulkDeleteMode && selectedChatIds.size > 0) { handleBulkDelete(); return; } - // Use filteredChats for selection actions - const chat = filteredChats[selected[0]]; if (!chat) return; if (pendingDeleteId === chat.id && !deletingId) { if (pendingDeleteTimeout.current) { clearTimeout(pendingDeleteTimeout.current); pendingDeleteTimeout.current = null; } - handleDelete(chat.id); + handleDelete(chat.id, selected[0]); } else if (!chat || pendingDeleteId !== chat.id) { createTab(chat); } @@ -181,6 +212,13 @@ export default function ChatPalette({ className, hidden: hiddenOuter, onDismiss e.preventDefault(); e.stopPropagation(); + // Rename takes priority + if (renameId) { + // Cancel rename + setRenameId(null); + setChatTitleRename(null); + } + if (bulkDeleteMode && selectedChatIds.size > 0) { handleBulkDelete(); return; @@ -193,7 +231,7 @@ export default function ChatPalette({ className, hidden: hiddenOuter, onDismiss clearTimeout(pendingDeleteTimeout.current); pendingDeleteTimeout.current = null; } - handleDelete(chat.id); + handleDelete(chat.id, selected[0]); } else if (pendingDeleteId !== chat.id) { setPendingDeleteId(chat.id); if (pendingDeleteTimeout.current) { @@ -210,7 +248,7 @@ export default function ChatPalette({ className, hidden: hiddenOuter, onDismiss if (hiddenOuter) { window.onkeydown = null; } else { - if (!isTouchDevice) inputRef.current?.focus(); + if (!isTouchDevice && !renameId) inputRef.current?.focus(); window.onkeydown = onKeyDown; } return () => { @@ -222,7 +260,7 @@ export default function ChatPalette({ className, hidden: hiddenOuter, onDismiss } }; // eslint-disable-next-line react-hooks/exhaustive-deps - }, [hiddenOuter, localChats.chats, pendingDeleteId, onDismiss, selected, hidden, isTouchDevice, bulkDeleteMode, selectedChatIds]); + }, [hiddenOuter, localChats.chats, pendingDeleteId, onDismiss, selected, hidden, isTouchDevice, bulkDeleteMode, selectedChatIds, renameId, chatTitleRename]); // Clean up touch timeouts when bulk mode changes useEffect(() => { @@ -249,7 +287,7 @@ export default function ChatPalette({ className, hidden: hiddenOuter, onDismiss useEffect(() => { if (!hidden) { if (isLoading) return; - (async function () { + (async function() { const chats = await fetch("/api/chat").then(res => res.json() as Promise); setLocalChats(chats && !("error" in chats) ? chats : { chats: [], @@ -329,11 +367,44 @@ export default function ChatPalette({ className, hidden: hiddenOuter, onDismiss }, DELETE_ANIMATION_DURATION); }; + const handleRenameSave = useCallback(async (chatId: string, idx: number) => { + if (!chatTitleRename) return; + setRenameId(null); + + try { + const chat = localChats.chats[idx]; + setLocalChats(prev => { + const newChats = { ...prev }; + newChats.chats[idx].label = chatTitleRename; + return newChats; + }); + + const result = await fetch(`/api/chat/${chatId}`, { + method: "POST", + body: JSON.stringify({ + label: chatTitleRename, + }), + }).then(res => res.json() as Promise<{ success: string } | ApiError>) + .catch(() => null); + if (!result) { + setLocalChats(prev => { + const newChats = { ...prev }; + newChats.chats[idx].label = chat.label; + return newChats; + }); + return; + } + } finally { // Hacky way to do defer in JS. + setChatTitleRename(null); + mutate(); // revalidate SWR + } + }, [chatTitleRename, localChats, mutate]) + // Handle delete with animation - const handleDelete = async (chatId: string) => { + const handleDelete = async (chatId: string, idx: number) => { setPendingDeleteId(null); + // Find index of chat to be deleted - const idxToDelete = localChats.chats.findIndex(c => c.id === chatId); setDeletingId(chatId); setTimeout(async () => { const result = await fetch(`/api/chat/${chatId}`, { method: "DELETE" }).then(res => res.json() as Promise<{ success: string } | ApiError>).catch(() => null); @@ -344,8 +415,8 @@ export default function ChatPalette({ className, hidden: hiddenOuter, onDismiss setLocalChats(prev => { const newChats = prev.chats.filter(c => c.id !== chatId); // If the deleted chat was selected and was the last, move selection to new last - if (idxToDelete === selected[0]) { - let newIdx = idxToDelete; + if (idx === selected[0]) { + let newIdx = idx; if (newIdx >= newChats.length) newIdx = newChats.length - 1; setSelected([Math.max(0, newIdx), 0]); } @@ -420,7 +491,7 @@ export default function ChatPalette({ className, hidden: hiddenOuter, onDismiss // Platform shortcut label (fix hydration) const [shortcutLabel, setShortcutLabel] = useState("CTRL+K"); useEffect(() => { - if (typeof window !== "undefined" && navigator.platform.toLowerCase().includes("mac")) { + if (typeof window !== "undefined" && navigator.userAgent.toLowerCase().includes("mac")) { setShortcutLabel("CMD+K"); } else { setShortcutLabel("CTRL+K"); @@ -440,6 +511,17 @@ export default function ChatPalette({ className, hidden: hiddenOuter, onDismiss const [lastIdx, lastDir] = lastSelectedRef.current; const idx = Math.max(0, Math.min(lastIdx, filteredChats.length - 1)); setSelected([idx, lastDir]); + + if (!selectedRef.current) return; + selectedRef.current.hidden = false; + } else if (!filteredChats.length) { + if (!selectedRef.current) return; + selectedRef.current.hidden = true; + } + + if (hidden) { + setRenameId(null); + setChatTitleRename(null); } }, [hidden, filteredChats.length]); @@ -578,7 +660,7 @@ export default function ChatPalette({ className, hidden: hiddenOuter, onDismiss `} onClick={() => inputRef.current?.focus()} > -
      +
      @@ -589,9 +671,11 @@ export default function ChatPalette({ className, hidden: hiddenOuter, onDismiss
      -
      - {shortcutLabel} -
      + {!isTouchDevice && ( +
      + {shortcutLabel} +
      + )}
      {bulkDeleteMode && (
      @@ -620,12 +704,12 @@ export default function ChatPalette({ className, hidden: hiddenOuter, onDismiss )}
        {!bulkDeleteMode && !isTouchDevice && ( @@ -650,7 +734,7 @@ export default function ChatPalette({ className, hidden: hiddenOuter, onDismiss
      • {section.section}
      • - {section.chats.map((chat, _) => { + {section.chats.map((chat, idx) => { // Flat index in filteredChats const flatIdx = filteredChats.findIndex(c => c.id === chat.id); const isSelected = selectedChatIds.has(chat.id); @@ -667,17 +751,19 @@ export default function ChatPalette({ className, hidden: hiddenOuter, onDismiss ${isSelected ? "bg-blue-500/20 border border-blue-500/50" : flatIdx !== selected[0] ? "hover:bg-white/[0.03]" : ""} rounded-2xl transition-all duration-200 overflow-clip hover:[&>#delete]:!opacity-100 hover:[&>#delete]:!translate-0 + group ${isDeleting ? "chat-delete-anim" : ""} ${isLongPressing ? "chat-long-press" : ""} `} onTouchStart={e => { if (!isTouchDevice) return; - + if (renameId === chat.id) return; + const target = e.target as HTMLElement; if ( - target.id === "delete" || - target.parentElement?.id === "delete" || - target.parentElement?.parentElement?.id === "delete" + target.classList.contains("child-button") || + target.parentElement?.classList?.contains("child-button") || + target.parentElement?.parentElement?.classList?.contains("child-button") ) return; touchStartRef.current = { @@ -690,12 +776,12 @@ export default function ChatPalette({ className, hidden: hiddenOuter, onDismiss if (touchStartRef.current?.chatId === chat.id) { // Add long press animation setLongPressActive(chat.id); - + // Trigger haptic feedback if available if ("vibrate" in navigator) { navigator.vibrate(50); } - + // Enter bulk mode and select this chat setBulkDeleteMode(true); setSelectedChatIds(prev => { @@ -703,24 +789,25 @@ export default function ChatPalette({ className, hidden: hiddenOuter, onDismiss newSet.add(chat.id); return newSet; }); - + // Remove animation after it completes setTimeout(() => setLongPressActive(null), 500); - + touchStartRef.current = null; } }, 500); }} - onTouchEnd={e => { + onTouchEnd={_ => { if (!isTouchDevice) return; - + if (renameId === chat.id) return; + if (touchTimeoutRef.current) { clearTimeout(touchTimeoutRef.current); touchTimeoutRef.current = null; } - + setLongPressActive(null); - + // If we're in bulk mode, handle tap as selection toggle if (bulkDeleteMode && touchStartRef.current) { const touchDuration = Date.now() - touchStartRef.current.startTime; @@ -743,10 +830,10 @@ export default function ChatPalette({ className, hidden: hiddenOuter, onDismiss createTab(chat); } } - + touchStartRef.current = null; }} - onTouchMove={e => { + onTouchMove={_ => { // Cancel long press if user moves finger if (touchTimeoutRef.current) { clearTimeout(touchTimeoutRef.current); @@ -758,12 +845,13 @@ export default function ChatPalette({ className, hidden: hiddenOuter, onDismiss onClick={e => { // Skip click handling on touch devices to avoid conflicts if (isTouchDevice) return; - + if (renameId === chat.id) return; + const clickTarget = e.target as HTMLElement; if ( - clickTarget.id === "delete" || - clickTarget.parentElement?.id === "delete" || - clickTarget.parentElement?.parentElement?.id === "delete" + clickTarget.classList.contains("child-button") || + clickTarget.parentElement?.classList?.contains("child-button") || + clickTarget.parentElement?.parentElement?.classList?.contains("child-button") ) return; if (e.shiftKey) { @@ -819,56 +907,122 @@ export default function ChatPalette({ className, hidden: hiddenOuter, onDismiss
      - {chat.label ?? "New Chat"} + {renameId !== chat.id ? ( + {chat.label ?? "New Chat"} + ) : ( + { + e.currentTarget.select(); + }} + className="flex-1 outline-none transition-all duration-250 py-1 focus:py-2 focus:px-2 border-2 border-white/50 focus:border-1 focus:border-white/10 rounded-lg focus:bg-black/10" + onInput={(e) => setChatTitleRename(e.currentTarget.value)} onChange={(e) => setChatTitleRename(e.currentTarget.value)} value={chatTitleRename ?? chat.label ?? "New Chat"} + /> + )} {timeLabel} - {/* ...existing delete button... */} {!bulkDeleteMode && ( -
      { - e.stopPropagation(); - if (deletingId) return; - if (pendingDeleteId === chat.id) { - if (pendingDeleteTimeout.current) { - clearTimeout(pendingDeleteTimeout.current); - pendingDeleteTimeout.current = null; +
      + {/* rename */} +
      { + e.stopPropagation(); + if (renameId === chat.id) { + handleRenameSave(chat.id, idx); + setChatTitleRename(null); + setRenameId(null); + } else { + setRenameId(chat.id); + // Cancel deleting + setPendingDeleteId(null); + if (pendingDeleteTimeout.current) clearTimeout(pendingDeleteTimeout.current); } - handleDelete(chat.id); - } else { - setPendingDeleteId(chat.id); - if (pendingDeleteTimeout.current) { - clearTimeout(pendingDeleteTimeout.current); + }} + > + + {/* Edit SVG */} + + + + + + {/* Checkmark SVG */} + + + + +
      + {/* delete */} +
      { + e.stopPropagation(); + if (renameId === chat.id) { + setChatTitleRename(null); + setRenameId(null); + return; } - pendingDeleteTimeout.current = setTimeout(() => setPendingDeleteId(id => id === chat.id ? null : id), 3000); - } - }} - > - - {/* Trash SVG */} - - - - - - - {/* Checkmark SVG */} - - - - + + if (deletingId) return; + if (pendingDeleteId === chat.id) { + if (pendingDeleteTimeout.current) { + clearTimeout(pendingDeleteTimeout.current); + pendingDeleteTimeout.current = null; + } + handleDelete(chat.id, idx); + } else { + setPendingDeleteId(chat.id); + if (pendingDeleteTimeout.current) { + clearTimeout(pendingDeleteTimeout.current); + } + pendingDeleteTimeout.current = setTimeout(() => setPendingDeleteId(id => id === chat.id ? null : id), 3000); + } + }} + > + + {/* Trash SVG */} + + + + + + + {/* Checkmark SVG */} + + + + + + {/* Cancel SVG */} + + + + + +
      )} @@ -883,7 +1037,7 @@ export default function ChatPalette({ className, hidden: hiddenOuter, onDismiss )}
-
+
); } From b7892f82a94c276b6e8bed2a847ce8bd96d61cfa Mon Sep 17 00:00:00 2001 From: FoxTale-Labs <72300200+FoxTale-Labs@users.noreply.github.com> Date: Fri, 20 Jun 2025 22:48:46 +0200 Subject: [PATCH 04/17] edge cases + touch improvements + same chat palette shortcut now also closes it --- src/app/components/ChatPalette.tsx | 13 ++++++++++++- src/app/components/Navbar.tsx | 4 ++-- 2 files changed, 14 insertions(+), 3 deletions(-) diff --git a/src/app/components/ChatPalette.tsx b/src/app/components/ChatPalette.tsx index 7c504be..680b231 100644 --- a/src/app/components/ChatPalette.tsx +++ b/src/app/components/ChatPalette.tsx @@ -190,6 +190,9 @@ export default function ChatPalette({ className, hidden: hiddenOuter, onDismiss if (renameId && chatTitleRename) { handleRenameSave(chat.id, selected[0]); return; + } else if (renameId) { + setRenameId(null); + return; } if (bulkDeleteMode && selectedChatIds.size > 0) { @@ -911,9 +914,17 @@ export default function ChatPalette({ className, hidden: hiddenOuter, onDismiss {chat.label ?? "New Chat"} ) : ( { + autoFocus + onFocus={e => { e.currentTarget.select(); }} + onSubmit={_ => { + if (chatTitleRename) { + handleRenameSave(chat.id, idx); + return; + } + setRenameId(null); + }} className="flex-1 outline-none transition-all duration-250 py-1 focus:py-2 focus:px-2 border-2 border-white/50 focus:border-1 focus:border-white/10 rounded-lg focus:bg-black/10" onInput={(e) => setChatTitleRename(e.currentTarget.value)} onChange={(e) => setChatTitleRename(e.currentTarget.value)} value={chatTitleRename ?? chat.label ?? "New Chat"} /> diff --git a/src/app/components/Navbar.tsx b/src/app/components/Navbar.tsx index 50609f4..ecf8e78 100644 --- a/src/app/components/Navbar.tsx +++ b/src/app/components/Navbar.tsx @@ -129,12 +129,12 @@ export function Navbar() { const isMac = navigator.userAgent.toLowerCase().includes("mac"); if ((isMac && e.metaKey && e.key.toLowerCase() === "k") || (!isMac && e.ctrlKey && e.key.toLowerCase() === "k")) { e.preventDefault(); - setShowPalette(true); + setShowPalette(!showPalette); } } window.addEventListener("keydown", onKeyDown); return () => window.removeEventListener("keydown", onKeyDown); - }, []); + }, [showPalette]); // Listen for chat title updates from fallback title generation useEffect(() => { From 1a184dd858b136ef32346a3bcb4fcb9d9ab4a53d Mon Sep 17 00:00:00 2001 From: FoxTale-Labs <72300200+FoxTale-Labs@users.noreply.github.com> Date: Fri, 20 Jun 2025 22:50:40 +0200 Subject: [PATCH 05/17] ctrl+arrow now moves 5 chats at once --- src/app/components/ChatPalette.tsx | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/src/app/components/ChatPalette.tsx b/src/app/components/ChatPalette.tsx index 680b231..4ea2b8e 100644 --- a/src/app/components/ChatPalette.tsx +++ b/src/app/components/ChatPalette.tsx @@ -147,12 +147,14 @@ export default function ChatPalette({ className, hidden: hiddenOuter, onDismiss onDismiss(); } } + + // Navigation if (e.key == "ArrowDown") { e.preventDefault(); e.stopPropagation(); if (renameId) return; - let i = selected[0] + 1; + let i = selected[0] + (e.ctrlKey ? 5 : 1); if (i >= filteredChats.length) { i = filteredChats.length - 1 < 0 ? 0 : filteredChats.length - 1; } @@ -163,12 +165,13 @@ export default function ChatPalette({ className, hidden: hiddenOuter, onDismiss e.stopPropagation(); if (renameId) return; - let i = selected[0] - 1; + let i = selected[0] - (e.ctrlKey ? 5 : 1); if (i < 0) { i = 0; } setSelected([i, 1]); } + if (e.ctrlKey && e.key == "r") { e.preventDefault(); e.stopPropagation(); From e32219b2bf84adb2b96fd6f3edde2d86d2ddb09a Mon Sep 17 00:00:00 2001 From: FoxTale-Labs <72300200+FoxTale-Labs@users.noreply.github.com> Date: Fri, 20 Jun 2025 22:58:18 +0200 Subject: [PATCH 06/17] Handle shortcut edge cases --- src/app/components/ChatPalette.tsx | 76 +++++++++++++++--------------- 1 file changed, 39 insertions(+), 37 deletions(-) diff --git a/src/app/components/ChatPalette.tsx b/src/app/components/ChatPalette.tsx index 4ea2b8e..530e460 100644 --- a/src/app/components/ChatPalette.tsx +++ b/src/app/components/ChatPalette.tsx @@ -120,7 +120,8 @@ export default function ChatPalette({ className, hidden: hiddenOuter, onDismiss // Keyboard navigation useEffect(() => { const onKeyDown: typeof window.onkeydown = (e) => { - if (e.key == "Escape") { + // Confirmation/Cancelation buttons + if (!e.shiftKey && !e.ctrlKey && !e.altKey && e.key == "Escape") { e.preventDefault(); e.stopPropagation(); @@ -147,9 +148,41 @@ export default function ChatPalette({ className, hidden: hiddenOuter, onDismiss onDismiss(); } } + if (!e.shiftKey && !e.ctrlKey && !e.altKey && e.key == "Enter") { + e.preventDefault(); + e.stopPropagation(); + + // Use filteredChats for selection actions + const chat = filteredChats[selected[0]]; + + // Rename takes priority + if (renameId && chatTitleRename) { + handleRenameSave(chat.id, selected[0]); + return; + } else if (renameId) { + setRenameId(null); + return; + } + + if (bulkDeleteMode && selectedChatIds.size > 0) { + handleBulkDelete(); + return; + } + + if (!chat) return; + if (pendingDeleteId === chat.id && !deletingId) { + if (pendingDeleteTimeout.current) { + clearTimeout(pendingDeleteTimeout.current); + pendingDeleteTimeout.current = null; + } + handleDelete(chat.id, selected[0]); + } else if (!chat || pendingDeleteId !== chat.id) { + createTab(chat); + } + } // Navigation - if (e.key == "ArrowDown") { + if (!e.shiftKey && !e.altKey && e.key == "ArrowDown") { e.preventDefault(); e.stopPropagation(); if (renameId) return; @@ -160,7 +193,7 @@ export default function ChatPalette({ className, hidden: hiddenOuter, onDismiss } setSelected([i, -1]); } - if (e.key == "ArrowUp") { + if (!e.shiftKey && !e.altKey && e.key == "ArrowUp") { e.preventDefault(); e.stopPropagation(); if (renameId) return; @@ -172,7 +205,8 @@ export default function ChatPalette({ className, hidden: hiddenOuter, onDismiss setSelected([i, 1]); } - if (e.ctrlKey && e.key == "r") { + // Action buttons + if (e.ctrlKey && !e.shiftKey && !e.altKey && e.key == "r") { e.preventDefault(); e.stopPropagation(); @@ -182,39 +216,7 @@ export default function ChatPalette({ className, hidden: hiddenOuter, onDismiss const chat = filteredChats[selected[0]]; setRenameId(chat.id); } - if (e.key == "Enter") { - e.preventDefault(); - e.stopPropagation(); - - // Use filteredChats for selection actions - const chat = filteredChats[selected[0]]; - - // Rename takes priority - if (renameId && chatTitleRename) { - handleRenameSave(chat.id, selected[0]); - return; - } else if (renameId) { - setRenameId(null); - return; - } - - if (bulkDeleteMode && selectedChatIds.size > 0) { - handleBulkDelete(); - return; - } - - if (!chat) return; - if (pendingDeleteId === chat.id && !deletingId) { - if (pendingDeleteTimeout.current) { - clearTimeout(pendingDeleteTimeout.current); - pendingDeleteTimeout.current = null; - } - handleDelete(chat.id, selected[0]); - } else if (!chat || pendingDeleteId !== chat.id) { - createTab(chat); - } - } - if (e.key === "Delete" || (e.shiftKey && e.key === "Backspace")) { + if (!e.ctrlKey && !e.altKey && ((!e.shiftKey && e.key === "Delete") || (e.shiftKey && e.key === "Backspace"))) { e.preventDefault(); e.stopPropagation(); From b2161fc990ef19483ee200fd60bb9ca0e0f78c0f Mon Sep 17 00:00:00 2001 From: FoxTale-Labs <72300200+FoxTale-Labs@users.noreply.github.com> Date: Fri, 20 Jun 2025 23:08:34 +0200 Subject: [PATCH 07/17] Making macOS tab switching/closing shortcut optimized (ctrl vs. opt) --- src/app/components/Tabs.tsx | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/src/app/components/Tabs.tsx b/src/app/components/Tabs.tsx index 0e3e49f..8551b01 100755 --- a/src/app/components/Tabs.tsx +++ b/src/app/components/Tabs.tsx @@ -142,6 +142,7 @@ export default function Tabs({ onTabChange, onTabCreate, onTabClose, tabs: rawTa // Keyboard shortcuts: Alt/Opt+W to close, Alt/Opt+Tab to next, Alt/Opt+Shift+Tab to prev useEffect(() => { + const isMac = typeof window !== "undefined" && navigator.userAgent.toLowerCase().includes("mac"); function handleKeyDown(e: KeyboardEvent) { // Don't trigger shortcuts in input, textarea, or contenteditable // const target = e.target as HTMLElement; @@ -158,7 +159,7 @@ export default function Tabs({ onTabChange, onTabCreate, onTabClose, tabs: rawTa return; } // Next tab: Alt/Opt+Tab - if (e.altKey && !e.ctrlKey && !e.metaKey && !e.shiftKey && (e.key === "Tab" || e.code === "Tab")) { + if ((!isMac ? (e.altKey && !e.ctrlKey) : (e.ctrlKey && !e.altKey)) && !e.metaKey && !e.shiftKey && (e.key === "Tab" || e.code === "Tab")) { e.preventDefault(); e.stopPropagation(); let nextIdx = activeTab + 1; @@ -167,7 +168,7 @@ export default function Tabs({ onTabChange, onTabCreate, onTabClose, tabs: rawTa return; } // Previous tab: Alt/Opt+Shift+Tab - if (e.altKey && !e.ctrlKey && !e.metaKey && e.shiftKey && (e.key === "Tab" || e.code === "Tab")) { + if ((!isMac ? (e.altKey && !e.ctrlKey) : (e.ctrlKey && !e.altKey)) && !e.metaKey && e.shiftKey && (e.key === "Tab" || e.code === "Tab")) { e.preventDefault(); e.stopPropagation(); let prevIdx = activeTab - 1; @@ -184,7 +185,7 @@ export default function Tabs({ onTabChange, onTabCreate, onTabClose, tabs: rawTa const [closeShortcut, setCloseShortcut] = useState("Alt+W"); useEffect(() => { const isMac = navigator.userAgent.toLowerCase().includes("mac"); - setCloseShortcut(isMac ? "⌥W" : "Alt+W"); + setCloseShortcut(isMac ? "⌃W" : "Alt+W"); }, []); return ( From 98733db191a64c9f1c1554d7df861c4e0c1640df Mon Sep 17 00:00:00 2001 From: FoxTale-Labs <72300200+FoxTale-Labs@users.noreply.github.com> Date: Fri, 20 Jun 2025 23:09:22 +0200 Subject: [PATCH 08/17] =?UTF-8?q?visually=20updating=20"CMD+K"=20to=20use?= =?UTF-8?q?=20"=E2=8C=98=20K"?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/app/components/ChatPalette.tsx | 6 +++--- src/app/components/Tabs.tsx | 2 +- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/src/app/components/ChatPalette.tsx b/src/app/components/ChatPalette.tsx index 530e460..cc1703f 100644 --- a/src/app/components/ChatPalette.tsx +++ b/src/app/components/ChatPalette.tsx @@ -500,7 +500,7 @@ export default function ChatPalette({ className, hidden: hiddenOuter, onDismiss const [shortcutLabel, setShortcutLabel] = useState("CTRL+K"); useEffect(() => { if (typeof window !== "undefined" && navigator.userAgent.toLowerCase().includes("mac")) { - setShortcutLabel("CMD+K"); + setShortcutLabel("⌘ K"); } else { setShortcutLabel("CTRL+K"); } @@ -680,9 +680,9 @@ export default function ChatPalette({ className, hidden: hiddenOuter, onDismiss {!isTouchDevice && ( -
+ {shortcutLabel} -
+ )} {bulkDeleteMode && ( diff --git a/src/app/components/Tabs.tsx b/src/app/components/Tabs.tsx index 8551b01..6e75724 100755 --- a/src/app/components/Tabs.tsx +++ b/src/app/components/Tabs.tsx @@ -185,7 +185,7 @@ export default function Tabs({ onTabChange, onTabCreate, onTabClose, tabs: rawTa const [closeShortcut, setCloseShortcut] = useState("Alt+W"); useEffect(() => { const isMac = navigator.userAgent.toLowerCase().includes("mac"); - setCloseShortcut(isMac ? "⌃W" : "Alt+W"); + setCloseShortcut(isMac ? "⌃ W" : "Alt+W"); }, []); return ( From 00864fed763bb28e0f956d2404e5e13919fa6fce Mon Sep 17 00:00:00 2001 From: FoxTale-Labs <72300200+FoxTale-Labs@users.noreply.github.com> Date: Sat, 21 Jun 2025 01:01:33 +0200 Subject: [PATCH 09/17] bug fix --- src/app/chat/[id]/layout.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/app/chat/[id]/layout.tsx b/src/app/chat/[id]/layout.tsx index 8ac1725..0adc27f 100644 --- a/src/app/chat/[id]/layout.tsx +++ b/src/app/chat/[id]/layout.tsx @@ -6,7 +6,7 @@ import ModelProviderClientWrapper from "./ModelProviderClientWrapper"; async function fetchModelProvider(chatId: string) { // You may need to pass cookies/headers for auth if required const res = await fetch(`${process.env.NEXT_PUBLIC_APP_URL || ""}/api/chat/${chatId}`, - { headers: { Cookie: cookies().toString() } } + { headers: { Cookie: (await cookies()).toString() } } ); if (!res.ok) return { model: null, provider: null }; const data = await res.json(); From 1e69c86e27be6a27d47d60b1ddfac775d9ed6e73 Mon Sep 17 00:00:00 2001 From: FoxTale-Labs <72300200+FoxTale-Labs@users.noreply.github.com> Date: Sat, 21 Jun 2025 23:05:38 +0200 Subject: [PATCH 10/17] complete overhaul of chat palette and reverse CTRL change for tabs and reorgnaizing and refactor --- src/app/api/chat/[id]/route.ts | 31 +- src/app/api/chat/route.ts | 15 +- src/app/components/CancelSvg.tsx | 10 + src/app/components/ChatInput.tsx | 2 +- src/app/components/ChatPalette.tsx | 1851 +++++++++-------- src/app/components/ChatPalette/ChatItem.tsx | 224 ++ src/app/components/ChatPalette/style.css | 56 + src/app/components/ChatSvg.tsx | 18 + src/app/components/CheckmarkSvg.tsx | 9 + .../{Dropdown.tsx => DropdownGrid.tsx} | 4 +- src/app/components/EditSvg.tsx | 9 + src/app/components/StarSvg.tsx | 20 + src/app/components/Tabs.tsx | 9 +- src/app/components/TrashSvg.tsx | 10 + src/app/lib/types/keyboardInput.ts | 1 + src/app/lib/utils/isNestedButton.ts | 21 + ...calStorageTabs.tsx => localStorageTabs.ts} | 0 src/internal-lib/redis.ts | 1 + src/internal-lib/types/api.ts | 4 + 19 files changed, 1404 insertions(+), 891 deletions(-) create mode 100644 src/app/components/CancelSvg.tsx create mode 100644 src/app/components/ChatPalette/ChatItem.tsx create mode 100644 src/app/components/ChatPalette/style.css create mode 100644 src/app/components/ChatSvg.tsx create mode 100644 src/app/components/CheckmarkSvg.tsx rename src/app/components/{Dropdown.tsx => DropdownGrid.tsx} (98%) create mode 100644 src/app/components/EditSvg.tsx create mode 100644 src/app/components/StarSvg.tsx create mode 100644 src/app/components/TrashSvg.tsx create mode 100644 src/app/lib/types/keyboardInput.ts create mode 100644 src/app/lib/utils/isNestedButton.ts rename src/app/lib/utils/{localStorageTabs.tsx => localStorageTabs.ts} (100%) diff --git a/src/app/api/chat/[id]/route.ts b/src/app/api/chat/[id]/route.ts index 3a23c32..3ba641f 100644 --- a/src/app/api/chat/[id]/route.ts +++ b/src/app/api/chat/[id]/route.ts @@ -1,7 +1,7 @@ import { Message } from "@/app/lib/types/ai"; import { NextRequest, NextResponse } from "next/server"; import { auth, currentUser } from "@clerk/nextjs/server"; -import redis, { USER_CHATS_KEY, USER_CHATS_INDEX_KEY, CHAT_MESSAGES_KEY, USER_FILES_KEY, MESSAGE_STREAM_KEY } from "@/internal-lib/redis"; +import redis, { USER_CHATS_KEY, USER_CHATS_INDEX_KEY, CHAT_MESSAGES_KEY, USER_FILES_KEY, MESSAGE_STREAM_KEY, USER_PINNED_CHATS_KEY } from "@/internal-lib/redis"; import { join } from "path"; import { unlink } from "fs/promises"; import { ApiError } from "@/internal-lib/types/api"; @@ -10,6 +10,7 @@ import { ApiError } from "@/internal-lib/types/api"; interface ChatResponse { id: string; label: string; + pinned?: boolean; model: string; provider: string; history: Message[]; @@ -53,12 +54,14 @@ export async function POST(req: NextRequest, { params }: { params: Promise<{ id: try { const updateBody = await req.json() as { label?: string; + pinned?: boolean; }; // Check if no fields are provided in the body - if (!updateBody.label) { // It's just label atm, model/provider can't be changed via updating the chat only by sending - return NextResponse.json({ error: "At least one field is required (label)" } as ApiError, { status: 400 }); + if (updateBody.label === undefined && updateBody.pinned === undefined) { // It's just label atm, model/provider can't be changed via updating the chat only by sending + return NextResponse.json({ error: "At least one field is required (label, pinned)" } as ApiError, { status: 400 }); } + const user = await currentUser(); if (!user) return NextResponse.json({ error: "Unauthorized" } as ApiError, { status: 200 }); if (user.banned) return NextResponse.json({ error: "Unauthorized" } as ApiError, { status: 200 }); @@ -70,16 +73,24 @@ export async function POST(req: NextRequest, { params }: { params: Promise<{ id: const chat = JSON.parse(rawChat); // If label is provided, update label - if (updateBody.label) { - chat.label = updateBody.label; + if (updateBody.label !== undefined && updateBody.label.trim() !== "") { + if (updateBody.label.trim().length > 100) { + return NextResponse.json({ error: "Label is too long, maximum length is 100 characters" } as ApiError, { status: 400 }); + } + chat.label = updateBody.label.trim(); } - - // Update the chat - const updated = await redis.hset(USER_CHATS_KEY(user.id), id, JSON.stringify(chat)); - if (!updated) { - return NextResponse.json({ error: "Failed to update chat" } as ApiError, { status: 500 }); + // If pinned is provided, update pinned + if (updateBody.pinned !== undefined) { + if (updateBody.pinned) { + await redis.zadd(USER_PINNED_CHATS_KEY(user.id), Date.now(), id); + } else { + await redis.zrem(USER_PINNED_CHATS_KEY(user.id), id); + } + chat.pinned = updateBody.pinned; } + // Update the chat + await redis.hset(USER_CHATS_KEY(user.id), id, JSON.stringify(chat)); return NextResponse.json({ success: "Updated chat successfully" }, { status: 200 }); } catch (error) { console.error("Unknown error occurred while updating a chat: ", (error as Error).message); diff --git a/src/app/api/chat/route.ts b/src/app/api/chat/route.ts index 6c571c8..2a5e1f9 100644 --- a/src/app/api/chat/route.ts +++ b/src/app/api/chat/route.ts @@ -2,7 +2,7 @@ import { NextRequest, NextResponse } from "next/server"; import { AVAILABLE_PROVIDERS } from "@/app/lib/types/ai"; import { Chat } from "@/app/lib/types/ai"; import { auth, currentUser } from "@clerk/nextjs/server"; -import redis, { USER_CHATS_INDEX_KEY, USER_CHATS_KEY } from "@/internal-lib/redis"; +import redis, { USER_CHATS_INDEX_KEY, USER_CHATS_KEY, USER_PINNED_CHATS_KEY } from "@/internal-lib/redis"; import "@/internal-lib/redis"; import { byokAvailable } from "@/internal-lib/utils/byok"; import { getChatClass } from "@/internal-lib/utils/getChatClass"; @@ -29,7 +29,10 @@ export async function GET(req: NextRequest) { const startIndex = (page - 1) * limit; const endIndex = startIndex + limit - 1; - const chatIds = await redis.zrevrange(USER_CHATS_INDEX_KEY(user.userId), startIndex, endIndex); + const chatIds = await redis.zrevrange(USER_CHATS_INDEX_KEY(user.userId), startIndex, endIndex).catch((err) => { + console.error("Error fetching chat IDs:", err); + return [] as string[]; + }); if (chatIds.length === 0) { return NextResponse.json({ chats: [], @@ -39,9 +42,15 @@ export async function GET(req: NextRequest) { hasMore: false }, { status: 200 }); } + const pinnedChatIds = await redis.smembers(USER_PINNED_CHATS_KEY(user.userId)).catch((err) => { + console.error("Error fetching pinned chat IDs:", err); + return [] as string[]; + }); + // Remove all pinned chat IDs from the main chat IDs list + const filteredChatIds = chatIds.filter(id => !pinnedChatIds.includes(id)); // Get chat data from hash - const rawChats = await redis.hmget(USER_CHATS_KEY(user.userId), ...chatIds); + const rawChats = await redis.hmget(USER_CHATS_KEY(user.userId), ...pinnedChatIds, ...filteredChatIds); const chats = rawChats .map((chatStr, i) => { try { diff --git a/src/app/components/CancelSvg.tsx b/src/app/components/CancelSvg.tsx new file mode 100644 index 0000000..f7add6c --- /dev/null +++ b/src/app/components/CancelSvg.tsx @@ -0,0 +1,10 @@ +import { SVGAttributes } from "react"; + +export default function CancelSvg(props: SVGAttributes) { + return ( + + + + + ) +} diff --git a/src/app/components/ChatInput.tsx b/src/app/components/ChatInput.tsx index 8266ac6..21a63a1 100644 --- a/src/app/components/ChatInput.tsx +++ b/src/app/components/ChatInput.tsx @@ -2,7 +2,7 @@ import Image from "next/image"; import { useEffect, useState, useRef, FormEventHandler, useMemo } from "react"; -import Dropdown from "./Dropdown"; +import Dropdown from "./DropdownGrid"; import { ModelCapabilities } from "../lib/types/ai"; import { escape as escapeHtml } from "html-escaper"; import { getAllModelCapabilities } from "@/internal-lib/utils/getAllModelCapabilities"; diff --git a/src/app/components/ChatPalette.tsx b/src/app/components/ChatPalette.tsx index cc1703f..22d0df1 100644 --- a/src/app/components/ChatPalette.tsx +++ b/src/app/components/ChatPalette.tsx @@ -1,12 +1,14 @@ -"use client"; - +import "./ChatPalette/style.css"; import React, { useCallback } from "react"; import useSWR from "swr"; import { ApiError, ChatResponse, GetChatsResponse } from "../../internal-lib/types/api"; -import { FormEventHandler, useEffect, useRef, useState, useMemo } from "react"; +import { useEffect, useRef, useState, useMemo } from "react"; import { addAndSaveTabsLocally } from "../lib/utils/localStorageTabs"; import { useRouter } from "next/navigation"; import { format, isToday, isYesterday, isThisWeek, formatRelative } from "date-fns"; +import isNestedButton from "../lib/utils/isNestedButton"; +import ChatItem from "./ChatPalette/ChatItem"; +import { Key } from "../lib/types/keyboardInput"; interface ChatPaletteProps { className?: string; @@ -14,325 +16,685 @@ interface ChatPaletteProps { onDismiss: () => void; } +// Local constants +export const PINNED_SECTION = "📌 Pinned"; // Animation duration in ms (should match CSS) -const DELETE_ANIMATION_DURATION = 300; +const DELETE_ANIMATION_DURATION = 250; // ms +const LONG_PRESS_DURATION = 500; // ms -export default function ChatPalette({ className, hidden: hiddenOuter, onDismiss }: ChatPaletteProps) { - const inputRef = useRef(null); - const [showLabel, setShowLabel] = useState(true); - const [hidden, setHidden] = useState(hiddenOuter); - const [selected, setSelected] = useState<[number, number]>([0, 0]); - const selectedRef = useRef(null); - const listRef = useRef(null); - const [deletingId, setDeletingId] = useState(null); - const [localChats, setLocalChats] = useState({ - chats: [], - hasMore: false, - limit: 0, - page: 0, - total: 0 - }); - const [selectedChatIds, setSelectedChatIds] = useState>(new Set()); - const [bulkDeleteMode, setBulkDeleteMode] = useState(false); - const [isTouchDevice, setIsTouchDevice] = useState(false); - const touchTimeoutRef = useRef(null); - const touchStartRef = useRef<{ chatId: string; startTime: number } | null>(null); - const [longPressActive, setLongPressActive] = useState(null); - const [searchQuery, setSearchQuery] = useState(""); - // Rename and delete - const [pendingDeleteId, setPendingDeleteId] = useState(null); - const pendingDeleteTimeout = useRef(null); - const [renameId, setRenameId] = useState(null); - const [chatTitleRename, setChatTitleRename] = useState(null); +// Fetch chats +// Helper to group chats by date section +export function getSectionLabel(date: number) { + if (isToday(date)) return "Today"; + if (isYesterday(date)) return "Yesterday"; + if (isThisWeek(date, { weekStartsOn: 1 })) return format(date, "EEEE"); // Weekday name + return formatRelative(date, "MM/dd/yyyy"); // Fallback to date +} - const { data, isLoading, mutate } = useSWR("/api/chat", async path => { - return fetch(path).then(res => res.json() as Promise); +// Extracted function to group and sort chats into sections +function parseChatsWithSections(data: GetChatsResponse) { + const chatsWithSections = new Map(); + data.chats.forEach(chat => { + const date = chat.createdAt ?? Date.now(); + const section = !chat.pinned ? getSectionLabel(date) : PINNED_SECTION; + if (!chatsWithSections.has(section)) { + chatsWithSections.set(section, []); + } + chatsWithSections.get(section)?.push(chat); }); - // Sync localChats with SWR data - useEffect(() => { - if (data && !("error" in data)) { - setLocalChats(data); - } - }, [data]); + // Sort sections by pinned first, then by date + const sortedSections = new Map( + [...chatsWithSections.entries()].sort((a, b) => { + if (a[0] === PINNED_SECTION) return -1; // Pinned section first + if (b[0] === PINNED_SECTION) return 1; + const aDate = a[1][0].createdAt ?? Date.now(); + const bDate = b[1][0].createdAt ?? Date.now(); + return bDate - aDate; // Sort by date descending + }) + ); - // Scroll/selection effect: keep selected div at bottom if possible - useEffect(() => { - const listElement = listRef.current; - if (!listElement) return; - - const el = chatItemRefs.current[selected[0]]; - if (el) { - const elTop = el.offsetTop; - const elHeight = el.offsetHeight; - const listHeight = listElement.clientHeight; - const listScrollHeight = listElement.scrollHeight; - - // Try to keep the selected item at the bottom if possible - // If the selected item fits below the current scroll, scroll so it's at the bottom - let targetScrollTop = elTop + elHeight - listHeight; - // Clamp to valid scroll range - targetScrollTop = Math.max(0, Math.min(targetScrollTop, listScrollHeight - listHeight)); - - listElement.scrollTo({ - top: targetScrollTop, - behavior: "smooth", - }); + return { + chats: sortedSections, + total: data.total, + page: data.page, + limit: data.limit, + hasMore: data.hasMore, + }; +} - // Move the selectedRef div as well - if (selectedRef.current) { - selectedRef.current.style.setProperty("--top-pos", `${elTop}px`); +export default function ChatPalette({ className, hidden: hiddenOuter, onDismiss }: ChatPaletteProps) { + const { data, isLoading, isValidating: isLoadingMore, mutate } = useSWR("/api/chat?page=1", async (url: string) => { + const res = await fetch(url, { + cache: "no-cache", + next: { revalidate: 0 }, + }); + if (!res.ok) { + try { + const errorData = await res.json() as ApiError; + throw new Error(errorData.error || "Failed to fetch chats"); + } catch (error) { + throw new Error("Failed to fetch chats"); } } - }, [selected, localChats?.chats?.length]); - useEffect(() => { - const selectedDiv = selectedRef.current; - if (selectedDiv) { - selectedDiv.style.setProperty("--top-pos", "0px"); - } - }, []); + // Parse response data + const data = await res.json() as GetChatsResponse; + return parseChatsWithSections(data); + }); - // Detect touch device - useEffect(() => { - const checkTouchDevice = () => { - setIsTouchDevice("ontouchstart" in window || navigator.maxTouchPoints > 0); - }; - checkTouchDevice(); - window.addEventListener("resize", checkTouchDevice); - return () => window.removeEventListener("resize", checkTouchDevice); - }, []); + const [hidden, setHidden] = useState(hiddenOuter); + // [[sectionIndex, chatIndex], movementDirection] + const [selected, setSelected] = useState<[[number, number], number]>([[0, 0], 0]); + // [[sectionIndex, chatIndex], movementDirection] + const lastSelectedRef = useRef<[[number, number], number]>([[0, 0], 0]); + const searchRef = useRef(null); + const [searchQuery, setSearchQuery] = useState(""); + const highlightRef = useRef(null); + const listRef = useRef(null); + const chatItemRefs = useRef<(HTMLLIElement | null)[]>([]); - const router = useRouter(); - function createTab(chat: ChatResponse) { - addAndSaveTabsLocally(localStorage, { - id: chat.id, - label: chat.label ?? "New Tab", - link: `/chat/${chat.id}` + // Renaming + const [renamingId, setRenamingId] = useState(null); + const [newChatTitle, setNewChatTitle] = useState(null); + // Deleting + const [pendingDeleteId, setPendingDeleteId] = useState(null); + const pendingDeleteTimeout = useRef(null); + const [deletingId, setDeletingId] = useState(null); + const [bulkDeleteMode, setBulkDeleteMode] = useState(false); + // Touch handling (Bulk delete) + const [longPressActive, setLongPressActive] = useState(null); + const [bulkSelectedChatIds, setSelectedChatIds] = useState>(new Set()); + const lastSelectedBulkChatRef = useRef(null); + const touchStartRef = useRef<{ chatId: string; startTime: number } | null>(null); + const touchTimeoutRef = useRef(null); + + // OS awareness for shortcuts + const isTouchDevice = useMemo(() => "ontouchstart" in window || navigator.maxTouchPoints > 0, []); + const chatPaletteShortcut = useMemo(() => { + if (isTouchDevice) return ""; + return typeof window !== "undefined" ? window.navigator.userAgent.toLowerCase().includes("mac") ? "⌘ K" : "Ctrl + K" : ""; + }, [isTouchDevice]); + + const filteredChats = useMemo(() => { + if (!data || !data.chats) return data?.chats || new Map(); + if (!searchQuery.trim()) return data.chats; + const query = searchQuery.toLowerCase(); + const filtered = new Map(); + data.chats.forEach((chats, section) => { + const filteredChats = chats.filter(chat => { + const titleMatch = chat.label?.toLowerCase().includes(query) || false; + const modelMatch = chat.model.toLowerCase().includes(query); + const providerMatch = chat.provider.toLowerCase().includes(query); + return titleMatch || modelMatch || providerMatch; + }); + if (filteredChats.length > 0) { + filtered.set(section, filteredChats); + } }); - router.push(`/chat/${chat.id}`); - setTimeout(() => { - onDismiss(); - }, 75); // Delay to allow navigation to start - } + return filtered; + }, [data, searchQuery]); - // Keyboard navigation useEffect(() => { - const onKeyDown: typeof window.onkeydown = (e) => { - // Confirmation/Cancelation buttons - if (!e.shiftKey && !e.ctrlKey && !e.altKey && e.key == "Escape") { - e.preventDefault(); - e.stopPropagation(); - - if (renameId) { - setRenameId(null); - setChatTitleRename(null); - return; + setHidden(hiddenOuter); + if (!hiddenOuter) { + mutate(data, { optimisticData: data, revalidate: true }); + } + }, [hiddenOuter, data, mutate]); + + const keyboardShortcutHandler = useCallback((e: KeyboardEvent) => { + const key = e.key as Key; + + // Movement shortcuts + if (!e.altKey && !e.ctrlKey && !e.metaKey && key === "ArrowUp") { + e.preventDefault(); + e.stopPropagation(); + + const isFaster = e.shiftKey; + const currentSectionIndex = selected[0][0]; + const currentIndex = selected[0][1]; + + let hasWrappedAround = false; + + const sectionKeys = Array.from(filteredChats.keys()); + let nextSectionIndex = currentSectionIndex; + let nextIndex = currentIndex; + + if (!isFaster) { + // Move up by 1 + nextIndex = currentIndex - 1; + if (nextIndex < 0) { + nextSectionIndex = currentSectionIndex - 1; + if (nextSectionIndex < 0) { + nextSectionIndex = sectionKeys.length - 1; + hasWrappedAround = true; + } + const prevSectionChats = filteredChats.get(sectionKeys[nextSectionIndex]) || []; + nextIndex = prevSectionChats.length - 1; } - - if (bulkDeleteMode || selectedChatIds.size > 0) { - setBulkDeleteMode(false); - setSelectedChatIds(new Set()); - return; + } else { + // Move up by 5 (across sections if needed), but do not wrap around + let remaining = 5; + let tempSectionIndex = currentSectionIndex; + let tempIndex = currentIndex; + while (remaining > 1) { + if (tempIndex - remaining >= 0) { + tempIndex -= remaining; + remaining = 0; + } else { + remaining -= (tempIndex + 1); + tempSectionIndex -= 1; + if (tempSectionIndex < 0) { + // Stop at the first item, do not wrap + tempSectionIndex = 0; + tempIndex = 0; + break; + } + const prevSectionChats = filteredChats.get(sectionKeys[tempSectionIndex]) || []; + tempIndex = prevSectionChats.length - 1; + } } + // For shift+up, do not wrap, so hasWrappedAround is always false + nextSectionIndex = tempSectionIndex; + nextIndex = tempIndex; + } - const chat = filteredChats[selected[0]]; - if (chat && pendingDeleteId === chat.id && !deletingId) { - setPendingDeleteId(""); - if (pendingDeleteTimeout.current) { - clearTimeout(pendingDeleteTimeout.current); - pendingDeleteTimeout.current = null; + setSelected([[nextSectionIndex, nextIndex], !hasWrappedAround ? 1 : -1]); + } + if (!e.altKey && !e.ctrlKey && !e.metaKey && key === "ArrowDown") { + e.preventDefault(); + e.stopPropagation(); + + const isFaster = e.shiftKey; + const currentSectionIndex = selected[0][0] || 0; + const currentIndex = selected[0][1]; + + let hasWrappedAround = false; + + const sectionKeys = Array.from(filteredChats.keys()); + let nextSectionIndex = currentSectionIndex; + let nextIndex = currentIndex; + + if (!isFaster) { + // Move down by 1 + nextIndex = currentIndex + 1; + const sectionLength = filteredChats.get(sectionKeys[currentSectionIndex])!.length; + if (nextIndex >= sectionLength) { + nextSectionIndex = currentSectionIndex + 1; + if (nextSectionIndex >= sectionKeys.length) { + nextSectionIndex = 0; + nextIndex = 0; + hasWrappedAround = true; + } else { + nextIndex = 0; } - } else if (!chat || pendingDeleteId !== chat.id) { - onDismiss(); } + } else { + // Move down by 5 (across sections if needed), but do not wrap around + let remaining = 5; + let tempSectionIndex = currentSectionIndex; + let tempIndex = currentIndex; + while (remaining > 1) { + const sectionLength = filteredChats.get(sectionKeys[tempSectionIndex])!.length; + const itemsLeftInSection = sectionLength - tempIndex - 1; + if (remaining <= itemsLeftInSection) { + tempIndex += remaining; + remaining = 0; + } else { + remaining -= itemsLeftInSection + 1; + tempSectionIndex += 1; + if (tempSectionIndex >= sectionKeys.length) { + // Stop at the last item, do not wrap + tempSectionIndex = sectionKeys.length - 1; + tempIndex = filteredChats.get(sectionKeys[tempSectionIndex])!.length - 1; + break; + } + tempIndex = 0; + } + } + nextSectionIndex = tempSectionIndex; + nextIndex = tempIndex; + // For shift+down, do not wrap, so hasWrappedAround is always false } - if (!e.shiftKey && !e.ctrlKey && !e.altKey && e.key == "Enter") { - e.preventDefault(); - e.stopPropagation(); - // Use filteredChats for selection actions - const chat = filteredChats[selected[0]]; + setSelected([[nextSectionIndex, nextIndex], !hasWrappedAround ? -1 : 1]); + } + if (!e.altKey && !e.shiftKey && !e.metaKey && (key === "Home" || (e.ctrlKey && key === "ArrowUp"))) { + e.preventDefault(); + e.stopPropagation(); + // Move to the first item in the first section + const firstSectionKey = Array.from(filteredChats.keys())[0]; + if (firstSectionKey) { + const firstSectionChats = filteredChats.get(firstSectionKey) || []; + setSelected([[0, 0], 1]); + } + } + if (!e.altKey && !e.shiftKey && !e.metaKey && (key === "End" || (e.ctrlKey && key === "ArrowDown"))) { + e.preventDefault(); + e.stopPropagation(); + // Move to the last item in the last section + const sectionKeys = Array.from(filteredChats.keys()); + if (sectionKeys.length > 0) { + const lastSectionKey = sectionKeys[sectionKeys.length - 1]; + const lastSectionChats = filteredChats.get(lastSectionKey) || []; + setSelected([[sectionKeys.length - 1, lastSectionChats.length - 1], -1]); + } + } - // Rename takes priority - if (renameId && chatTitleRename) { - handleRenameSave(chat.id, selected[0]); - return; - } else if (renameId) { - setRenameId(null); - return; - } + if (!e.altKey && !e.shiftKey && !e.metaKey && !e.ctrlKey && key === "Escape") { + e.preventDefault(); + e.stopPropagation(); - if (bulkDeleteMode && selectedChatIds.size > 0) { - handleBulkDelete(); - return; - } + if (bulkDeleteMode) { + // If bulk delete mode is active, exit it + setBulkDeleteMode(false); + setSelectedChatIds(new Set()); + lastSelectedBulkChatRef.current = null; + return; + } - if (!chat) return; - if (pendingDeleteId === chat.id && !deletingId) { - if (pendingDeleteTimeout.current) { - clearTimeout(pendingDeleteTimeout.current); - pendingDeleteTimeout.current = null; - } - handleDelete(chat.id, selected[0]); - } else if (!chat || pendingDeleteId !== chat.id) { - createTab(chat); + if (pendingDeleteId) { + // If a chat is pending delete, cancel the pending delete + if (pendingDeleteTimeout.current) { + clearTimeout(pendingDeleteTimeout.current); + pendingDeleteTimeout.current = null; } + setPendingDeleteId(null); + return; } - // Navigation - if (!e.shiftKey && !e.altKey && e.key == "ArrowDown") { - e.preventDefault(); - e.stopPropagation(); - if (renameId) return; + onDismiss(); + return; + } - let i = selected[0] + (e.ctrlKey ? 5 : 1); - if (i >= filteredChats.length) { - i = filteredChats.length - 1 < 0 ? 0 : filteredChats.length - 1; + if (!e.altKey && !e.shiftKey && !e.metaKey && !e.ctrlKey && key === "Enter") { + if (renamingId) return; + e.preventDefault(); + e.stopPropagation(); + + if (bulkDeleteMode) { + // If bulk delete mode is active, toggle the chat from being selected + const currentSectionIndex = selected[0][0]; + const currentIndex = selected[0][1]; + const sectionKeys = Array.from(filteredChats.keys()); + const currentSectionKey = sectionKeys[currentSectionIndex]; + if (currentSectionKey) { + const chat = filteredChats.get(currentSectionKey)?.[currentIndex]; + if (chat) { + setSelectedChatIds(prev => { + const newSet = new Set(prev); + if (newSet.has(chat.id)) { + newSet.delete(chat.id); + } else { + newSet.add(chat.id); + } + return newSet; + }); + } } - setSelected([i, -1]); } - if (!e.shiftKey && !e.altKey && e.key == "ArrowUp") { - e.preventDefault(); - e.stopPropagation(); - if (renameId) return; - - let i = selected[0] - (e.ctrlKey ? 5 : 1); - if (i < 0) { - i = 0; + + if (pendingDeleteId) { + // If a chat is pending delete, confirm deletion + if (pendingDeleteTimeout.current) { + clearTimeout(pendingDeleteTimeout.current); + pendingDeleteTimeout.current = null; } - setSelected([i, 1]); + handleDelete(pendingDeleteId); + return; } - // Action buttons - if (e.ctrlKey && !e.shiftKey && !e.altKey && e.key == "r") { - e.preventDefault(); - e.stopPropagation(); + if (selected[0][0] < 0 || selected[0][1] < 0) return; + const sectionKeys = Array.from(filteredChats.keys()); + const currentSectionKey = sectionKeys[selected[0][0]]; + if (!currentSectionKey) return; + const chat = filteredChats.get(currentSectionKey)?.[selected[0][1]]; + if (!chat) return; - setPendingDeleteId(null); - if (pendingDeleteTimeout.current) clearTimeout(pendingDeleteTimeout.current); + openTab(chat); + return; + } - const chat = filteredChats[selected[0]]; - setRenameId(chat.id); + + if (!e.altKey && !e.metaKey && !e.ctrlKey && ((!e.shiftKey && key === "Delete") || (e.shiftKey && key === "Backspace"))) { + e.preventDefault(); + e.stopPropagation(); + + if (bulkDeleteMode && bulkSelectedChatIds.size > 0) { + // handleBulkDelete(); + return; } - if (!e.ctrlKey && !e.altKey && ((!e.shiftKey && e.key === "Delete") || (e.shiftKey && e.key === "Backspace"))) { - e.preventDefault(); - e.stopPropagation(); - - // Rename takes priority - if (renameId) { - // Cancel rename - setRenameId(null); - setChatTitleRename(null); - } - if (bulkDeleteMode && selectedChatIds.size > 0) { - handleBulkDelete(); - return; + if (selected[0][0] < 0 || selected[0][1] < 0) return; + const sectionKeys = Array.from(filteredChats.keys()); + const currentSectionKey = sectionKeys[selected[0][0]]; + if (!currentSectionKey) return; + const chat = filteredChats.get(currentSectionKey)?.[selected[0][1]]; + if (!chat) return; + + if (pendingDeleteId === chat.id && !deletingId) { + if (pendingDeleteTimeout.current) { + clearTimeout(pendingDeleteTimeout.current); + pendingDeleteTimeout.current = null; } + handleDelete(chat.id); + } else if (pendingDeleteId !== chat.id) { + setPendingDeleteId(chat.id); + if (pendingDeleteTimeout.current) { + clearTimeout(pendingDeleteTimeout.current); + } + pendingDeleteTimeout.current = setTimeout(() => setPendingDeleteId(id => id === chat.id ? null : id), 3000); + } + return; + } + + if (e.ctrlKey && !e.shiftKey && !e.metaKey && !e.altKey && e.key == "r") { + e.preventDefault(); + e.stopPropagation(); - const chat = filteredChats[selected[0]]; - if (!chat) return; - if (pendingDeleteId === chat.id && !deletingId) { - if (pendingDeleteTimeout.current) { - clearTimeout(pendingDeleteTimeout.current); - pendingDeleteTimeout.current = null; + setPendingDeleteId(null); + if (pendingDeleteTimeout.current) clearTimeout(pendingDeleteTimeout.current); + + if (selected[0][0] < 0 || selected[0][1] < 0) return; + const sectionKeys = Array.from(filteredChats.keys()); + const currentSectionKey = sectionKeys[selected[0][0]]; + if (!currentSectionKey) return; + const chat = filteredChats.get(currentSectionKey)?.[selected[0][1]]; + if (!chat) return; + setRenamingId(chat.id); + } + }, [onDismiss, selected, bulkDeleteMode, bulkSelectedChatIds.size, filteredChats, deletingId, pendingDeleteId, renamingId, mutate, isTouchDevice]); + + useEffect(() => { + if (hidden) { + lastSelectedRef.current = selected; + setRenamingId(null); + setNewChatTitle(null); + setBulkDeleteMode(false); + setPendingDeleteId(null); + setDeletingId(null); + setLongPressActive(null); + setSelectedChatIds(new Set()); + lastSelectedBulkChatRef.current = null; + } + + if (!hidden) { + window.onkeydown = keyboardShortcutHandler; + } + + // Dynamically set the highlight position with the --top CSS variable + const updateHighlightPosition = () => { + if (!highlightRef.current || !listRef.current) return; + let totalSectionsLength = 0; + filteredChats.entries().toArray().forEach((entry, idx) => { + if (idx >= selected[0][0]) return; + totalSectionsLength += entry[1].length; + }); + const flatIndex = (selected[0][0] + totalSectionsLength + 1) + selected[0][1]; + const selectedItem = chatItemRefs.current[flatIndex]; + if (!selectedItem) return; + const rect = selectedItem.getBoundingClientRect(); + const listRect = listRef.current.getBoundingClientRect(); + // Account for scroll position + const scrollTop = listRef.current.scrollTop; + const topPos = rect.top - listRect.top + scrollTop; + highlightRef.current.style.setProperty("--top-pos", `${topPos}px`); + highlightRef.current.style.height = `${rect.height}px`; + if (selectedItem && listRef.current) { + const list = listRef.current; + const itemRect = selectedItem.getBoundingClientRect(); + const listRect = list.getBoundingClientRect(); + const scrollTop = list.scrollTop; + + if (selected[1] === -1) { + // Move down: keep selected item at the bottom edge + const offsetBottom = itemRect.bottom - listRect.bottom; + if (offsetBottom > 0) { + const newScrollTop = scrollTop + offsetBottom; + list.scrollTo({ top: newScrollTop, behavior: "smooth" }); + } else if (itemRect.top < listRect.top) { + // If above, scroll up to top edge + const offsetTop = itemRect.top - listRect.top; + const newScrollTop = scrollTop + offsetTop; + list.scrollTo({ top: newScrollTop, behavior: "smooth" }); } - handleDelete(chat.id, selected[0]); - } else if (pendingDeleteId !== chat.id) { - setPendingDeleteId(chat.id); - if (pendingDeleteTimeout.current) { - clearTimeout(pendingDeleteTimeout.current); + } else if (selected[1] === 1) { + const previousElementIndex = selected[0][0] + totalSectionsLength + selected[0][1]; + let previousElement: HTMLLIElement | null = null; + if (previousElementIndex >= 0) previousElement = chatItemRefs.current[previousElementIndex]; + + // Move up: keep selected item at the top edge + const offsetTop = itemRect.top - listRect.top; + const padding = previousElement && previousElement.classList.contains("section") ? previousElement.clientHeight : 0; // Adjust padding as needed + if (offsetTop < 0) { + const newScrollTop = scrollTop + offsetTop - padding; + list.scrollTo({ top: newScrollTop, behavior: "smooth" }); + } else if (itemRect.bottom > listRect.bottom) { + // If below, scroll down to bottom edge + const offsetBottom = itemRect.bottom - listRect.bottom; + const newScrollTop = scrollTop + offsetBottom - padding; + list.scrollTo({ top: newScrollTop, behavior: "smooth" }); } - pendingDeleteTimeout.current = setTimeout(() => setPendingDeleteId(id => id === chat.id ? null : id), 3000); } } }; + updateHighlightPosition(); - if (hiddenOuter !== hidden) { - setHidden(hiddenOuter); - } - if (hiddenOuter) { + return () => { window.onkeydown = null; - } else { - if (!isTouchDevice && !renameId) inputRef.current?.focus(); - window.onkeydown = onKeyDown; } - return () => { - if (window.onkeydown === onKeyDown) window.onkeydown = null; - // Clean up touch timeout - if (touchTimeoutRef.current) { - clearTimeout(touchTimeoutRef.current); - touchTimeoutRef.current = null; + }, [hidden, selected, data, filteredChats, mutate, keyboardShortcutHandler]); + + + const router = useRouter(); + function openTab(chat: ChatResponse) { + addAndSaveTabsLocally(localStorage, { + id: chat.id, + label: chat.label ?? "New Tab", + link: `/chat/${chat.id}` + }); + router.push(`/chat/${chat.id}`); + setTimeout(() => { + onDismiss(); + }, 25); // Delay to allow navigation to start + } + + function onChatClick(e: React.MouseEvent, chat: ChatResponse) { + if (renamingId === chat.id) return; + + // Check if it's a nested button + const target = e.target as HTMLElement; + if (isNestedButton(target)) return; + + if (bulkDeleteMode && !e.shiftKey) { + e.preventDefault(); + // In bulk mode, regular click toggles selection + setSelectedChatIds(prev => { + const newSet = new Set(prev); + lastSelectedBulkChatRef.current = chat.id; + if (newSet.has(chat.id)) { + newSet.delete(chat.id); + } else { + newSet.add(chat.id); + } + if (newSet.size === 0) { + setBulkDeleteMode(false); + } + return newSet; + }); + return; + } else if (bulkDeleteMode && e.shiftKey) { + // Shift click in bulk mode should add all tabs from the last selected to the current one + e.preventDefault(); + const lastSelectedId = lastSelectedBulkChatRef.current; + if (!lastSelectedId) { + // If no last selected, just toggle current + setSelectedChatIds(prev => { + const newSet = new Set(prev); + newSet.add(chat.id); + lastSelectedBulkChatRef.current = chat.id; + return newSet; + }); + return; } - }; - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [hiddenOuter, localChats.chats, pendingDeleteId, onDismiss, selected, hidden, isTouchDevice, bulkDeleteMode, selectedChatIds, renameId, chatTitleRename]); - // Clean up touch timeouts when bulk mode changes - useEffect(() => { - if (!bulkDeleteMode) { - if (touchTimeoutRef.current) { - clearTimeout(touchTimeoutRef.current); - touchTimeoutRef.current = null; + // Find all chat ids in order + const allChatIds: string[] = []; + filteredChats.forEach(chats => { + chats.forEach(c => allChatIds.push(c.id)); + }); + const startIdx = allChatIds.indexOf(lastSelectedId); + const endIdx = allChatIds.indexOf(chat.id); + if (startIdx === -1 || endIdx === -1) return; + const [from, to] = startIdx < endIdx ? [startIdx, endIdx] : [endIdx, startIdx]; + const idsToSelect = allChatIds.slice(from, to + 1); + + setSelectedChatIds(prev => { + const newSet = new Set(prev); + idsToSelect.forEach(id => { + newSet.add(id); + }); + lastSelectedBulkChatRef.current = chat.id; + return newSet; + }); + return; + } + + if (e.shiftKey) { + e.preventDefault(); + // Toggle bulk selection mode + setBulkDeleteMode(true); + setSelectedChatIds(prev => { + const newSet = new Set(prev); + lastSelectedBulkChatRef.current = chat.id; + if (newSet.has(chat.id)) { + newSet.delete(chat.id); + } else { + newSet.add(chat.id); + } + if (newSet.size === 0) { + setBulkDeleteMode(false); + } + return newSet; + }); + } else { + openTab(chat); + } + }; + + // Extracted function to update pinned status and move chat between sections + function updatePinnedStatus( + chatsMap: Map, + chatId: string, + newPinned: boolean + ): Map { + const updatedChats = new Map(chatsMap); + let chatToUpdate: ChatResponse | undefined; + + // Find and remove chat from its old section + for (const [section, chats] of updatedChats.entries()) { + const idx = chats.findIndex(c => c.id === chatId); + if (idx !== -1) { + chatToUpdate = { ...chats[idx], pinned: newPinned }; + chats.splice(idx, 1); + // Remove section if empty + if (chats.length === 0) updatedChats.delete(section); + break; } - touchStartRef.current = null; - setLongPressActive(null); } - }, [bulkDeleteMode]); - // Reset pendingDeleteId if selection changes - useEffect(() => { - setPendingDeleteId(null); - if (pendingDeleteTimeout.current) { - clearTimeout(pendingDeleteTimeout.current); - pendingDeleteTimeout.current = null; + if (!chatToUpdate) return updatedChats; + + // Determine new section + let newSection: string; + if (newPinned) { + newSection = PINNED_SECTION; + } else { + const date = chatToUpdate.createdAt ?? Date.now(); + newSection = getSectionLabel(date); } - }, [selected]); - // Refetch chats when unhidden - useEffect(() => { - if (!hidden) { - if (isLoading) return; - (async function() { - const chats = await fetch("/api/chat").then(res => res.json() as Promise); - setLocalChats(chats && !("error" in chats) ? chats : { - chats: [], - hasMore: false, - limit: 0, - page: 0, - total: 0 - }); - })(); + // Add chat to new section, create section if it doesn't exist + if (!updatedChats.has(newSection)) { + updatedChats.set(newSection, []); } - }, [hidden, isLoading]); + const sectionChats = updatedChats.get(newSection)!; + sectionChats.push(chatToUpdate); + sectionChats.sort((a, b) => (b.createdAt ?? Date.now()) - (a.createdAt ?? Date.now())); - // Ref to persist last selected index - const lastSelectedRef = useRef<[number, number]>([0, 0]); + // Remove any empty sections (in case) + for (const [section, chats] of updatedChats.entries()) { + if (chats.length === 0) updatedChats.delete(section); + } - // Save selected index before hiding - useEffect(() => { - if (hidden) { - lastSelectedRef.current = selected; + // Resort sections: pinned first, then by date + const sortedSections = new Map( + [...updatedChats.entries()].sort((a, b) => { + if (a[0] === PINNED_SECTION) return -1; + if (b[0] === PINNED_SECTION) return 1; + const aDate = a[1][0].createdAt ?? Date.now(); + const bDate = b[1][0].createdAt ?? Date.now(); + return bDate - aDate; + }) + ); + + return sortedSections; + } + // Handle pinning/unpinning a chat + const handlePinChat = async (chatId: string, newPinnedStatus: boolean) => { + if (!data) return; + + const [currentSectionIndex, currentChatIndex] = selected[0]; + const currentSectionKey = Array.from(filteredChats.keys())[currentSectionIndex]; + const selectedChat = currentSectionKey ? filteredChats.get(currentSectionKey)?.[currentChatIndex] : undefined; + const selectedChatId = selectedChat?.id; + + const newChatsMap = updatePinnedStatus(data.chats, chatId, newPinnedStatus); + + if (selectedChatId) { // This covers both cases: pinned chat is selected, or another chat is selected. + const newSectionKeys = Array.from(newChatsMap.keys()); + let newSelectedSectionIndex = 0; + let newSelectedChatIndex = 0; + + const found = newSectionKeys.some((section, sectionIndex) => { + const chats = newChatsMap.get(section) || []; + const chatIndex = chats.findIndex(c => c.id === selectedChatId); + if (chatIndex !== -1) { + newSelectedSectionIndex = sectionIndex; + newSelectedChatIndex = chatIndex; + return true; + } + return false; + }); + + if (found) { + setSelected([[newSelectedSectionIndex, newSelectedChatIndex], selected[1]]); + } else { + setSelected([[0, 0], 0]); + } } - }, [hidden, selected]); - const onInput: FormEventHandler = (event) => { - const value = event.currentTarget.value; - setShowLabel(!value.length); - setSearchQuery(value); // update search query + mutate({ ...data, chats: newChatsMap }, false); + + await fetch(`/api/chat/${chatId}`, { + method: "POST", + body: JSON.stringify({ pinned: newPinnedStatus }), + }); + + await mutate(); }; // Handle bulk delete const handleBulkDelete = async () => { - if (selectedChatIds.size === 0) return; + if (bulkSelectedChatIds.size === 0) return; - const chatIdsToDelete = Array.from(selectedChatIds); + const chatIdsToDelete = Array.from(bulkSelectedChatIds); setBulkDeleteMode(false); setSelectedChatIds(new Set()); - // Add delete animation class to all selected chats + // Add delete animation to all selected chats + setDeletingId(null); // Reset before animating chatIdsToDelete.forEach(id => setDeletingId(id)); setTimeout(async () => { @@ -345,320 +707,134 @@ export default function ChatPalette({ className, hidden: hiddenOuter, onDismiss if (!result.success) { console.error("Bulk delete failed:", result.error); + setDeletingId(null); return; } - // Update local state - setLocalChats(prev => { - const newChats = prev.chats.filter(c => !chatIdsToDelete.includes(c.id)); - - // Adjust selection if needed - if (chatIdsToDelete.includes(localChats.chats[selected[0]]?.id)) { - const newIdx = Math.min(selected[0], newChats.length - 1); - setSelected([Math.max(0, newIdx), 0]); + mutate(currentData => { + if (!currentData) return currentData; + // Remove deleted chats from all sections + const updatedChats = new Map(currentData.chats); + for (const [section, chats] of updatedChats.entries()) { + const filtered = chats.filter(c => !chatIdsToDelete.includes(c.id)); + if (filtered.length === 0) { + updatedChats.delete(section); + } else { + updatedChats.set(section, filtered); + } } - + // Move selection if needed + let [sectionIdx, chatIdx] = selected[0]; + const sectionKeys = Array.from(updatedChats.keys()); + if ( + sectionIdx >= sectionKeys.length || + (sectionKeys[sectionIdx] && + !(updatedChats.get(sectionKeys[sectionIdx])?.[chatIdx])) + ) { + // Move to first available chat + sectionIdx = 0; + chatIdx = 0; + } + setSelected([[sectionIdx, chatIdx], 0]); // Dismiss palette if no chats left - if (newChats.length === 0) { + const totalChats = Array.from(updatedChats.values()).reduce((acc, arr) => acc + arr.length, 0); + if (totalChats === 0) { setTimeout(() => onDismiss(), 0); } + return { ...currentData, chats: updatedChats }; + }, false); - return { ...prev, chats: newChats }; - }); - + setDeletingId(null); mutate(); // revalidate SWR } catch (error) { console.error("Failed to delete chats:", error); - } finally { setDeletingId(null); } }, DELETE_ANIMATION_DURATION); }; - const handleRenameSave = useCallback(async (chatId: string, idx: number) => { - if (!chatTitleRename) return; - setRenameId(null); - - try { - const chat = localChats.chats[idx]; - setLocalChats(prev => { - const newChats = { ...prev }; - newChats.chats[idx].label = chatTitleRename; - return newChats; - }); - - const result = await fetch(`/api/chat/${chatId}`, { - method: "POST", - body: JSON.stringify({ - label: chatTitleRename, - }), - }).then(res => res.json() as Promise<{ success: string } | ApiError>) - .catch(() => null); - if (!result) { - setLocalChats(prev => { - const newChats = { ...prev }; - newChats.chats[idx].label = chat.label; - return newChats; - }); - return; - } - } finally { // Hacky way to do defer in JS. - setChatTitleRename(null); - mutate(); // revalidate SWR - } - }, [chatTitleRename, localChats, mutate]) - // Handle delete with animation - const handleDelete = async (chatId: string, idx: number) => { + const handleDelete = async (chatId: string) => { setPendingDeleteId(null); - - // Find index of chat to be deleted setDeletingId(chatId); + + // Find section and index of chat to be deleted + let sectionIdx = -1; + let chatIdx = -1; + let sectionKey: string | undefined; + const sectionKeys = Array.from(filteredChats.keys()); + sectionKeys.forEach((key, sIdx) => { + const chats = filteredChats.get(key) || []; + const idx = chats.findIndex(c => c.id === chatId); + if (idx !== -1) { + sectionIdx = sIdx; + chatIdx = idx; + sectionKey = key; + } + }); + setTimeout(async () => { - const result = await fetch(`/api/chat/${chatId}`, { method: "DELETE" }).then(res => res.json() as Promise<{ success: string } | ApiError>).catch(() => null); + const result = await fetch(`/api/chat/${chatId}`, { method: "DELETE" }) + .then(res => res.json() as Promise<{ success: string } | ApiError>) + .catch(() => null); if (!result || "error" in result) { setDeletingId(null); return; } - setLocalChats(prev => { - const newChats = prev.chats.filter(c => c.id !== chatId); - // If the deleted chat was selected and was the last, move selection to new last - if (idx === selected[0]) { - let newIdx = idx; - if (newIdx >= newChats.length) newIdx = newChats.length - 1; - setSelected([Math.max(0, newIdx), 0]); + + mutate(currentData => { + if (!currentData) return currentData; + // Remove chat from the correct section + const updatedChats = new Map(currentData.chats); + if (sectionKey) { + const chats = updatedChats.get(sectionKey) || []; + chats.splice(chatIdx, 1); + if (chats.length === 0) { + updatedChats.delete(sectionKey); + } else { + updatedChats.set(sectionKey, chats); + } + } + // Move selection if needed + if ( + sectionIdx === selected[0][0] && + chatIdx === selected[0][1] + ) { + // If last chat in section, move up, else stay at same index + const chatsInSection = updatedChats.get(sectionKey || "") || []; + let newSectionIdx = sectionIdx; + let newChatIdx = selected[0][1]; + if (newChatIdx >= chatsInSection.length) { + newChatIdx = chatsInSection.length - 1; + if (newChatIdx < 0 && sectionKeys.length > 1) { + // Move to previous section if exists + newSectionIdx = Math.max(0, sectionIdx - 1); + const prevSectionChats = updatedChats.get(sectionKeys[newSectionIdx]) || []; + newChatIdx = prevSectionChats.length - 1; + } + } + setSelected([[Math.max(0, newSectionIdx), Math.max(0, newChatIdx)], 0]); } // Dismiss palette if this was the last chat - if (prev.chats.length === 1) { - setTimeout(() => onDismiss(), 0); // Defer to avoid React setState in render error + const totalChats = Array.from(updatedChats.values()).reduce((acc, arr) => acc + arr.length, 0); + if (totalChats === 0) { + setTimeout(() => onDismiss(), 0); } - return { ...prev, chats: newChats }; - }); + return { ...currentData, chats: updatedChats }; + }, false); + setDeletingId(null); mutate(); // revalidate SWR }, DELETE_ANIMATION_DURATION); }; - // Pagination state - const [page, setPage] = useState(1); - const [hasMore, setHasMore] = useState(true); - const [loadingMore, setLoadingMore] = useState(false); - - // Fetch chats with pagination - useEffect(() => { - if (hidden) return; - setPage(1); - setHasMore(true); - setLocalChats({ chats: [], hasMore: false, limit: 0, page: 0, total: 0 }); - }, [hidden]); - - useEffect(() => { - if (hidden) return; - setLoadingMore(true); - fetch(`/api/chat?page=${page}&limit=25`).then(res => res.json() as Promise) - .then(chats => { - if (!("error" in chats)) { - setLocalChats(prev => { - let mergedChats; - if (page === 1) { - mergedChats = chats.chats; - } else { - const existingIds = new Set(prev.chats.map(c => c.id)); - mergedChats = [...prev.chats]; - for (const chat of chats.chats) { - if (!existingIds.has(chat.id)) { - mergedChats.push(chat); - } - } - } - return { - ...chats, - chats: mergedChats - }; - }); - setHasMore(chats.hasMore); - } - }) - .finally(() => setLoadingMore(false)); - }, [page, hidden]); - - // Infinite scroll: load next page when reaching bottom - useEffect(() => { - const list = listRef.current; - if (!list) return; - function onScroll() { - if (!list || !hasMore || loadingMore) return; - if (list.scrollTop + list.clientHeight >= list.scrollHeight - 10) { - setPage(p => p + 1); - } - } - list.addEventListener("scroll", onScroll); - return () => list.removeEventListener("scroll", onScroll); - }, [hasMore, loadingMore]); - - // Platform shortcut label (fix hydration) - const [shortcutLabel, setShortcutLabel] = useState("CTRL+K"); - useEffect(() => { - if (typeof window !== "undefined" && navigator.userAgent.toLowerCase().includes("mac")) { - setShortcutLabel("⌘ K"); - } else { - setShortcutLabel("CTRL+K"); - } - }, []); - - // Filter chats by search query (case-insensitive, label only) - const filteredChats = searchQuery.trim().length > 0 - ? localChats.chats.filter(chat => - (chat.label ?? "New Chat").toLowerCase().includes(searchQuery.trim().toLowerCase()) - ) - : localChats.chats; - - // Restore selected index when showing and chats are loaded - useEffect(() => { - if (!hidden && filteredChats.length > 0) { - const [lastIdx, lastDir] = lastSelectedRef.current; - const idx = Math.max(0, Math.min(lastIdx, filteredChats.length - 1)); - setSelected([idx, lastDir]); - - if (!selectedRef.current) return; - selectedRef.current.hidden = false; - } else if (!filteredChats.length) { - if (!selectedRef.current) return; - selectedRef.current.hidden = true; - } - - if (hidden) { - setRenameId(null); - setChatTitleRename(null); - } - }, [hidden, filteredChats.length]); - - // Clamp selected index if filteredChats gets shorter - useEffect(() => { - if (selected[0] >= filteredChats.length) { - setSelected([filteredChats.length > 0 ? filteredChats.length - 1 : 0, 0]); - } - // Optionally, reset to 0 if list is empty - }, [filteredChats.length]); - - // Helper to group chats by date section - function getSectionLabel(date: Date) { - if (isToday(date)) return "Today"; - if (isYesterday(date)) return "Yesterday"; - if (isThisWeek(date, { weekStartsOn: 1 })) return format(date, "EEEE"); // Weekday name - return formatRelative(date, "MM/dd/yyyy"); // Fallback to date - } - - // Group chats into sections (memoized) - const chatsWithSections = useMemo(() => { - const sections: Array<{ section: string; chats: typeof filteredChats }> = []; - filteredChats?.forEach(chat => { - const createdAt = chat.createdAt ? new Date(chat.createdAt) : new Date(); - const section = getSectionLabel(createdAt); - if (!sections.length || sections[sections.length - 1].section !== section) { - sections.push({ section, chats: [chat] }); - } else { - sections[sections.length - 1].chats.push(chat); - } - }); - return sections; - }, [filteredChats]); - - // For adaptive selected div - const chatItemRefs = useRef<(HTMLLIElement | null)[]>([]); - useEffect(() => { - if (!listRef.current || !selectedRef.current) return; - // Find the flat index of the selected chat in the rendered list - let flatIdx = 0; - let found = false; - for (let i = 0; i < chatsWithSections.length; ++i) { - for (let j = 0; j < chatsWithSections[i].chats.length; ++j) { - if (flatIdx === selected[0]) { - found = true; - break; - } - flatIdx++; - } - if (found) break; - } - const el = chatItemRefs.current[selected[0]]; - if (el && selectedRef.current) { - const rect = el.getBoundingClientRect(); - const top = el.offsetTop; - selectedRef.current.style.setProperty("--top-pos", `${top}px`); - selectedRef.current.style.height = `${rect.height}px`; - } - }, [selected, chatsWithSections, filteredChats?.length, hidden]); - - // Unfocus search input when palette is hidden - useEffect(() => { - if (hidden && inputRef.current) { - inputRef.current.blur(); - } - }, [hidden]); - return ( <> -
{ - onDismiss(); - }} - > - . -
-
+ className={`z-25 bg-black/15 absolute left-0 right-0 top-0 bottom-0 select-none ${hidden ? "pointer-events-none opacity-0" : "opacity-100"} backdrop-blur-xs transition-opacity duration-300`} + onClick={() => onDismiss()} + /> +
inputRef.current?.focus()} + onClick={() => !isTouchDevice ? searchRef.current?.focus() : {}} > -
+
-
{!isTouchDevice && ( - {shortcutLabel} + {chatPaletteShortcut} )}
- {bulkDeleteMode && ( -
-
- {selectedChatIds.size} chat{selectedChatIds.size !== 1 ? "s" : ""} selected -
-
- - -
+
{ + if (el) { + el.style.setProperty("--bulk-bar-height", `${el.clientHeight + 20}px`); + } + }} + > +
+ {bulkSelectedChatIds.size} chat{bulkSelectedChatIds.size !== 1 ? "s" : ""} selected
- )} +
+ + +
+
-
    - {!bulkDeleteMode && !isTouchDevice && ( +
      + {!isTouchDevice && (
diff --git a/src/app/components/ChatPalette/ChatItem.tsx b/src/app/components/ChatPalette/ChatItem.tsx new file mode 100644 index 0000000..82f566b --- /dev/null +++ b/src/app/components/ChatPalette/ChatItem.tsx @@ -0,0 +1,224 @@ +import { ApiError, ChatResponse } from "@/internal-lib/types/api"; +import { format } from "date-fns"; +import { useCallback, useEffect, useState } from "react"; +import EditSvg from "../EditSvg"; +import CheckmarkSvg from "../CheckmarkSvg"; +import TrashSvg from "../TrashSvg"; +import CancelSvg from "../CancelSvg"; +import StarSvg from "../StarSvg"; +import ChatSvg from "../ChatSvg"; +import { getSectionLabel, PINNED_SECTION } from "../ChatPalette"; + +interface ChatItemProps { + chat: ChatResponse; + idx: number; + section?: string; + isSelected: boolean; + isBulkSelected?: boolean; + bulkDeleteMode?: boolean; + onRenameTrigger?: (id: string, idx: number) => void; + onRename?: (newLabel: string, id: string, idx: number) => void; + onRenameCancel?: () => void; + onRenameInput?: (newLabel: string) => void; + onDeleteTrigger?: (id: string, idx: number) => void; + onDelete?: (id: string, idx: number) => void; + onPinUpdate?: (chatId: string, newPinned: boolean) => void; + renameId?: string | null; + pendingDeleteId?: string | null; + deletingId?: string | null; +} + +export default function ChatItem({ + chat, + idx, + section, + isSelected = false, + isBulkSelected = false, + bulkDeleteMode = false, + onRenameTrigger, + onRename, + onRenameCancel, + onDeleteTrigger, + onDelete, + onPinUpdate, + onRenameInput, + renameId, + pendingDeleteId, + deletingId +}: ChatItemProps) { + const [label, setLabel] = useState(chat.label); + const timeFormat = useCallback(() => { + return format(chat.createdAt ?? Date.now(), "HH:mm"); + }, [chat.createdAt]) + const [time, setTime] = useState(timeFormat()); + + useEffect(() => { + setTime(timeFormat()); + // Check if the state actually changed to prevent infinite loops + if (chat.label !== label) setLabel(chat.label); + }, [chat, timeFormat]); + + return ( + <> + {!bulkDeleteMode ? ( + + ) : ( +
+ + + + + + +
+ )} + + {renameId !== chat.id ? ( + {label ?? "New Chat"} + ) : ( +
{ + e.preventDefault(); + if (label && label !== chat.label) { + onRename?.(label, chat.id, idx); + return; + } + e.currentTarget.reset(); + }} className="flex-1 flex items-center"> + { setLabel(e.currentTarget.value); onRenameInput?.(e.currentTarget.value); }} + onFocus={e => e.currentTarget.select()} + maxLength={100} + autoFocus + value={label ?? "New Chat"} + className="flex-1 outline-none transition-all duration-250 py-1 focus:py-2 focus:px-2 border-2 border-white/50 focus:border-1 focus:border-white/10 rounded-lg focus:bg-black/10" + /> +
+ )} + {section === PINNED_SECTION ? `${getSectionLabel(chat.createdAt ?? Date.now())} ${time}` : time} + + { /* Actions */} + {!bulkDeleteMode && ( +
+ {/* rename */} + + + {/* delete */} + +
+ )} + + ); +} diff --git a/src/app/components/ChatPalette/style.css b/src/app/components/ChatPalette/style.css new file mode 100644 index 0000000..baf632d --- /dev/null +++ b/src/app/components/ChatPalette/style.css @@ -0,0 +1,56 @@ +.chat-delete-anim { + opacity: 0 !important; + transform: translateX(50px) scale(0.95); + + transition: opacity 300ms, transform 300ms; +} + +.chat-long-press { + animation: pulse-selection 0.5s ease-out; +} + +@keyframes pulse-selection { + 0% { + transform: scale(1); + background-color: rgba(59, 130, 246, 0.1); + } + + 50% { + transform: scale(1.02); + background-color: rgba(59, 130, 246, 0.3); + } + + 100% { + transform: scale(1); + background-color: rgba(59, 130, 246, 0.2); + } +} + +/* Custom scrollbar styles for chat list */ +ul::-webkit-scrollbar { + width: 8px; + background: transparent; + position: absolute; +} + +ul::-webkit-scrollbar-thumb { + background: rgba(255, 255, 255, 0.1); + border-radius: 9999px; +} + +ul::-webkit-scrollbar-track { + background: transparent; +} + +ul::-webkit-scrollbar-button { + background: transparent; + display: none; + height: 0; + width: 0; +} + +ul { + scrollbar-width: thin; + scrollbar-color: rgba(255, 255, 255, 0.1) transparent; + /* Overlay scrollbar so it doesn"t move content */ +} diff --git a/src/app/components/ChatSvg.tsx b/src/app/components/ChatSvg.tsx new file mode 100644 index 0000000..0df4b5e --- /dev/null +++ b/src/app/components/ChatSvg.tsx @@ -0,0 +1,18 @@ +import { SVGAttributes } from "react"; + +export default function ChatSvg(props: SVGAttributes) { + return ( + + + + + + + + + + + + + ) +} diff --git a/src/app/components/CheckmarkSvg.tsx b/src/app/components/CheckmarkSvg.tsx new file mode 100644 index 0000000..65f4f35 --- /dev/null +++ b/src/app/components/CheckmarkSvg.tsx @@ -0,0 +1,9 @@ +import { SVGAttributes } from "react"; + +export default function CheckmarkSvg(props: SVGAttributes) { + return ( + + + + ) +} diff --git a/src/app/components/Dropdown.tsx b/src/app/components/DropdownGrid.tsx similarity index 98% rename from src/app/components/Dropdown.tsx rename to src/app/components/DropdownGrid.tsx index 0a00cda..ea36a0f 100644 --- a/src/app/components/Dropdown.tsx +++ b/src/app/components/DropdownGrid.tsx @@ -221,7 +221,7 @@ export default function Dropdown({ className, label, items: options, name, optio className={`opacity-40 ml-1.5 ${!shown ? "scale-100" : "-scale-100"} transition-all`} /> -
+
{(shown || isClosing) && dropdownPosition && typeof window !== "undefined" && document.body && ReactDOM.createPortal( <> -
+ {(() => { + const messages = pages.flatMap(p => p?.messages); + const lastMessage = messages?.[messages?.length - 1]; + console.log("Last message:", lastMessage?.role, generating, `${streamingMessageContent.trim()}`); + return ( + (generating || regeneratingIdx) && !streamingMessageContent.trim() && lastMessage?.role === "user" && ( +
+ + + + + +
+ Why is this showing? Latency, reasoning and uploading files +
+ +
+ ) + ) + })()} + {!isLoading && ( + <> + {/* Streaming model message */} + {streamingMessageContent && ( + + )} + {/* Optimistic user message */} + {optimisticSentUserMessage && ( + + )} + {/* Render all messages from SWR */} + {pages.flatMap((messages, pageIdx) => + messages.messages.map((message, msgIdx) => ( + + )) + )} + )}
+ {showScrollToBottom && ( + + )} {model && provider && ( )} @@ -575,7 +658,7 @@ const PreWithCopy = ({ node, className, children, ...props }: any) => { ); }; -const MessageBubble = ({ message, index, onDelete, onRegenerate, regeneratingIdx }: { message: Message, index: number, onDelete?: (idx: number) => void, onRegenerate?: (idx: number) => void, regeneratingIdx?: number | null }) => { +const MessageBubble = ({ message, index, pageIdx, msgIdx, onDelete, onRegenerate, regeneratingIdx }: { message: Message, index: number, pageIdx: number, msgIdx: number, onDelete?: (pageIdx: number, msgIdx: number) => void, onRegenerate?: (pageIdx: number, msgIdx: number) => void, regeneratingIdx?: { pageIdx: number; messageIdx: number } | null }) => { const isUser = message?.role === "user"; const className = isUser ? "px-6 py-4 rounded-2xl mb-1 bg-white/[0.06] justify-self-end break-words max-w-full overflow-x-auto" @@ -672,11 +755,11 @@ const MessageBubble = ({ message, index, onDelete, onRegenerate, regeneratingIdx return (
setHovered(true)} onMouseLeave={() => setHovered(false)} > -
+
{renderedMarkdown} {/* Annotations rendering */} {message.parts && message.parts[0]?.annotations && message.parts[0].annotations.length > 0 && ( @@ -707,7 +790,7 @@ const MessageBubble = ({ message, index, onDelete, onRegenerate, regeneratingIdx )} {/* Delete button for user messages only */} @@ -749,10 +830,10 @@ const MessageBubble = ({ message, index, onDelete, onRegenerate, regeneratingIdx setPendingDelete(true); } else { setPendingDelete(false); - onDelete(index); + onDelete(pageIdx, msgIdx); } }} - className={`relative transition-all duration-300 hover:text-neutral-50/75 text-neutral-50/50 rounded-full flex items-center justify-center z-10 ${pendingDelete ? "!text-red-500" : ""}`} + className={`relative transition-all duration-300 hover:text-neutral-50/75 text-neutral-50/50 rounded-full flex items-center justify-center ${pendingDelete ? "!text-red-500" : ""}`} style={{ opacity: hovered || pendingDelete ? 1 : 0, pointerEvents: hovered || pendingDelete ? "auto" : "none", width: 36, height: 36 }} > diff --git a/src/app/components/Navbar.tsx b/src/app/components/Navbar.tsx index ecf8e78..93a501a 100644 --- a/src/app/components/Navbar.tsx +++ b/src/app/components/Navbar.tsx @@ -152,7 +152,7 @@ export function Navbar() { // Add settings button for BYOK return ( <> -