diff --git a/frontend/src/components/ChatHistoryList.tsx b/frontend/src/components/ChatHistoryList.tsx index beeb0c704..f930d1728 100644 --- a/frontend/src/components/ChatHistoryList.tsx +++ b/frontend/src/components/ChatHistoryList.tsx @@ -1,23 +1,27 @@ +import { useState } from "react"; import { useLocalState } from "@/state/useLocalState"; import { Link } from "@tanstack/react-router"; import { useQuery, useQueryClient } from "@tanstack/react-query"; import { useNavigate } from "@tanstack/react-router"; -import { MoreHorizontal, Trash } from "lucide-react"; +import { MoreHorizontal, Trash, Pencil } from "lucide-react"; import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from "@/components/ui/dropdown-menu"; +import { RenameChatDialog } from "@/components/RenameChatDialog"; interface ChatHistoryListProps { currentChatId?: string; } export function ChatHistoryList({ currentChatId }: ChatHistoryListProps) { - const { fetchOrCreateHistoryList, deleteChat } = useLocalState(); + const { fetchOrCreateHistoryList, deleteChat, renameChat } = useLocalState(); const navigate = useNavigate(); const queryClient = useQueryClient(); + const [isRenameDialogOpen, setIsRenameDialogOpen] = useState(false); + const [selectedChat, setSelectedChat] = useState<{ id: string; title: string } | null>(null); const { isPending, @@ -40,6 +44,23 @@ export function ChatHistoryList({ currentChatId }: ChatHistoryListProps) { } }; + const handleOpenRenameDialog = (chat: { id: string; title: string }) => { + setSelectedChat(chat); + setIsRenameDialogOpen(true); + }; + + const handleRenameChat = async (chatId: string, newTitle: string) => { + try { + await renameChat(chatId, newTitle); + // Invalidate both the chat history list and the specific chat + queryClient.invalidateQueries({ queryKey: ["chatHistory"] }); + queryClient.invalidateQueries({ queryKey: ["chat", chatId] }); + } catch (error) { + console.error("Error renaming chat:", error); + throw error; + } + }; + if (error) { return
{error.message}
; } @@ -77,6 +98,10 @@ export function ChatHistoryList({ currentChatId }: ChatHistoryListProps) { + handleOpenRenameDialog(chat)}> + + Rename Chat + handleDeleteChat(chat.id)}> Delete Chat @@ -87,6 +112,16 @@ export function ChatHistoryList({ currentChatId }: ChatHistoryListProps) {
))} + + {selectedChat && ( + + )} ); } diff --git a/frontend/src/components/RenameChatDialog.tsx b/frontend/src/components/RenameChatDialog.tsx new file mode 100644 index 000000000..840c8087f --- /dev/null +++ b/frontend/src/components/RenameChatDialog.tsx @@ -0,0 +1,127 @@ +import { useState, useEffect, useCallback } from "react"; +import { Button } from "@/components/ui/button"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle +} from "@/components/ui/dialog"; +import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; +import { Alert, AlertDescription } from "@/components/ui/alert"; + +interface RenameChatDialogProps { + open: boolean; + onOpenChange: (open: boolean) => void; + chatId: string; + currentTitle: string; + onRename: (chatId: string, newTitle: string) => Promise; +} + +export function RenameChatDialog({ + open, + onOpenChange, + chatId, + currentTitle, + onRename +}: RenameChatDialogProps) { + const [newTitle, setNewTitle] = useState(""); + const [isLoading, setIsLoading] = useState(false); + const [error, setError] = useState(null); + + // Set the initial value when dialog opens + useEffect(() => { + if (open) { + setNewTitle(currentTitle); + } + }, [open, currentTitle]); + + const resetForm = useCallback(() => { + setNewTitle(currentTitle); + setError(null); + setIsLoading(false); + }, [currentTitle]); + + useEffect(() => { + if (!open) { + resetForm(); + } + }, [open, resetForm]); + + const handleSubmit = async (e: React.FormEvent) => { + e.preventDefault(); + setError(null); + + const trimmedTitle = newTitle.trim(); + + if (!trimmedTitle) { + setError("Chat title cannot be empty."); + return; + } + + // Check if the new title is the same as the current title + if (trimmedTitle === currentTitle.trim()) { + setError("Please enter a different title."); + return; + } + + setIsLoading(true); + try { + await onRename(chatId, trimmedTitle); + // Close dialog immediately on success + onOpenChange(false); + } catch (error) { + console.error("Failed to rename chat:", error); + setError("Failed to rename chat. Please try again."); + } finally { + setIsLoading(false); + } + }; + + return ( + + + + Rename Chat + Enter a new name for this chat conversation. + +
+ {error && ( + + {error} + + )} +
+ + setNewTitle(e.target.value)} + required + autoFocus + onKeyDown={(e) => { + if (e.key === "Enter") { + e.preventDefault(); + // Use requestSubmit for better form validation + const form = e.currentTarget.form; + if (form) form.requestSubmit(); + } + }} + /> +
+ + + + +
+
+
+ ); +} diff --git a/frontend/src/routes/_auth.chat.$chatId.tsx b/frontend/src/routes/_auth.chat.$chatId.tsx index 1d96e504b..70eec932e 100644 --- a/frontend/src/routes/_auth.chat.$chatId.tsx +++ b/frontend/src/routes/_auth.chat.$chatId.tsx @@ -11,6 +11,7 @@ import { Sidebar, SidebarToggle } from "@/components/Sidebar"; import { useQuery, useQueryClient } from "@tanstack/react-query"; import { InfoPopover } from "@/components/InfoPopover"; import { Button } from "@/components/ui/button"; +import { BillingStatus } from "@/billing/billingApi"; export const Route = createFileRoute("/_auth/chat/$chatId")({ component: ChatComponent @@ -162,14 +163,6 @@ function ChatComponent() { // eslint-disable-next-line react-hooks/exhaustive-deps }, [queryChat, chatId, isPending]); - async function generateChatTitle(messages: ChatMessage[]): Promise { - // Find the first user message - const userMessage = messages.find((message) => message.role === "user"); - if (!userMessage) return "New Chat"; - // Use the first 50 characters of the user message - return `${userMessage.content.slice(0, 50)}`; - } - // IMPORTANT that this runs only once (because it uses the user's tokens!) const userPromptEffectRan = useRef(false); @@ -188,6 +181,86 @@ function ChatComponent() { const sendMessage = useCallback( async (input: string) => { + // Helper function to check if the user is on a free plan + function isUserOnFreePlan(): boolean { + try { + const billingStatus = queryClient.getQueryData(["billingStatus"]) as + | BillingStatus + | undefined; + + return ( + !billingStatus || + !billingStatus.product_name || + billingStatus.product_name.toLowerCase().includes("free") + ); + } catch (error) { + console.log("Error checking billing status, defaulting to free plan", error); + return true; // Default to free plan if there's an error + } + } + + async function generateChatTitle(messages: ChatMessage[]): Promise { + // Find the first user message + const userMessage = messages.find((message) => message.role === "user"); + if (!userMessage) return "New Chat"; + + // Simple title generation - truncate first message to 50 chars + const simpleTitleFromMessage = userMessage.content.slice(0, 50).trim(); + + // For free plan users, just use the simple title + // For paid plans, try to generate AI title + if (isUserOnFreePlan()) { + console.log("Using simple title generation for free plan user"); + return simpleTitleFromMessage; + } + + // For paid plans, use LLM to generate a smart title + try { + console.log("Using AI title generation for paid plan user"); + // Get the user's first message, truncate if too long + const userContent = userMessage.content.slice(0, 500); // Reduced to 500 chars to optimize token usage + + // Use the OpenAI API to generate a concise title - use the same model as chat + const stream = openai.beta.chat.completions.stream({ + model: model, // Use the same model that's used for chat + messages: [ + { + role: "system", + content: + "You are a helpful assistant that generates concise, meaningful titles (3-5 words) for chat conversations based on the user's first message. Return only the title without quotes or explanations." + }, + { + role: "user", + content: `Generate a concise, contextual title (3-5 words) for a chat that starts with this message: "${userContent}"` + } + ], + temperature: 0.7, + max_tokens: 15, // Keep response very short + stream: true + }); + + let generatedTitle = ""; + for await (const chunk of stream) { + const content = chunk.choices[0]?.delta?.content || ""; + generatedTitle += content; + } + + // Get the final completion + await stream.finalChatCompletion(); + + // Remove quotes if present and limit length + const cleanTitle = generatedTitle + .replace(/^["']|["']$/g, "") // Remove surrounding quotes if present + .replace(/\n/g, " ") // Remove new lines + .trim(); + + return cleanTitle || simpleTitleFromMessage; // Fallback to simple title if generation fails + } catch (error) { + console.error("Failed to generate chat title:", error); + // Fallback to simple title method + return simpleTitleFromMessage; + } + } if (!input.trim() || !localChat) return; setError(""); @@ -209,6 +282,42 @@ function ChatComponent() { setIsLoading(true); try { + // Start title generation early for paid users if needed + let titleGenerationPromise; + let title = localChat.title; + + if (title === "New Chat") { + const isFreePlan = isUserOnFreePlan(); + + if (!isFreePlan) { + console.log("Starting async AI title generation for paid user's chat"); + // Start title generation in parallel for paid users + titleGenerationPromise = generateChatTitle(newMessages).then((newTitle) => { + // Clean up the title + const cleanTitle = newTitle.replace(/"/g, "").replace(/\n/g, " "); + + // Update local chat with generated title immediately when available + setLocalChat((prev) => ({ + ...prev, + title: cleanTitle + })); + + return cleanTitle; + }); + } else { + console.log("Using simple title for free user's chat"); + // For free users, set the title synchronously + const newTitle = await generateChatTitle(newMessages); + title = newTitle.replace(/"/g, "").replace(/\n/g, " "); + + setLocalChat((prev) => ({ + ...prev, + title + })); + } + } + + // Stream the chat response (happens in parallel with title generation) const stream = openai.beta.chat.completions.stream({ model, messages: newMessages, @@ -260,15 +369,9 @@ function ChatComponent() { }); } - let title = localChat.title; - - // Generate and update the chat title, if the current title isn't "New Chat" - if (title === "New Chat") { - console.log("Generating chat title"); - const newTitle = await generateChatTitle(finalMessages); - - // Get rid of quotes and any newlines in the title - title = newTitle.replace(/"/g, "").replace(/\n/g, " "); + // Wait for title generation to complete if we started it + if (titleGenerationPromise) { + title = await titleGenerationPromise; } const chatCompletion = await stream.finalChatCompletion(); @@ -278,13 +381,22 @@ function ChatComponent() { setUserPrompt(""); // React sucks and doesn't get the latest state - await persistChat({ ...localChat, title, messages: finalMessages }); + // Use current title from localChat which may have been updated asynchronously + const currentTitle = localChat.title === "New Chat" ? title : localChat.title; + await persistChat({ ...localChat, title: currentTitle, messages: finalMessages }); + // Invalidate chat history to show the new title in the sidebar queryClient.invalidateQueries({ queryKey: ["chatHistory"], refetchType: "all" }); + // Invalidate current chat query to ensure the title update is reflected + queryClient.invalidateQueries({ + queryKey: ["chat", chatId], + refetchType: "all" + }); + // Only invalidate billing status after everything is complete queryClient.invalidateQueries({ queryKey: ["billingStatus"], @@ -303,7 +415,10 @@ function ChatComponent() { setIsLoading(false); }, - [localChat, model, openai, persistChat, queryClient, setUserPrompt] + // We intentionally don't include freshBillingStatus in the dependency array + // even though it's used in the closure to avoid re-creating the function + // on every billing status change + [localChat, model, openai, persistChat, queryClient, setUserPrompt, chatId] ); return ( diff --git a/frontend/src/state/LocalStateContext.tsx b/frontend/src/state/LocalStateContext.tsx index de134fe4a..60b15b685 100644 --- a/frontend/src/state/LocalStateContext.tsx +++ b/frontend/src/state/LocalStateContext.tsx @@ -161,6 +161,25 @@ export const LocalStateProvider = ({ children }: { children: React.ReactNode }) await put("history_list", JSON.stringify(updatedChatHistory)); } + async function renameChat(chatId: string, newTitle: string) { + try { + // Get the current chat (getChatById already throws if chat not found) + const chat = await getChatById(chatId); + + // Update the chat title + chat.title = newTitle; + + // Save the updated chat + await persistChat(chat); + + // The persistChat function already updates the history list + return; + } catch (error) { + console.error("Error renaming chat:", error); + throw new Error("Error renaming chat"); + } + } + function setDraftMessage(chatId: string, draft: string) { if (!chatId?.trim()) { console.error("Invalid chatId provided to setDraftMessage"); @@ -201,6 +220,7 @@ export const LocalStateProvider = ({ children }: { children: React.ReactNode }) fetchOrCreateHistoryList, clearHistory, deleteChat, + renameChat, draftMessages: localState.draftMessages, setDraftMessage, clearDraftMessage diff --git a/frontend/src/state/LocalStateContextDef.ts b/frontend/src/state/LocalStateContextDef.ts index e0ecd6ba4..9823e64b7 100644 --- a/frontend/src/state/LocalStateContextDef.ts +++ b/frontend/src/state/LocalStateContextDef.ts @@ -25,12 +25,13 @@ export type LocalState = { billingStatus: BillingStatus | null; setBillingStatus: (status: BillingStatus) => void; setUserPrompt: (prompt: string) => void; - addChat: () => Promise; + addChat: (title?: string) => Promise; getChatById: (id: string) => Promise; persistChat: (chat: Chat) => Promise; fetchOrCreateHistoryList: () => Promise; clearHistory: () => Promise; deleteChat: (chatId: string) => Promise; + renameChat: (chatId: string, newTitle: string) => Promise; /** Map of chat IDs to their draft messages */ draftMessages: Map; /** Sets a draft message for a specific chat */ @@ -51,6 +52,7 @@ export const LocalStateContext = createContext({ fetchOrCreateHistoryList: async () => [], clearHistory: async () => {}, deleteChat: async () => {}, + renameChat: async () => {}, draftMessages: new Map(), setDraftMessage: () => {}, clearDraftMessage: () => {}