adds the ability to rename chats

This commit is contained in:
2026-05-28 22:22:55 -07:00
parent f79e5e02c5
commit cb8ea935fa
10 changed files with 455 additions and 59 deletions

View File

@@ -1,5 +1,5 @@
import { useEffect, useMemo, useRef, useState } from "preact/hooks";
import { Check, ChevronDown, Globe2, LoaderCircle, Menu, MessageSquare, Paperclip, Plus, Rabbit, Search, SendHorizontal, Trash2, X } from "lucide-preact";
import { Check, ChevronDown, Globe2, LoaderCircle, Menu, MessageSquare, Paperclip, Pencil, Plus, Rabbit, Search, SendHorizontal, Trash2, X } from "lucide-preact";
import { Button } from "@/components/ui/button";
import { Textarea } from "@/components/ui/textarea";
import { Separator } from "@/components/ui/separator";
@@ -24,6 +24,7 @@ import {
runCompletionStream,
runSearchStream,
suggestChatTitle,
updateChatTitle,
getMessageAttachments,
type ChatAttachment,
type ActiveRunsResponse,
@@ -57,6 +58,9 @@ type ContextMenuState = {
x: number;
y: number;
};
type RenameChatDialogState = {
chatId: string;
};
type PendingChatState = {
messages: Message[];
};
@@ -752,10 +756,15 @@ export default function App() {
const [isConvertingQuickQuestion, setIsConvertingQuickQuestion] = useState(false);
const [quickQuestionError, setQuickQuestionError] = useState<string | null>(null);
const [error, setError] = useState<string | null>(null);
const [renameChatDialog, setRenameChatDialog] = useState<RenameChatDialogState | null>(null);
const [renameChatDraft, setRenameChatDraft] = useState("");
const [renameChatError, setRenameChatError] = useState<string | null>(null);
const [isRenamingChat, setIsRenamingChat] = useState(false);
const [transcriptTailSpacerHeight, setTranscriptTailSpacerHeight] = useState(TRANSCRIPT_BOTTOM_GAP);
const transcriptContainerRef = useRef<HTMLDivElement>(null);
const transcriptEndRef = useRef<HTMLDivElement>(null);
const contextMenuRef = useRef<HTMLDivElement>(null);
const renameChatInputRef = useRef<HTMLInputElement>(null);
const fileInputRef = useRef<HTMLInputElement>(null);
const dragDepthRef = useRef(0);
const pendingAttachmentsRef = useRef<ChatAttachment[]>([]);
@@ -882,6 +891,11 @@ export default function App() {
setQuickSubmittedModelSelection(null);
setQuickQuestionMessages([]);
setQuickQuestionError(null);
setContextMenu(null);
setRenameChatDialog(null);
setRenameChatDraft("");
setRenameChatError(null);
setIsRenamingChat(false);
setError(null);
};
@@ -1377,16 +1391,74 @@ export default function App() {
return () => window.removeEventListener("keydown", handleKeyDown);
}, [filteredSidebarItems, isAuthenticated, isQuickQuestionOpen]);
const getRenameSeedTitle = (chatId: string) => {
if (selectedChat?.id === chatId) return getChatTitle(selectedChat, selectedChat.messages);
const summary = chats.find((chat) => chat.id === chatId);
if (summary) return getChatTitle(summary);
const sidebarItem = sidebarItems.find((item) => item.kind === "chat" && item.id === chatId);
return sidebarItem?.title ?? "New chat";
};
const openRenameChatDialog = (chatId: string) => {
setContextMenu(null);
setRenameChatDraft(getRenameSeedTitle(chatId));
setRenameChatError(null);
setRenameChatDialog({ chatId });
};
const openContextMenu = (event: MouseEvent, item: SidebarSelection) => {
event.preventDefault();
const menuWidth = 160;
const menuHeight = 40;
const menuWidth = 176;
const menuHeight = item.kind === "chat" ? 80 : 40;
const padding = 8;
const x = Math.min(event.clientX, window.innerWidth - menuWidth - padding);
const y = Math.min(event.clientY, window.innerHeight - menuHeight - padding);
setContextMenu({ item, x: Math.max(padding, x), y: Math.max(padding, y) });
};
const handleRenameChatSubmit = async (event?: Event) => {
event?.preventDefault();
if (!renameChatDialog || isRenamingChat) return;
const title = renameChatDraft.trim();
if (!title) {
setRenameChatError("Enter a chat title.");
return;
}
setIsRenamingChat(true);
setRenameChatError(null);
setError(null);
try {
const updatedChat = await updateChatTitle(renameChatDialog.chatId, title);
setChats((current) => [updatedChat, ...current.filter((chat) => chat.id !== updatedChat.id)]);
setWorkspaceItems((current) => upsertWorkspaceItem(current, chatWorkspaceItem(updatedChat)));
setSelectedChat((current) => {
if (!current || current.id !== updatedChat.id) return current;
return {
...current,
title: updatedChat.title,
updatedAt: updatedChat.updatedAt,
initiatedProvider: updatedChat.initiatedProvider,
initiatedModel: updatedChat.initiatedModel,
lastUsedProvider: updatedChat.lastUsedProvider,
lastUsedModel: updatedChat.lastUsedModel,
};
});
setRenameChatDialog(null);
setRenameChatDraft("");
} catch (err) {
const message = err instanceof Error ? err.message : String(err);
if (message.includes("bearer token")) {
handleAuthFailure(message);
} else {
setRenameChatError(message);
}
} finally {
setIsRenamingChat(false);
}
};
const handleDeleteFromContextMenu = async () => {
if (!contextMenu || isItemRunning(contextMenu.item)) return;
const target = contextMenu.item;
@@ -1426,6 +1498,15 @@ export default function App() {
};
}, [contextMenu]);
useEffect(() => {
if (!renameChatDialog) return;
const timer = window.setTimeout(() => {
renameChatInputRef.current?.focus();
renameChatInputRef.current?.select();
}, 0);
return () => window.clearTimeout(timer);
}, [renameChatDialog]);
useEffect(() => {
if (!isQuickQuestionOpen) return;
const handleKeyDown = (event: KeyboardEvent) => {
@@ -2610,8 +2691,21 @@ export default function App() {
<Menu className="h-4 w-4" />
</Button>
<div>
<h1 className="text-sm font-semibold text-violet-50 md:text-base">{selectedTitle}</h1>
<div className="flex min-w-0 items-center gap-1.5">
<h1 className="truncate text-sm font-semibold text-violet-50 md:text-base">{selectedTitle}</h1>
{draftKind === null && selectedItem?.kind === "chat" ? (
<Button
type="button"
size="icon"
variant="ghost"
className="h-7 w-7 shrink-0 text-violet-100/72 hover:text-violet-50"
onClick={() => openRenameChatDialog(selectedItem.id)}
title="Rename chat"
aria-label="Rename chat"
>
<Pencil className="h-3.5 w-3.5" />
</Button>
) : null}
</div>
</div>
<div className="flex w-full max-w-xl items-center gap-2 md:w-auto">
@@ -2783,6 +2877,16 @@ export default function App() {
style={{ left: contextMenu.x, top: contextMenu.y }}
onContextMenu={(event) => event.preventDefault()}
>
{contextMenu.item.kind === "chat" ? (
<button
type="button"
className="flex w-full items-center gap-2 rounded-md px-2 py-1.5 text-left text-sm text-violet-100 transition hover:bg-violet-400/12"
onClick={() => openRenameChatDialog(contextMenu.item.id)}
>
<Pencil className="h-3.5 w-3.5" />
Rename
</button>
) : null}
<button
type="button"
className="flex w-full items-center gap-2 rounded-md px-2 py-1.5 text-left text-sm text-rose-300 transition hover:bg-rose-500/12 disabled:text-muted-foreground"
@@ -2794,6 +2898,61 @@ export default function App() {
</button>
</div>
) : null}
{renameChatDialog ? (
<div
className="fixed inset-0 z-[60] flex items-center justify-center bg-black/72 p-3 backdrop-blur-md md:p-6"
onMouseDown={(event) => {
if (event.target === event.currentTarget && !isRenamingChat) setRenameChatDialog(null);
}}
>
<form
role="dialog"
aria-modal="true"
aria-labelledby="rename-chat-title"
className="glass-panel w-full max-w-md rounded-2xl border border-violet-300/24 p-4 shadow-2xl shadow-black/45 md:p-5"
onSubmit={(event) => void handleRenameChatSubmit(event)}
>
<div className="mb-4 flex items-center justify-between gap-3">
<h2 id="rename-chat-title" className="text-sm font-semibold text-violet-50">
Rename chat
</h2>
<Button
type="button"
size="icon"
variant="ghost"
className="h-8 w-8"
onClick={() => setRenameChatDialog(null)}
disabled={isRenamingChat}
aria-label="Close rename dialog"
>
<X className="h-4 w-4" />
</Button>
</div>
<input
ref={renameChatInputRef}
value={renameChatDraft}
onInput={(event) => {
setRenameChatDraft(event.currentTarget.value);
if (renameChatError) setRenameChatError(null);
}}
maxLength={120}
className="h-11 w-full rounded-lg border border-violet-300/22 bg-background/72 px-3 text-sm text-violet-50 outline-none shadow-[inset_0_1px_0_hsl(255_100%_92%_/_0.06)] placeholder:text-muted-foreground focus:border-violet-300/45 focus:ring-1 focus:ring-ring/70"
aria-label="Chat title"
disabled={isRenamingChat}
/>
{renameChatError ? <p className="mt-2 text-sm text-rose-300">{renameChatError}</p> : null}
<div className="mt-4 flex justify-end gap-2">
<Button type="button" variant="secondary" onClick={() => setRenameChatDialog(null)} disabled={isRenamingChat}>
Cancel
</Button>
<Button type="submit" disabled={isRenamingChat || !renameChatDraft.trim()}>
{isRenamingChat ? <LoaderCircle className="h-4 w-4 animate-spin" /> : <Check className="h-4 w-4" />}
Save
</Button>
</div>
</form>
</div>
) : null}
{isQuickQuestionOpen ? (
<div
className="fixed inset-0 z-[60] flex items-center justify-center bg-black/72 p-3 backdrop-blur-md md:p-6"