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
>
.
-
+
{!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() : {}}
>
-
- {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 && (
)}
- {chatsWithSections.length === 0 && !isLoading && !loadingMore && (
+ {filteredChats.size === 0 && !isLoading && !isLoadingMore && (
-
No chats found.
)}
- {isLoading && !loadingMore && (
+ {isLoading && !isLoadingMore && (
-
Loading... please wait
)}
- {chatsWithSections.map((section, _) => (
-
- -
- {section.section}
-
- {section.chats.map((chat, idx) => {
- // Flat index in filteredChats
- const flatIdx = filteredChats.findIndex(c => c.id === chat.id);
- const isSelected = selectedChatIds.has(chat.id);
- const isDeleting = deletingId === chat.id;
- const isLongPressing = longPressActive === chat.id;
- const createdAt = chat.createdAt ? new Date(chat.createdAt) : new Date();
- const timeLabel = format(createdAt, "HH:mm");
- return (
- - { chatItemRefs.current[flatIdx] = el; }}
- className={`
- p-4 h-[64px] flex gap-4 items-center text-neutral-50/80 w-full cursor-pointer
- ${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.classList.contains("child-button") ||
- target.parentElement?.classList?.contains("child-button") ||
- target.parentElement?.parentElement?.classList?.contains("child-button")
- ) return;
-
- touchStartRef.current = {
- chatId: chat.id,
- startTime: Date.now()
- };
-
- // Start long press timer (500ms)
- touchTimeoutRef.current = setTimeout(() => {
- 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 => {
- const newSet = new Set(prev);
- newSet.add(chat.id);
- return newSet;
- });
-
- // Remove animation after it completes
- setTimeout(() => setLongPressActive(null), 500);
-
- touchStartRef.current = null;
+ {filteredChats.entries().toArray().map((value, sectionIdx) => {
+ let totalSectionsLength = 0;
+ filteredChats.entries().toArray().forEach((entry, idx) => {
+ if (idx >= sectionIdx) return;
+ totalSectionsLength += entry[1].length;
+ });
+
+ return (
+
+
- { chatItemRefs.current[sectionIdx + totalSectionsLength] = el }}
+ className="section px-4 pb-2 pt-2.5 text-xs text-neutral-400 font-semibold select-none"
+ >
+ {value[0]}
+
+
+ {value[1].map((chat, chatIdx) => {
+ const flatIdx = sectionIdx * totalSectionsLength + chatIdx;
+ const isSelected = sectionIdx === selected[0][0] && chatIdx === selected[0][1];
+ const isBulkSelected = bulkSelectedChatIds.has(chat.id);
+ const isDeleting = deletingId === chat.id;
+ const isLongPressing = longPressActive === chat.id;
+
+ return (
+ - { chatItemRefs.current[(sectionIdx + totalSectionsLength + 1) + chatIdx] = el }}
+ className={`
+ p-4 h-[64px] flex gap-4 items-center text-neutral-50/80 w-full cursor-pointer
+ ${isBulkSelected ?
+ "bg-blue-500/15" :
+ !isSelected ? "hover:bg-white/[0.03]" :
+ ""
}
- }, 500);
- }}
- 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;
- if (touchDuration < 500) {
- setSelectedChatIds(prev => {
- const newSet = new Set(prev);
- if (newSet.has(chat.id)) {
- newSet.delete(chat.id);
- } else {
- newSet.add(chat.id);
+ transition-all duration-200 overflow-clip
+ ${isDeleting ? "chat-delete-anim" : ""}
+ ${isLongPressing ? "chat-long-press" : ""}
+ group
+
+ rounded-2xl
+ ${(() => {
+ // Disable top roundness if previous element is selected
+ const prevIsSelected = (() => {
+ if (!isBulkSelected) return false;
+ if (chatIdx === 0) return false;
+ const lastChatId = value[1][chatIdx - 1].id;
+ return bulkSelectedChatIds.has(lastChatId);
+ })();
+ return prevIsSelected ? "!rounded-b-2xl !rounded-t-none" : "";
+ })()}
+ ${(() => {
+ // Disable bottom roundness if next element is selected
+ const sectionChats = value[1];
+ const nextIsSelected = (() => {
+ if (!isBulkSelected) return false;
+ if (chatIdx === sectionChats.length - 1) return false;
+ const nextChatId = value[1][chatIdx + 1].id;
+ return bulkSelectedChatIds.has(nextChatId);
+ })();
+ return nextIsSelected ? "!rounded-t-2xl !rounded-b-none" : "";
+ })()}
+ `}
+ // onTouchStart={e => {
+ // if (!isTouchDevice) return;
+ // if (renamingId === chat.id) return;
+
+ // // Check if it's a nested button
+ // const target = e.target as HTMLElement;
+ // if (isNestedButton(target)) return;
+
+ // touchStartRef.current = {
+ // chatId: chat.id,
+ // startTime: Date.now()
+ // };
+
+ // // Start long press timer (500ms)
+ // touchTimeoutRef.current = setTimeout(() => {
+ // 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 => {
+ // const newSet = new Set(prev);
+ // newSet.add(chat.id);
+ // return newSet;
+ // });
+
+ // // Remove animation after it completes
+ // setTimeout(() => setLongPressActive(null), 500);
+
+ // touchStartRef.current = null;
+ // }
+ // }, 500);
+ // }}
+ // onTouchEnd={_ => {
+ // if (!isTouchDevice) return;
+ // if (renamingId === 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;
+ // if (touchDuration < 500) {
+ // setSelectedChatIds(prev => {
+ // const newSet = new Set(prev);
+ // if (newSet.has(chat.id)) {
+ // newSet.delete(chat.id);
+ // } else {
+ // newSet.add(chat.id);
+ // }
+ // return newSet;
+ // });
+ // }
+ // }
+ // // If touch was released quickly and we're not in bulk mode, it's a tap
+ // else if (touchStartRef.current && !bulkDeleteMode) {
+ // const touchDuration = Date.now() - touchStartRef.current.startTime;
+ // if (touchDuration < 500) {
+ // createTab(chat);
+ // }
+ // }
+
+ // touchStartRef.current = null;
+ // }}
+ // onTouchMove={_ => {
+ // // Cancel long press if user moves finger
+ // if (touchTimeoutRef.current) {
+ // clearTimeout(touchTimeoutRef.current);
+ // touchTimeoutRef.current = null;
+ // }
+ // setLongPressActive(null);
+ // touchStartRef.current = null;
+ // }}
+ onClick={e => onChatClick(e, chat)}
+ >
+ {
+ setRenamingId(id);
+ setNewChatTitle(null);
+ }}
+ onRename={(newLabel, id, idx) => {
+ setRenamingId(null);
+ setNewChatTitle(null);
+ mutate(async data => {
+ if (!data) return data;
+ const result = await fetch(`/api/chat/${id}`, {
+ method: "POST",
+ body: JSON.stringify({ label: newLabel }),
+ }).then(res => res.json()).catch(err => {
+ console.error(err);
+ return null; // If error, return current data
+ });
+ if (!result || "error" in result) {
+ console.error("Failed to rename chat:", result?.error || "Unknown error");
+ return data; // If error, return current data
}
- return newSet;
+
+ const updatedChats = new Map(data.chats);
+ const sectionChats = updatedChats.get(value[0]) || [];
+ const chatToUpdate = sectionChats.find(c => c.id === id);
+ if (chatToUpdate) {
+ chatToUpdate.label = newLabel;
+ sectionChats[idx].label = newLabel; // Update the local copy for immediate UI update
+ updatedChats.set(value[0], sectionChats);
+ return { ...data, chats: updatedChats };
+ }
+ return data;
+ }, {
+ optimisticData(currentData, displayedData) {
+ if (!currentData) return displayedData ?? {
+ chats: new Map(),
+ total: 0,
+ page: 1,
+ limit: 0,
+ hasMore: false,
+ };
+ const updatedChats = new Map(currentData.chats);
+ const sectionChats = updatedChats.get(value[0]) || [];
+ const chatToUpdate = sectionChats.find(c => c.id === id);
+ if (chatToUpdate) {
+ chatToUpdate.label = newLabel;
+ sectionChats[idx].label = newLabel; // Update the local copy for immediate UI update
+ updatedChats.set(value[0], sectionChats);
+ return { ...currentData, chats: updatedChats };
+ }
+ return currentData;
+ },
+ revalidate: true,
});
- }
- }
- // If touch was released quickly and we're not in bulk mode, it's a tap
- else if (touchStartRef.current && !bulkDeleteMode) {
- const touchDuration = Date.now() - touchStartRef.current.startTime;
- if (touchDuration < 500) {
- createTab(chat);
- }
- }
-
- touchStartRef.current = null;
- }}
- onTouchMove={_ => {
- // Cancel long press if user moves finger
- if (touchTimeoutRef.current) {
- clearTimeout(touchTimeoutRef.current);
- touchTimeoutRef.current = null;
- }
- setLongPressActive(null);
- touchStartRef.current = null;
- }}
- 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.classList.contains("child-button") ||
- clickTarget.parentElement?.classList?.contains("child-button") ||
- clickTarget.parentElement?.parentElement?.classList?.contains("child-button")
- ) return;
-
- if (e.shiftKey) {
- e.preventDefault();
- // Toggle bulk selection mode
- setBulkDeleteMode(true);
- setSelectedChatIds(prev => {
- const newSet = new Set(prev);
- if (newSet.has(chat.id)) {
- newSet.delete(chat.id);
- } else {
- newSet.add(chat.id);
- }
- return newSet;
- });
- } else if (bulkDeleteMode) {
- // In bulk mode, regular click toggles selection
- setSelectedChatIds(prev => {
- const newSet = new Set(prev);
- if (newSet.has(chat.id)) {
- newSet.delete(chat.id);
- } else {
- newSet.add(chat.id);
- }
- return newSet;
- });
- } else {
- createTab(chat);
- }
- }}
- >
- {bulkDeleteMode && (
-
- { }}
- className="w-4 h-4 text-blue-600 bg-gray-100 border-gray-300 rounded focus:ring-blue-500"
- />
-
- )}
-
-
-
- {renameId !== chat.id ? (
- {chat.label ?? "New Chat"}
- ) : (
- {
- e.currentTarget.select();
}}
- onSubmit={_ => {
- if (chatTitleRename) {
- handleRenameSave(chat.id, idx);
- return;
+ onRenameCancel={() => {
+ setRenamingId(null);
+ setNewChatTitle(null);
+ }}
+ onDeleteTrigger={id => {
+ setPendingDeleteId(id);
+ if (pendingDeleteTimeout.current) {
+ clearTimeout(pendingDeleteTimeout.current);
}
- setRenameId(null);
+ pendingDeleteTimeout.current = setTimeout(() => setPendingDeleteId(id => id === chat.id ? null : id), 3000);
}}
- 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"}
+ onDelete={id => handleDelete(id)}
/>
- )}
- {timeLabel}
- {!bulkDeleteMode && (
-
- {/* 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);
- }
- }}
- >
-
- {/* Edit SVG */}
-
-
-
- {/* Checkmark SVG */}
-
-
-
- {/* delete */}
-
{
- e.stopPropagation();
- if (renameId === chat.id) {
- setChatTitleRename(null);
- setRenameId(null);
- return;
- }
-
- 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 */}
-
-
-
-
- )}
-
- );
- })}
-
- ))}
- {loadingMore && (
+
+ );
+ })}
+
+ )
+ })}
+ {/* {isLoadingMore && (
-
Loading more...
- )}
+ )} */}
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"}
+ ) : (
+
+ )}
+
{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