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 (
+
+ );
+}
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: () => {}