diff --git a/docker-compose.yml b/docker-compose.yml index e72959e..fece23f 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -4,26 +4,26 @@ services: container_name: redis-open3 restart: unless-stopped # Uncomment this for development to expose Redis port - # ports: - # - "6379:6379" + ports: + - "6379:6379" volumes: - redis-data:/data command: ["redis-server", "--save", "60", "1", "--loglevel", "warning"] # Comment this out if you want to run a development environment locally - web: - build: . - container_name: open3-web - restart: unless-stopped - ports: - - "3000:3000" - environment: - - NODE_ENV=production - - REDIS_URL=redis-open3:6379 - depends_on: - - redis - volumes: - - upload-data:/app/public/uploads + # web: + # build: . + # container_name: open3-web + # restart: unless-stopped + # ports: + # - "3000:3000" + # environment: + # - NODE_ENV=production + # - REDIS_URL=redis-open3:6379 + # depends_on: + # - redis + # volumes: + # - upload-data:/app/public/uploads volumes: redis-data: diff --git a/package-lock.json b/package-lock.json index a3d67d5..77e57f7 100644 --- a/package-lock.json +++ b/package-lock.json @@ -24,6 +24,7 @@ "parse-numeric-range": "^1.3.0", "react": "^19.0.0", "react-dom": "^19.0.0", + "react-intersection-observer": "^9.16.0", "react-markdown": "^10.1.0", "rehype-highlight": "^7.0.2", "rehype-raw": "^7.0.0", @@ -5968,6 +5969,21 @@ "react": "^19.1.0" } }, + "node_modules/react-intersection-observer": { + "version": "9.16.0", + "resolved": "https://registry.npmjs.org/react-intersection-observer/-/react-intersection-observer-9.16.0.tgz", + "integrity": "sha512-w9nJSEp+DrW9KmQmeWHQyfaP6b03v+TdXynaoA964Wxt7mdR3An11z4NNCQgL4gKSK7y1ver2Fq+JKH6CWEzUA==", + "license": "MIT", + "peerDependencies": { + "react": "^17.0.0 || ^18.0.0 || ^19.0.0", + "react-dom": "^17.0.0 || ^18.0.0 || ^19.0.0" + }, + "peerDependenciesMeta": { + "react-dom": { + "optional": true + } + } + }, "node_modules/react-is": { "version": "16.13.1", "dev": true, diff --git a/package.json b/package.json index bd03c8b..4cf39d1 100644 --- a/package.json +++ b/package.json @@ -37,6 +37,7 @@ "parse-numeric-range": "^1.3.0", "react": "^19.0.0", "react-dom": "^19.0.0", + "react-intersection-observer": "^9.16.0", "react-markdown": "^10.1.0", "rehype-highlight": "^7.0.2", "rehype-raw": "^7.0.0", @@ -57,4 +58,4 @@ "tailwindcss": "^4", "typescript": "^5" } -} \ No newline at end of file +} diff --git a/src/app/api/chat/[id]/messages/route.ts b/src/app/api/chat/[id]/messages/route.ts index 73f16eb..9d03605 100644 --- a/src/app/api/chat/[id]/messages/route.ts +++ b/src/app/api/chat/[id]/messages/route.ts @@ -1,10 +1,18 @@ import { NextRequest, NextResponse } from "next/server"; import { auth, currentUser } from "@clerk/nextjs/server"; -import redis, { CHAT_GENERATING_KEY, CHAT_MESSAGES_KEY, USER_CHATS_KEY } from "@/internal-lib/redis"; +import redis, { CHAT_MESSAGES_KEY, USER_CHATS_KEY } from "@/internal-lib/redis"; import { Message } from "@/app/lib/types/ai"; import { ApiError } from "@/internal-lib/types/api"; -export async function GET(_: NextRequest, { params }: { params: Promise<{ id: string }> }) { +export interface ChatMessagesResponse { + messages: Message[]; + total: number; + page: number; + limit: number; + hasMore: boolean; +} + +export async function GET(req: NextRequest, { params }: { params: Promise<{ id: string }> }) { if (!redis) { return NextResponse.json({ error: "Redis connection failure" } as ApiError, { status: 500 }); } @@ -13,17 +21,50 @@ export async function GET(_: NextRequest, { params }: { params: Promise<{ id: st if (!user) return NextResponse.json({ error: "Unauthorized" } as ApiError, { status: 401 }); if (!user.userId) return NextResponse.json({ error: "Unauthorized" } as ApiError, { status: 401 }); + // Pagination parameters + const page = parseInt(req.nextUrl.searchParams.get("page") || "1"); + const limit = parseInt(req.nextUrl.searchParams.get("limit") || "25"); + const reverse = req.nextUrl.searchParams.get("reverse") === "true"; + if (page < 1) { + return NextResponse.json({ error: "Page must be greater than 0" } as ApiError, { status: 400 }); + } + if (limit < 1 || limit > 100) { + return NextResponse.json({ error: "Limit must be between 1 and 100" } as ApiError, { status: 400 }); + } + const startIndex = (page - 1) * limit; + const endIndex = startIndex + limit - 1; + const { id } = await params; const chatExists = await redis.hexists(USER_CHATS_KEY(user.userId), id); if (!chatExists) { return NextResponse.json({ error: "Chat not found" } as ApiError, { status: 404 }); } - const isGenerating = await redis.get(CHAT_GENERATING_KEY(id)); - try { + const total = await redis.llen(CHAT_MESSAGES_KEY(id)); let messageStrings: string[] = []; - messageStrings = await redis.lrange(CHAT_MESSAGES_KEY(id), 0, -1); + + if (reverse) { + // Reverse pagination: newest messages first + // Calculate indices from the end + const reverseStart = total - (page * limit); + const reverseEnd = total - ((page - 1) * limit) - 1; + // Clamp indices to valid range + const start = Math.max(reverseStart, 0); + const end = Math.max(reverseEnd, 0); + + // Redis lrange is inclusive, so ensure start <= end + if (start <= end) { + messageStrings = await redis.lrange(CHAT_MESSAGES_KEY(id), start, end); + } else { + messageStrings = []; + } + // Since lrange returns oldest-to-newest, reverse to get newest-to-oldest + messageStrings = messageStrings.reverse(); + } else { + // Normal pagination: oldest messages first + messageStrings = await redis.lrange(CHAT_MESSAGES_KEY(id), startIndex, endIndex); + } const messages: Message[] = messageStrings.map(msgStr => { try { @@ -33,10 +74,14 @@ export async function GET(_: NextRequest, { params }: { params: Promise<{ id: st } }).filter(Boolean); + console.log(`Retrieved ${messages.length} messages for chat ${id} on page ${page} with limit ${limit}. Total messages: ${total} hasMore: ${total > endIndex + 1}`); return NextResponse.json({ messages, - generating: !!isGenerating, - }, { status: 200 }); + total, + page, + limit, + hasMore: total > endIndex + 1, + } as ChatMessagesResponse, { status: 200 }); } catch (error) { console.error("Failed to retrieve messages:", error); return NextResponse.json({ error: "Failed to retrieve messages" } as ApiError, { status: 500 }); diff --git a/src/app/api/chat/[id]/route.ts b/src/app/api/chat/[id]/route.ts index f125b21..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[]; @@ -45,6 +46,58 @@ 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; + pinned?: boolean; + }; + + // Check if no fields are provided in the body + 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 }); + + 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 !== 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(); + } + // 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); + 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 +107,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 +129,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/api/chat/route.ts b/src/app/api/chat/route.ts index 6c571c8..800ed89 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"; @@ -19,7 +19,7 @@ export async function GET(req: NextRequest) { // Pagination parameters const page = parseInt(req.nextUrl.searchParams.get("page") || "1"); - const limit = parseInt(req.nextUrl.searchParams.get("limit") || "50"); + const limit = parseInt(req.nextUrl.searchParams.get("limit") || "25") +1; if (page < 1) { return NextResponse.json({ error: "Page must be greater than 0" } as ApiError, { status: 400 }); } @@ -29,36 +29,91 @@ 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); - if (chatIds.length === 0) { - return NextResponse.json({ - chats: [], - total: 0, - page, - limit, - hasMore: false - }, { status: 200 }); + // Fetch all pinned chat IDs (usually few) + const pinnedChatIds = await redis.zrevrange(USER_PINNED_CHATS_KEY(user.userId), 0, -1).catch((err) => { + console.error("Error fetching pinned chat IDs:", err); + return [] as string[]; + }); + const totalPinned = pinnedChatIds.length; + + // Calculate how many pinned chats are on this page + let paginatedPinned: string[] = []; + let paginatedUnpinned: string[] = []; + if (startIndex < totalPinned) { + // This page includes some pinned chats + const pinnedStart = startIndex; + const pinnedEnd = Math.min(totalPinned - 1, endIndex); + paginatedPinned = pinnedChatIds.slice(pinnedStart, pinnedEnd + 1); + // If not enough pinned to fill the page, fill with unpinned + const unpinnedNeeded = limit - paginatedPinned.length; + if (unpinnedNeeded > 0) { + // Unpinned offset is always 0 for first page, or (startIndex - totalPinned) for later pages + const unpinnedOffset = Math.max(0, startIndex - totalPinned); + // Fetch a large enough window, filter out pinned, then slice for offset and count + const fetchWindow = (unpinnedOffset + unpinnedNeeded) * 3; + let allUnpinned: string[] = []; + let redisOffset = 0; + while (allUnpinned.length < unpinnedOffset + unpinnedNeeded) { + const batch = await redis.zrevrange(USER_CHATS_INDEX_KEY(user.userId), redisOffset, redisOffset + fetchWindow - 1).catch((err) => { + console.error("Error fetching chat IDs:", err); + return [] as string[]; + }); + if (batch.length === 0) break; + const filtered = batch.filter(id => !pinnedChatIds.includes(id)); + allUnpinned = [...allUnpinned, ...filtered]; + redisOffset += fetchWindow; + if (batch.length < fetchWindow) break; + } + paginatedUnpinned = allUnpinned.slice(unpinnedOffset, unpinnedOffset + unpinnedNeeded); + } + } else { + // This page is after all pinned chats, only unpinned + const unpinnedOffset = startIndex - totalPinned; + const fetchWindow = (unpinnedOffset + limit) * 3; + let allUnpinned: string[] = []; + let redisOffset = 0; + while (allUnpinned.length < unpinnedOffset + limit) { + const batch = await redis.zrevrange(USER_CHATS_INDEX_KEY(user.userId), redisOffset, redisOffset + fetchWindow - 1).catch((err) => { + console.error("Error fetching chat IDs:", err); + return [] as string[]; + }); + if (batch.length === 0) break; + const filtered = batch.filter(id => !pinnedChatIds.includes(id)); + allUnpinned = [...allUnpinned, ...filtered]; + redisOffset += fetchWindow; + if (batch.length < fetchWindow) break; + } + paginatedUnpinned = allUnpinned.slice(unpinnedOffset, unpinnedOffset + limit); } + // Merge pinned and unpinned for this page + const paginatedChatIds = [...paginatedPinned, ...paginatedUnpinned]; // 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), ...paginatedChatIds).catch((err) => { + console.error("Error fetching chat data:", err) + return [] as string[]; + }); const chats = rawChats .map((chatStr, i) => { try { return chatStr ? { ...JSON.parse(chatStr), - id: chatIds[i], + id: paginatedChatIds[i], } : null; } catch (e) { // This is gonna screw me over some day.. - console.error(`Failed to parse chat ${chatIds[i]}:`, e); + console.error(`Failed to parse chat ${paginatedChatIds[i]}:`, e); return null; } }) .filter(Boolean); // remove nulls // Get total count once (not paginated) - const total = await redis.zcard(USER_CHATS_INDEX_KEY(user.userId)); + const totalChats = await redis.zcard(USER_CHATS_INDEX_KEY(user.userId)).catch((err) => { + console.error("Error fetching total chat count:", err); + return 0; + }); + const total = totalPinned + (totalChats - totalPinned); return NextResponse.json({ chats, @@ -102,7 +157,7 @@ export async function POST(req: NextRequest) { } as ChatResponse)) .zadd(USER_CHATS_INDEX_KEY(user.id), Date.now(), id) .exec(); - + // Check for failure if (!result || result.some(([err]) => err)) { await redis.hdel(USER_CHATS_KEY(user.id), id); @@ -114,7 +169,7 @@ export async function POST(req: NextRequest) { console.error("Error creating chat:", error); return NextResponse.json({ error: "Failed to create chat" } as ApiError, { status: 500 }); } - + return NextResponse.json({ id, model: chat.model, diff --git a/src/app/chat/[id]/layout.tsx b/src/app/chat/[id]/layout.tsx index 8ac1725..a91df9c 100644 --- a/src/app/chat/[id]/layout.tsx +++ b/src/app/chat/[id]/layout.tsx @@ -1,31 +1,54 @@ -import React from "react"; -import { cookies } from "next/headers"; +"use client"; +import React, { use, useEffect, useState } from "react"; import ModelProviderClientWrapper from "./ModelProviderClientWrapper"; -// Helper to fetch model/provider on the server -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() } } - ); - if (!res.ok) return { model: null, provider: null }; - const data = await res.json(); - return { - model: data.model || null, - provider: data.provider || null, +// Default values +const DEFAULT_MODEL = "google/gemini-2.5-flash"; +const DEFAULT_PROVIDER = "openrouter"; + +export default function ChatLayout({ children, params }: { + children: React.ReactNode, + params: Promise<{ id: string }>, +}) { + // Try to get previous values from localStorage (client only) + const getInitialModel = () => { + if (typeof window !== "undefined") { + return localStorage.getItem("lastModel") || DEFAULT_MODEL; + } + return DEFAULT_MODEL; }; -} + const getInitialProvider = () => { + if (typeof window !== "undefined") { + return localStorage.getItem("lastProvider") || DEFAULT_PROVIDER; + } + return DEFAULT_PROVIDER; + }; + + const { id } = use(params); -export default async function ChatLayout({ children, params }: { children: React.ReactNode, params: Promise<{ id: string }> }) { - const { model, provider } = await fetchModelProvider((await params).id); + const [model, setModel] = useState(getInitialModel); + const [provider, setProvider] = useState(getInitialProvider); - if (!model || !provider) { - return ( -
- Loading chat... -
- ); - } + useEffect(() => { + async function fetchModelProvider(chatId: string) { + try { + const res = await fetch(`/api/chat/${chatId}`); + if (!res.ok) return; + const data = await res.json(); + if (data.model) { + setModel(data.model); + localStorage.setItem("lastModel", data.model); + } + if (data.provider) { + setProvider(data.provider); + localStorage.setItem("lastProvider", data.provider); + } + } catch (e) { + // Ignore errors, keep optimistic state + } + } + fetchModelProvider(id); + }, [id]); return ( diff --git a/src/app/chat/[id]/page.tsx b/src/app/chat/[id]/page.tsx index cbc822f..d14d141 100644 --- a/src/app/chat/[id]/page.tsx +++ b/src/app/chat/[id]/page.tsx @@ -1,6 +1,6 @@ "use client"; -import React, { useEffect, useRef, useState, useMemo, useCallback } from "react"; +import React, { useEffect, useRef, useState, useMemo, useCallback, useLayoutEffect } from "react"; import ChatInput from "@/app/components/ChatInput"; import Markdown from "react-markdown"; import remarkGfm from "remark-gfm"; @@ -11,83 +11,65 @@ import { ChunkResponse, Message } from "@/app/lib/types/ai"; import { escape } from "html-escaper"; import rehypeClassAll from "@/app/lib/utils/rehypeClassAll"; import { useParams } from "next/navigation"; -import { loadMessagesFromServer } from "@/app/lib/utils/messageUtils"; import Image from "next/image"; import { Protect, SignedOut } from "@clerk/nextjs"; import { useModelProvider } from "./ModelProviderContext"; +import useSWRInfinite from "swr/infinite"; +import { ChatMessagesResponse } from "@/app/api/chat/[id]/messages/route"; +import { ApiError } from "@/internal-lib/types/api"; + +// Scrolling not at the bottom = show scroll to bottom button +function isAtBottom(threshold = 16): boolean { + const scrollTop = window.scrollY || document.documentElement.scrollTop; + const scrollHeight = document.documentElement.scrollHeight; + const clientHeight = document.documentElement.clientHeight; + + return scrollTop + clientHeight >= scrollHeight - threshold; +} + +const SCROLL_TOP_THRESHOLD = 196; // px, adjust as needed for prefetching before top +const PAGE_SIZE = 15; export default function Chat() { const { model, provider } = useModelProvider(); const params = useParams(); const tabId = params.id?.toString() ?? ""; - const [messages, setMessages] = useState([]); - const messagesRef = useRef(null); + const [messagesEl, setMessagesEl] = useState(null); const [generating, setGenerating] = useState(false); - const [messagesLoading, setMessagesLoading] = useState(true); const eventSourceRef = useRef(null); - const [autoScroll, setAutoScroll] = useState(true); - const programmaticScrollRef = useRef(false); - const topSentinelRef = useRef(null); const [streamError, setStreamError] = useState(null); const [byokRequired, setByokRequired] = useState(false); - - const fetchMessages = useCallback(async () => { - setMessagesLoading(true); - const serverMessages = await loadMessagesFromServer(tabId); - setMessagesLoading(false); - return serverMessages; - }, [tabId]); - - useEffect(() => { - if (!tabId) return; - async function loadInitial() { - const serverMessages = await fetchMessages(); - setMessages(prev => { - if (serverMessages.messages.length === prev.length) { - return prev; // No new messages, return existing - } - return [prev, serverMessages.messages].flat(); - }); - // Instantly scroll to bottom after initial messages load - setTimeout(() => { - const messagesElement = messagesRef.current; - if (messagesElement) { - programmaticScrollRef.current = true; - window.scrollTo({ - top: messagesElement.scrollHeight, - behavior: "auto" - }); - setTimeout(() => { - programmaticScrollRef.current = false; - }, 100); - } - }, 0); - } - loadInitial(); - }, [tabId, fetchMessages]); - - useEffect(() => { - if (!messagesLoading && messages.length > 0 && autoScroll) { - const messagesElement = messagesRef.current; - if (messagesElement) { - programmaticScrollRef.current = true; - window.scrollTo({ - behavior: "smooth", - top: messagesElement.scrollHeight, - }); - setTimeout(() => { - programmaticScrollRef.current = false; - }, 100); - } + const [optimisticSentUserMessage, setOptimisticSentUserMessage] = useState(null); + const [streamingMessageContent, setStreamingMessageContent] = useState(""); + + // TODO: Make use of `error` + const { data: pages = [], error, size, setSize, isValidating, isLoading, mutate } = useSWRInfinite( + (pageIndex, previousPage) => { + if (previousPage && previousPage.length < PAGE_SIZE) return null; + return `/api/chat/${tabId}/messages?page=${pageIndex + 1}&limit=${PAGE_SIZE}&reverse=true`; + }, + (url) => + fetch(url) + .then(res => res.json() as Promise) + .then(json => { + if ("error" in json) throw Error(json.error); + return json; + }), + { + revalidateOnFocus: false, + revalidateOnReconnect: true, + keepPreviousData: true, } - }, [messages, messagesLoading, autoScroll]); + ); const localGenerating = useRef(generating); const onSend = useCallback(async (message: string, attachments: { url: string; filename: string }[] = [], search: boolean, model: string, provider: string) => { // Add user message optimistically to UI const userMessage: Message = { role: "user", parts: [{ text: message }], attachments: attachments.length > 0 ? attachments : undefined }; - setMessages(prev => [...prev, userMessage]); + setOptimisticSentUserMessage(userMessage); + setStreamingMessageContent(""); + setStreamError(null); localGenerating.current = true; setGenerating(true); @@ -105,6 +87,8 @@ export default function Chat() { setGenerating(false); localGenerating.current = false; setStreamError("Failed to send message"); + setOptimisticSentUserMessage(null); + setStreamingMessageContent(""); return; }); @@ -112,56 +96,108 @@ export default function Chat() { setGenerating(false); localGenerating.current = false; setStreamError("Failed to send message"); + setOptimisticSentUserMessage(null); + setStreamingMessageContent(""); return; } }, [tabId]); // Regenerate handler for LLM responses - const [regeneratingIdx, setRegeneratingIdx] = useState(null); - const handleRegenerate = useCallback(async (idx: number) => { - setRegeneratingIdx(idx); - const deleteRes = await fetch(`/api/chat/${tabId}/messages/delete-from-index?fromIndex=${idx}`, { method: "DELETE" }) - .catch(() => { + const [regeneratingIdx, setRegeneratingIdx] = useState<{ pageIdx: number; messageIdx: number } | null>(null); + const handleRegenerate = useCallback( + async (pageIdx: number, msgIdx: number) => { + setGenerating(true); + // Find total messages from the first page (should be present in paginated response) + const total = pages[0]?.total; + if (typeof total !== "number") { + setStreamError("Total message count not available"); + setGenerating(false); + return; + } + + // Calculate the server index (oldest=0, newest=total-1) + const serverIndex = total - (pageIdx * PAGE_SIZE + msgIdx) - 1; + + // Find the previous user message (should be at serverIndex-1) + const allMessages = pages.flatMap(page => page.messages); + const prevUserMsg = allMessages[serverIndex - 1] as Message | undefined; + if (!prevUserMsg || (prevUserMsg?.role) !== "user") { + console.warn("No previous user message found for regeneration"); + setRegeneratingIdx(null); + setGenerating(false); + return; + } + + console.log("Regenerating message at server index:", serverIndex); + setStreamingMessageContent(""); + setRegeneratingIdx({ pageIdx, messageIdx: msgIdx }); + setStreamError(null); + const deleteRes = await fetch(`/api/chat/${tabId}/messages/delete-from-index?fromIndex=${serverIndex}`, { method: "DELETE" }) + .catch(() => { + setRegeneratingIdx(null); + setStreamError("Failed to delete message for regeneration"); + setGenerating(false); + return; + }); + if (!deleteRes || !deleteRes.ok) { setRegeneratingIdx(null); setStreamError("Failed to delete message for regeneration"); + setGenerating(false); return; - }); - if (!deleteRes || !deleteRes.ok) { - setRegeneratingIdx(null); - setStreamError("Failed to delete message for regeneration"); - return; - } + } - const prevUserMsg = messages[idx - 1]; - if (!prevUserMsg || (prevUserMsg?.role) !== "user") { - setRegeneratingIdx(null); - return; - } + const response = await fetch(`/api/chat/${tabId}/regenerate?fromIndex=${serverIndex}`, { + method: "GET", + headers: { + "Content-Type": "application/json", + }, + }).catch(() => { + setRegeneratingIdx(null); + setStreamError("Failed to regenerate message"); + setGenerating(false); + return; + }); - const response = await fetch(`/api/chat/${tabId}/regenerate?fromIndex=${idx}`, { - method: "GET", - headers: { - "Content-Type": "application/json", - }, - }).catch(() => { - setRegeneratingIdx(null); - setStreamError("Failed to regenerate message"); - return; - }); + if (!response || !response.ok) { + setRegeneratingIdx(null); + setStreamError("Failed to regenerate message"); + setGenerating(false); + return; + } - if (!response || !response.ok) { - setRegeneratingIdx(null); - setStreamError("Failed to regenerate message"); - return; - } + // Now delete the messages after sending the request on the view + // Remove all messages after the to-be-regenerated AI message (but keep the user message) using mutate + setGenerating(false); + mutate(pages => { + if (!pages) return pages; + // Clone pages to avoid mutation + const newPages = pages.map(page => ({ + ...page, + messages: [...page.messages], + })); + + // Remove the message at the given pageIdx and msgIdx, + // and remove all messages/pages after it + if ( + newPages[pageIdx] && + newPages[pageIdx].messages && + newPages[pageIdx].messages[msgIdx] + ) { + // Remove messages after msgIdx in the same page + newPages[pageIdx].messages = newPages[pageIdx].messages.slice(0, msgIdx); + // Remove all pages after pageIdx + newPages.length = pageIdx + 1; + // Optionally update total if present + if (typeof newPages[0].total === "number") { + // Recalculate total as sum of all messages + newPages[0].total = newPages.reduce((acc, page) => acc + page.messages.length, 0); + } + } - // Now delete the messages after sending the request on the view - setMessages(prev => { - const newMessages = [...prev]; - newMessages.splice(idx, 1); // Remove the model message at idx - return newMessages; - }); - }, [messages, tabId]); + // Remove empty pages except the first one (to avoid empty UI) + return newPages.filter((page, idx) => idx === 0 || page.messages.length > 0); + }); + }, [pages, tabId]); useEffect(() => { if (eventSourceRef.current) { @@ -171,11 +207,13 @@ export default function Chat() { const eventSource = new EventSource(`/api/stream?` + new URLSearchParams({ chat: tabId }).toString()); eventSourceRef.current = eventSource; - const streamDoneEvent = (event: MessageEvent) => { + const streamDoneEvent = async (event: MessageEvent) => { assistantMessage = ""; - reloadMessagesFromServerIfStateInvalid(); + await mutate().catch(() => { }); setGenerating(false); setRegeneratingIdx(null); + setOptimisticSentUserMessage(null); + setStreamingMessageContent(""); } eventSource.addEventListener("stream-done", streamDoneEvent); @@ -183,17 +221,22 @@ export default function Chat() { assistantMessage = ""; setStreamError(event.data || "An error occurred"); setGenerating(false); + setStreamingMessageContent(""); } eventSource.addEventListener("stream-error", streamErrorEvent); let assistantMessage = ""; eventSource.onmessage = async (event) => { + setStreamError(null); if (!localGenerating.current) { // That means this client didn't start the generation, therefore reload the state first - await reloadMessagesFromServerIfStateInvalid().catch(() => { + // Use mutate to reload messages from the server if state is invalid + await mutate().catch(() => { setStreamError("Failed to reload messages from server"); setGenerating(false); localGenerating.current = false; + setOptimisticSentUserMessage(null); + setStreamingMessageContent(""); return; }); } @@ -210,20 +253,12 @@ export default function Chat() { if (!parsed.content) return; // Skip empty chunks assistantMessage += parsed.content; - setMessages(prev => { - const newMessages = [...prev]; - const lastMessage = newMessages[newMessages.length - 1]; - if (lastMessage?.role === "model") { - lastMessage.parts = [{ text: assistantMessage, annotations: parsed.urlCitations || [] }]; - return [...newMessages]; - } else { - return [...newMessages, { role: "model", parts: [{ text: assistantMessage }] } as Message]; - } - }); + setStreamingMessageContent(assistantMessage); } catch (e) { console.error("Failed to parse chunk text:", e); setStreamError("Failed to parse response chunk"); setGenerating(false); + setStreamingMessageContent(""); eventSource.close(); } } @@ -239,13 +274,18 @@ export default function Chat() { eventSource.close(); assistantMessage = ""; setGenerating(false); + setOptimisticSentUserMessage(null); + setStreamingMessageContent(""); }, { once: true }); - eventSource.addEventListener("done", () => { - reloadMessagesFromServerIfStateInvalid(); + eventSource.addEventListener("done", async () => { + // Use mutate to reload messages from the server if state is invalid eventSource.close(); assistantMessage = ""; setGenerating(false); + setOptimisticSentUserMessage(null); + setStreamingMessageContent(""); + await mutate().catch(() => { }); }, { once: true }); return () => { @@ -256,10 +296,10 @@ export default function Chat() { eventSourceRef.current = null; } } - }, [tabId, streamError]); + }, [tabId, streamError, mutate]); - // Memorize initial search state from sessionStorage (lines 262-283 logic) - const [initialSearch, setInitialSearch] = useState(undefined); + // Memorize previous web search state from sessionStorage (lines 262-283 logic) + const [webSearchInitiallyOn, setWebSearchInitiallyOn] = useState(undefined); useEffect(() => { const tempNewMsg = sessionStorage.getItem("temp-new-tab-msg"); if (tempNewMsg) { @@ -276,7 +316,7 @@ export default function Chat() { }; checkEventSource(); }); - setInitialSearch(!!parsedMsg.search); + setWebSearchInitiallyOn(!!parsedMsg.search); sessionStorage.removeItem("temp-new-tab-msg"); waitUntilEventSource.then(() => { onSend(parsedMsg.message, parsedMsg.attachments || [], parsedMsg.search || false, "", ""); @@ -284,135 +324,120 @@ export default function Chat() { } } catch { } } - - let lastScrollY = window.scrollY; - function handleScroll() { - if (programmaticScrollRef.current) { - lastScrollY = window.scrollY; - return; - } - const messagesElement = messagesRef.current; - if (!messagesElement) return; - const currentScrollY = window.scrollY; - const scrollPosition = currentScrollY + window.innerHeight; - const bottomThreshold = messagesElement.scrollHeight - 35; - - if (currentScrollY < lastScrollY) { - setAutoScroll(false); - } else if (currentScrollY > lastScrollY) { - if (scrollPosition >= bottomThreshold) { - const messagesElement = messagesRef.current; - if (messagesElement) { - programmaticScrollRef.current = true; - window.scrollTo({ - behavior: "instant", - top: messagesElement.scrollHeight, - }); - setTimeout(() => { - programmaticScrollRef.current = false; - }, 100); - } - setAutoScroll(true); - } - } - lastScrollY = currentScrollY; - } - window.addEventListener("scroll", handleScroll); - return () => window.removeEventListener("scroll", handleScroll); }, [onSend, tabId]); - const reloadMessagesFromServerIfStateInvalid = useCallback(async () => { - const serverMessages = await loadMessagesFromServer(tabId); - if (!messages[messages.length - 1] || messages[messages.length - 1]?.role !== "model") { - setMessages(serverMessages.messages); + const [showScrollToBottom, setShowScrollToBottom] = useState(false); + const previousScrollHeightRef = useRef(0); + // Fix scroll behavior: only scroll when loading more at top or when at bottom + useLayoutEffect(() => { + const previousScrollHeight = previousScrollHeightRef.current; + const newScrollHeight = document.body.scrollHeight; + // If loading more messages at the top (pagination) + if (window.scrollY <= SCROLL_TOP_THRESHOLD && newScrollHeight > previousScrollHeight) { + // Preserve scroll position when loading more + const scrollDelta = newScrollHeight - previousScrollHeight; + window.scrollTo({ + top: scrollDelta, + behavior: "auto", + }); } - }, [tabId, messages]); + previousScrollHeightRef.current = newScrollHeight; + }, [pages]); useEffect(() => { - if (autoScroll) { - setTimeout(() => { - const event = new Event("scroll"); - window.dispatchEvent(event); - }, 0); + const onScroll = () => { + setShowScrollToBottom(!isAtBottom()); + + // Make sure the scroll position is retained when loading more + if (window.scrollY <= SCROLL_TOP_THRESHOLD && !isValidating && pages[pages.length - 1]?.hasMore) { + previousScrollHeightRef.current = document.body.scrollHeight; + setSize(size + 1); // triggers useLayoutEffect on `pages` change + } + }; + + window.addEventListener("scroll", onScroll); + return () => window.removeEventListener("scroll", onScroll); + }, [size, setSize, isValidating, pages, tabId]); + + const initiallyLoadedRef = useRef(false); + useEffect(() => { + if (initiallyLoadedRef.current) return; + + // Scroll to bottom only once after messages load + if (!isLoading && pages.length > 0 && messagesEl) { + initiallyLoadedRef.current = true; + window.scrollTo({ top: document.body.scrollHeight, behavior: "instant" }); } - }, [autoScroll]); + }, [isLoading, pages, tabId, messagesEl]); - function handleStopAutoScroll() { - setAutoScroll(false); - } + // useLayoutEffect(() => { + // console.log(pages) + // }, [pages]); - async function handleDeleteMessage(idx: number) { - if (idx === 0) { + // Update handleDeleteMessage to accept pageIdx and msgIdx, and use the same server index calculation as regeneration + async function handleDeleteMessage(pageIdx: number, msgIdx: number) { + const total = pages[0]?.total; + if (typeof total !== "number") { + setStreamError("Total message count not available"); + return; + } + const serverIndex = total - (pageIdx * PAGE_SIZE + msgIdx) - 1; + if (serverIndex === 0) { // Delete the entire chat if the first message is deleted await fetch(`/api/chat/${tabId}`, { method: "DELETE" }); // Redirect to home or another page after deletion window.location.href = "/"; return; } - await fetch(`/api/chat/${tabId}/messages/delete-from-index?fromIndex=${idx}`, { method: "DELETE" }); - setMessages(messages.slice(0, idx)); + await fetch(`/api/chat/${tabId}/messages/delete-from-index?fromIndex=${serverIndex}`, { method: "DELETE" }); + mutate(pages => { + if (!pages) return pages; + // Clone pages to avoid mutation + const newPages = pages.map(page => ({ + ...page, + messages: [...page.messages], + })); + + // Remove the message at the given pageIdx and msgIdx, + // and remove all messages/pages after it + if ( + newPages[pageIdx] && + newPages[pageIdx].messages && + newPages[pageIdx].messages[msgIdx] + ) { + // Remove messages after msgIdx in the same page + newPages[pageIdx].messages = newPages[pageIdx].messages.slice(0, msgIdx); + // Remove all pages after pageIdx + newPages.length = pageIdx + 1; + // Optionally update total if present + if (typeof newPages[0].total === "number") { + // Recalculate total as sum of all messages + newPages[0].total = newPages.reduce((acc, page) => acc + page.messages.length, 0); + } + } + + // Remove empty pages except the first one (to avoid empty UI) + return newPages.filter((page, idx) => idx === 0 || page.messages.length > 0); + }); } useEffect(() => { - fetch("/api/byok/required").then(res => res.json()).then(data => { - setByokRequired(data.required); - if (data.required) { - window.location.href = "/settings"; - } - }); + fetch("/api/byok/required") + .then(res => res.json()) + .then((data) => { + setByokRequired(data.required); + if (data.required) { + window.location.href = "/settings"; + } + }); }, []); - - // Fetch chat info (model/provider) on mount - // useEffect(() => { - // if (!tabId) return; - // fetch(`/api/chat/${tabId}`) - // .then(res => res.json()) - // .then data => { - // if (data && data.model && data.provider) { - // setModel(data.model); - // setProvider(data.provider); - // } - // }) - // .catch(() => { - // setModel(null); - // setProvider(null); - // }); - // }, [tabId]); - if (byokRequired) return null; return ( <> - {generating && autoScroll && ( - - )}
-
-
- {!messagesLoading && ( - <> - {messages.map((message, idx) => ( - - ))} - - )} +
{streamError && (
Message generation failed. You can retry. @@ -421,43 +446,100 @@ export default function Chat() { className="bg-red-500 text-white px-4 py-2 rounded hover:bg-red-600 transition cursor-pointer mt-2" onClick={() => { setStreamError(null); - if (messages.length > 1) handleRegenerate(messages.length - 1); + // Find last message's pageIdx and messageIdx + const lastPageIdx = pages.length - 1; + const lastMsgIdx = pages[lastPageIdx]?.messages.length - 1; + if (lastPageIdx >= 0 && lastMsgIdx >= 0) handleRegenerate(lastPageIdx, lastMsgIdx); }} > Retry
)} - {generating && (!messages[messages.length - 1] || messages[messages.length - 1]?.role !== "model") && ( -
- - - - - -
- Why is this showing? Latency, reasoning and uploading files -
- -
+ {(() => { + const messages = pages.flatMap(p => p?.messages); + const lastMessage = messages?.[messages?.length - 1]; + 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 +657,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 +754,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 +789,7 @@ const MessageBubble = ({ message, index, onDelete, onRegenerate, regeneratingIdx )} {/* Delete button for user messages only */} @@ -749,10 +829,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/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 ffb19a0..ac796ce 100644 --- a/src/app/components/ChatPalette.tsx +++ b/src/app/components/ChatPalette.tsx @@ -1,12 +1,15 @@ -"use client"; - -import React from "react"; -import useSWR from "swr"; -import { ApiError, ChatResponse, GetChatsResponse } from "../../internal-lib/types/api"; -import { FormEventHandler, useEffect, useRef, useState, useMemo } from "react"; +import "./ChatPalette/style.css"; +import React, { startTransition, useCallback } from "react"; +import useSWRInfinite from "swr/infinite"; +import { ApiError, ChatResponse, CreateChatRequest, CreateChatResponse, GetChatsResponse } from "../../internal-lib/types/api"; +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"; +import { useInView } from 'react-intersection-observer'; interface ChatPaletteProps { className?: string; @@ -14,278 +17,820 @@ 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 + +// 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 format(date, "MM/dd/yyyy"); // Fallback to date +} + +// Extracted function to group and sort chats into sections +function parseChatsWithSections(data: GetChatsResponse) { + const chatsWithSections = new Map(); + data.chats.forEach(chat => { + try { + 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); + } catch { } + }); + + // 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 + }) + ); + + return { + chats: sortedSections, + total: data.total, + page: data.page, + limit: data.limit, + hasMore: data.hasMore, + }; +} export default function ChatPalette({ className, hidden: hiddenOuter, onDismiss }: ChatPaletteProps) { - const inputRef = useRef(null); - const [showLabel, setShowLabel] = useState(true); + const { ref, inView } = useInView(); + + const [loadingMore, setLoadingMore] = useState(false); + const { data: paginatedData, isLoading, isValidating, mutate, size, setSize } = useSWRInfinite((pageIndex: number, previousPageData) => { + if (previousPageData && !previousPageData.hasMore) return null; // No more pages to load + const page = pageIndex + 1; + return `/api/chat?page=${page}?limit=35`; // Adjust limit as needed + }, 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"); + } + } + + // Parse response data + const data = await res.json() as GetChatsResponse; + return parseChatsWithSections(data); + }, { + revalidateOnMount: true, + revalidateOnReconnect: true, + }); + + const data = useMemo(() => { + if (!paginatedData) return undefined; + // Merge all pages' chats into a single Map with sections + const mergedChats = new Map(); + let total = 0, page = 1, limit = 0, hasMore = false; + paginatedData.forEach(pageData => { + if (!pageData) return; + pageData.chats.forEach((chats, section) => { + if (!mergedChats.has(section)) { + mergedChats.set(section, []); + } + mergedChats.get(section)!.push(...chats); + }); + total = pageData.total; + page = pageData.page; + limit = pageData.limit; + hasMore = pageData.hasMore; + }); + // Resort sections: pinned first, then by date + const sortedSections = new Map( + [...mergedChats.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 { chats: sortedSections, total, page, limit, hasMore }; + }, [paginatedData]); + const [hidden, setHidden] = useState(hiddenOuter); - const [selected, setSelected] = useState<[number, number]>([0, 0]); - const selectedRef = useRef(null); + // [[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 [deletingId, setDeletingId] = useState(null); - const [localChats, setLocalChats] = useState({ - chats: [], - hasMore: false, - limit: 0, - page: 0, - total: 0 - }); + const chatItemRefs = useRef<(HTMLLIElement | null)[]>([]); + + // Renaming + const [renamingId, setRenamingId] = useState(null); + // Deleting const [pendingDeleteId, setPendingDeleteId] = useState(null); const pendingDeleteTimeout = useRef(null); - const [selectedChatIds, setSelectedChatIds] = useState>(new Set()); + const [deletingId, setDeletingId] = useState(null); const [bulkDeleteMode, setBulkDeleteMode] = useState(false); - const [isTouchDevice, setIsTouchDevice] = useState(false); - const touchTimeoutRef = useRef(null); - const touchStartRef = useRef<{ chatId: string; startTime: number } | null>(null); + // Touch handling (Bulk delete) const [longPressActive, setLongPressActive] = useState(null); - const [searchQuery, setSearchQuery] = useState(""); + const [bulkSelectedChatIds, setSelectedChatIds] = useState>(new Set()); + const lastSelectedBulkChatRef = useRef(null); + const touchStartRef = useRef<{ chatId: string; startTime: number } | null>(null); + const touchTimeoutRef = useRef(null); - const { data, isLoading, mutate } = useSWR("/api/chat", async path => { - return fetch(path).then(res => res.json() as Promise); - }); + // 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 new Map(); + if (!searchQuery.trim()) { + // If there are chats, return them directly + return data.chats; + } + const query = searchQuery.toLowerCase(); + const filtered = new Map(); + let hasAny = false; + 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); + hasAny = true; + } + }); + // If search yields nothing, but there are chats, return empty map to trigger 'No chats found' + return hasAny ? filtered : new Map(); + }, [data, searchQuery]); - // Sync localChats with SWR data useEffect(() => { - if (data && !("error" in data)) { - setLocalChats(data); + setHidden(hiddenOuter); + if (!hiddenOuter) { + mutate(paginatedData, { optimisticData: paginatedData, revalidate: true }); } - }, [data]); + }, [hiddenOuter, paginatedData, mutate]); - // 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", + if (inView && !isValidating && !loadingMore && data?.hasMore) { + setLoadingMore(true); + setSize((prevSize) => prevSize + 1).finally(() => { + setLoadingMore(false); }); + console.log("Loading more chats..."); + } + }, [inView, isValidating, loadingMore, setSize, data?.hasMore]); + + const keyboardShortcutHandler = useCallback(async (e: KeyboardEvent) => { + const key = e.key as Key; - // Move the selectedRef div as well - if (selectedRef.current) { - selectedRef.current.style.setProperty("--top-pos", `${elTop}px`); + // 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; + } + } 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; } - } - }, [selected, localChats?.chats?.length]); - useEffect(() => { - const selectedDiv = selectedRef.current; - if (selectedDiv) { - selectedDiv.style.setProperty("--top-pos", "0px"); + setSelected([[nextSectionIndex, nextIndex], !hasWrappedAround ? 1 : -1]); } - }, []); + if (!e.altKey && !e.ctrlKey && !e.metaKey && key === "ArrowDown") { + e.preventDefault(); + e.stopPropagation(); - // Detect touch device - useEffect(() => { - const checkTouchDevice = () => { - setIsTouchDevice("ontouchstart" in window || navigator.maxTouchPoints > 0); - }; - checkTouchDevice(); - window.addEventListener("resize", checkTouchDevice); - return () => window.removeEventListener("resize", checkTouchDevice); - }, []); + const isFaster = e.shiftKey; + const currentSectionIndex = selected[0][0] || 0; + const currentIndex = selected[0][1]; - const router = useRouter(); - function createTab(chat: ChatResponse) { - addAndSaveTabsLocally(localStorage, { - id: chat.id, - label: chat.label ?? "New Tab", - link: `/chat/${chat.id}` - }); - router.push(`/chat/${chat.id}`); - setTimeout(() => { - onDismiss(); - }, 75); // Delay to allow navigation to start - } + let hasWrappedAround = false; - // Keyboard navigation - useEffect(() => { - const onKeyDown: typeof window.onkeydown = (e) => { - if (e.key == "Escape") { - e.preventDefault(); - e.stopPropagation(); + const sectionKeys = Array.from(filteredChats.keys()); + let nextSectionIndex = currentSectionIndex; + let nextIndex = currentIndex; - if (bulkDeleteMode || selectedChatIds.size > 0) { - setBulkDeleteMode(false); - setSelectedChatIds(new Set()); - return; + 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) { + // Don't wrap around if we're loading more data or if there's more data to load + if (loadingMore || data?.hasMore) { + nextSectionIndex = sectionKeys.length - 1; + nextIndex = sectionLength - 1; + hasWrappedAround = false; + } else { + nextSectionIndex = 0; + nextIndex = 0; + hasWrappedAround = true; + } + } else { + nextIndex = 0; + } } - - const chat = localChats.chats[selected[0]]; - if (chat && pendingDeleteId === chat.id && !deletingId) { - setPendingDeleteId(""); - if (pendingDeleteTimeout.current) { - clearTimeout(pendingDeleteTimeout.current); - pendingDeleteTimeout.current = null; + } 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; } - } else if (!chat || pendingDeleteId !== chat.id) { - onDismiss(); } + nextSectionIndex = tempSectionIndex; + nextIndex = tempIndex; + // For shift+down, do not wrap, so hasWrappedAround is always false } - if (e.key == "ArrowDown") { - e.preventDefault(); - e.stopPropagation(); - let i = selected[0] + 1; - if (i >= filteredChats.length) { - i = filteredChats.length - 1 < 0 ? 0 : filteredChats.length - 1; + + setSelected([[nextSectionIndex, nextIndex], !hasWrappedAround ? -1 : 1]); + + // Preemptive loading when approaching the end + if (!loadingMore && data?.hasMore && !hasWrappedAround) { + const totalItems = Array.from(filteredChats.values()).reduce((sum, chats) => sum + chats.length, 0); + const currentFlatIndex = Array.from(filteredChats.entries()) + .slice(0, nextSectionIndex) + .reduce((sum, [_, chats]) => sum + chats.length, 0) + nextIndex; + + // Load more when within 5 items of the end + if (totalItems - currentFlatIndex <= 5) { + console.log("Preemptively loading more chats..."); + setLoadingMore(true); + setSize((prevSize) => prevSize + 1).finally(() => { + setLoadingMore(false); + }); } - setSelected([i, -1]); } - if (e.key == "ArrowUp") { - e.preventDefault(); - e.stopPropagation(); - let i = selected[0] - 1; - if (i < 0) { - i = 0; - } - setSelected([i, 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.key == "Enter") { - e.preventDefault(); - e.stopPropagation(); + } + 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]); + } + } - if (bulkDeleteMode && selectedChatIds.size > 0) { - handleBulkDelete(); - return; + if (!e.altKey && !e.shiftKey && !e.metaKey && !e.ctrlKey && key === "Escape") { + e.preventDefault(); + e.stopPropagation(); + + if (bulkDeleteMode) { + // If bulk delete mode is active, exit it + setBulkDeleteMode(false); + setSelectedChatIds(new Set()); + lastSelectedBulkChatRef.current = null; + return; + } + + if (pendingDeleteId) { + // If a chat is pending delete, cancel the pending delete + if (pendingDeleteTimeout.current) { + clearTimeout(pendingDeleteTimeout.current); + pendingDeleteTimeout.current = null; } + setPendingDeleteId(null); + return; + } + + if (renamingId) { + // If renaming is active, cancel renaming + setRenamingId(null); + return; + } + + onDismiss(); + return; + } - const chat = localChats.chats[selected[0]]; - if (!chat) return; - if (pendingDeleteId === chat.id && !deletingId) { - if (pendingDeleteTimeout.current) { - clearTimeout(pendingDeleteTimeout.current); - pendingDeleteTimeout.current = null; + 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; + }); } - handleDelete(chat.id); - } else if (!chat || pendingDeleteId !== chat.id) { - createTab(chat); } + return; } - if (e.key === "Delete" || (e.shiftKey && e.key === "Backspace")) { - e.preventDefault(); - e.stopPropagation(); - if (bulkDeleteMode && selectedChatIds.size > 0) { - handleBulkDelete(); - return; + if (pendingDeleteId) { + // If a chat is pending delete, confirm deletion + if (pendingDeleteTimeout.current) { + clearTimeout(pendingDeleteTimeout.current); + pendingDeleteTimeout.current = null; } + handleDelete(pendingDeleteId); + 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; + + openTab(chat); + return; + } + + if (e.shiftKey && !e.altKey && !e.metaKey && !e.ctrlKey && key === "Enter") { + if (searchQuery.trim() === "") return; + e.preventDefault(); + e.stopPropagation(); + await createChatFromSearch(); + return; + } - const chat = localChats.chats[selected[0]]; - if (!chat) return; - if (pendingDeleteId === chat.id && !deletingId) { - if (pendingDeleteTimeout.current) { - clearTimeout(pendingDeleteTimeout.current); - pendingDeleteTimeout.current = null; + + 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 (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(); + + 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, data?.hasMore, loadingMore, searchQuery, setSize]); + + useEffect(() => { + if (hidden) { + lastSelectedRef.current = selected; + setRenamingId(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); - } 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) 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}` + }); + onDismiss(); + startTransition(() => { + router.push(`/chat/${chat.id}`); + }); + } + + const createChatFromSearch = useCallback(async () => { + if (!searchQuery.trim()) return; + onDismiss(); + setTimeout(() => { + setSearchQuery(""); + }, 200); + + // If shift + enter is pressed, create a new chat with the search query as title + const chat = await fetch("/api/chat", { + method: "POST", + body: JSON.stringify({ + model: "google/gemini-2.5-flash", // this should prolly not be hardcoded + provider: "openrouter", + } as CreateChatRequest), + }).then(res => res.json() as Promise) + .catch(() => undefined); + if (!chat || "error" in chat) { + return; + } + + sessionStorage.setItem("temp-new-tab-msg", JSON.stringify({ message: searchQuery.trim(), tabId: chat.id })); + addAndSaveTabsLocally(localStorage, { + id: chat.id, + link: `/chat/${chat.id}` + }); + + startTransition(() => { + router.push(`/chat/${chat.id}`); + }); + }, [searchQuery, onDismiss, router]); + + 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]); - // 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((pages) => { + if (!pages) return pages; + // Update the first page's chats (or whichever page contains the updated chat) + return pages.map((page, idx) => { + if (idx === 0 && page) { + return { ...page, chats: newChatsMap }; + } + return page; + }); + }, 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 () => { @@ -298,31 +843,70 @@ 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)); + mutate((pages) => { + if (!pages) return pages; + // Merge all pages' chats into a single Map + const mergedChats = new Map(); + let total = 0, page = 1, limit = 0, hasMore = false; + pages.forEach(pageData => { + if (!pageData) return; + pageData.chats.forEach((chats, section) => { + if (!mergedChats.has(section)) { + mergedChats.set(section, []); + } + mergedChats.get(section)!.push(...chats); + }); + total = pageData.total; + page = pageData.page; + limit = pageData.limit; + hasMore = pageData.hasMore; + }); - // 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]); + // Remove deleted chats from all sections + for (const [section, chats] of mergedChats.entries()) { + const filtered = chats.filter(c => !chatIdsToDelete.includes(c.id)); + if (filtered.length === 0) { + mergedChats.delete(section); + } else { + mergedChats.set(section, filtered); + } } + // Move selection if needed + let [sectionIdx, chatIdx] = selected[0]; + const sectionKeys = Array.from(mergedChats.keys()); + if ( + sectionIdx >= sectionKeys.length || + (sectionKeys[sectionIdx] && + !(mergedChats.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(mergedChats.values()).reduce((acc, arr) => acc + arr.length, 0); + if (totalChats === 0) { setTimeout(() => onDismiss(), 0); } - return { ...prev, chats: newChats }; - }); + // Return updated pages (update first page, keep others as is) + return pages.map((page, idx) => { + if (idx === 0 && page) { + return { ...page, chats: mergedChats }; + } + return page; + }); + }, false); - mutate(); // revalidate SWR + setDeletingId(null); } catch (error) { console.error("Failed to delete chats:", error); - } finally { setDeletingId(null); } }, DELETE_ANIMATION_DURATION); @@ -331,243 +915,106 @@ export default function ChatPalette({ className, hidden: hiddenOuter, onDismiss // Handle delete with animation const handleDelete = async (chatId: string) => { setPendingDeleteId(null); - // Find index of chat to be deleted - const idxToDelete = localChats.chats.findIndex(c => c.id === chatId); 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 (idxToDelete === selected[0]) { - let newIdx = idxToDelete; - if (newIdx >= newChats.length) newIdx = newChats.length - 1; - setSelected([Math.max(0, newIdx), 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 - } - return { ...prev, chats: newChats }; - }); - 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); - } - } + mutate((pages) => { + if (!pages) return pages; + // Merge all pages' chats into a single Map + const mergedChats = new Map(); + pages.forEach(pageData => { + if (!pageData) return; + pageData.chats.forEach((chats, section) => { + if (!mergedChats.has(section)) { + mergedChats.set(section, []); } - return { - ...chats, - chats: mergedChats - }; + mergedChats.get(section)!.push(...chats); }); - 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.platform.toLowerCase().includes("mac")) { - setShortcutLabel("CMD+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]); - } - }, [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 - } + // Remove chat from the correct section + if (sectionKey) { + const chats = mergedChats.get(sectionKey) || []; + chats.splice(chatIdx, 1); + if (chats.length === 0) { + mergedChats.delete(sectionKey); + } else { + mergedChats.set(sectionKey, chats); + } + } - // 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]); + // 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 = mergedChats.get(sectionKey || "") || []; + let newSectionIdx = sectionIdx; + let newChatIdx = selected[0][1]; + const mergedSectionKeys = Array.from(mergedChats.keys()); + if (newChatIdx >= chatsInSection.length) { + newChatIdx = chatsInSection.length - 1; + if (newChatIdx < 0 && mergedSectionKeys.length > 1) { + // Move to previous section if exists + newSectionIdx = Math.max(0, sectionIdx - 1); + const prevSectionChats = mergedChats.get(mergedSectionKeys[newSectionIdx]) || []; + newChatIdx = prevSectionChats.length - 1; + } + } + setSelected([[Math.max(0, newSectionIdx), Math.max(0, newChatIdx)], 0]); + } - // 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; + // Dismiss palette if this was the last chat + const totalChats = Array.from(mergedChats.values()).reduce((acc, arr) => acc + arr.length, 0); + if (totalChats === 0) { + setTimeout(() => onDismiss(), 0); } - 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 updated pages (update first page, keep others as is) + return pages.map((page, idx) => { + if (idx === 0 && page) { + return { ...page, chats: mergedChats }; + } + return page; + }); + }, false); + + setDeletingId(null); + mutate(); // revalidate SWR + }, DELETE_ANIMATION_DURATION); + }; return ( <> -
{ - onDismiss(); - }} - > - . -
-
+ className={`z-75 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() : {}} > -
+
-
-
- {shortcutLabel} + setSearchQuery(e.currentTarget.value)} value={searchQuery} autoFocus={!isTouchDevice} id="search" className="w-full outline-none text-neutral-50/80" />
+ + {!isTouchDevice && ( + + {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..b90cb1e --- /dev/null +++ b/src/app/components/ChatPalette/ChatItem.tsx @@ -0,0 +1,496 @@ +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; + isTouchDevice?: boolean; +} + +export default function ChatItem({ + chat, + idx, + section, + isSelected = false, + isBulkSelected = false, + bulkDeleteMode = false, + onRenameTrigger, + onRename, + onRenameCancel, + onDeleteTrigger, + onDelete, + onPinUpdate, + onRenameInput, + renameId, + pendingDeleteId, + deletingId, + isTouchDevice = false, +}: 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 ? ( + isTouchDevice ? ( +
+ +
+ ) : ( + + ) + ) : ( +
+ + + + + + +
+ )} + +
+ {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 min-w-0" + > + { + 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 */} + {isTouchDevice ? ( +
+ +
+ ) : ( + + )} + + {/* delete */} + {isTouchDevice ? ( +
+ +
+ ) : ( + + )} +
+ )} + + ); +} 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( <>