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

@@ -126,6 +126,8 @@ Behavior notes:
### `PATCH /v1/chats/:chatId`
- Body: `{ "title": string }`
- Response: `{ "chat": ChatSummary }`
- Blank titles are rejected. The server trims surrounding whitespace before storing the title.
- Renaming updates the returned chat's `updatedAt`.
- Not found: `404 { "message": "chat not found" }`
### `POST /v1/chats/title/suggest`
@@ -140,7 +142,8 @@ Behavior notes:
Behavior notes:
- If the chat already has a non-empty title, server returns the existing chat unchanged.
- Server always uses OpenAI `gpt-4.1-mini` to generate a one-line title (up to ~4 words), updates the chat title, and returns the updated chat.
- If a title is set while suggestion generation is in flight, server returns the current chat instead of overwriting that title.
- When no title exists at write time, server uses OpenAI `gpt-4.1-mini` to generate a one-line title (up to ~4 words), updates the chat title, and returns the updated chat.
### `DELETE /v1/chats/:chatId`
- Response: `{ "deleted": true }`

View File

@@ -74,6 +74,16 @@ actor SybilAPIClient: SybilAPIClienting {
return response.chat
}
func updateChatTitle(chatID: String, title: String) async throws -> ChatSummary {
let response = try await request(
"/v1/chats/\(chatID)",
method: "PATCH",
body: AnyEncodable(ChatTitleUpdateBody(title: title)),
responseType: ChatCreateResponse.self
)
return response.chat
}
func deleteChat(chatID: String) async throws {
_ = try await request("/v1/chats/\(chatID)", method: "DELETE", responseType: DeleteResponse.self)
}
@@ -640,6 +650,10 @@ private struct ChatCreateBody: Encodable {
var messages: [CompletionRequestMessage]?
}
private struct ChatTitleUpdateBody: Encodable {
var title: String
}
private struct SearchCreateBody: Encodable {
var title: String?
var query: String?

View File

@@ -11,6 +11,7 @@ protocol SybilAPIClienting: Sendable {
messages: [CompletionRequestMessage]?
) async throws -> ChatSummary
func getChat(chatID: String) async throws -> ChatDetail
func updateChatTitle(chatID: String, title: String) async throws -> ChatSummary
func deleteChat(chatID: String) async throws
func suggestChatTitle(chatID: String, content: String) async throws -> ChatSummary
func listSearches() async throws -> [SearchSummary]

View File

@@ -111,8 +111,22 @@ struct SybilSidebarItemList: View {
@Bindable var viewModel: SybilViewModel
var isSelected: (SidebarItem) -> Bool
var onSelect: (SidebarItem) -> Void
@State private var renameTarget: SidebarItem?
@State private var renameTitle = ""
private var isRenameAlertPresented: Binding<Bool> {
Binding {
renameTarget != nil
} set: { isPresented in
if !isPresented {
renameTarget = nil
renameTitle = ""
}
}
}
var body: some View {
Group {
if viewModel.isLoadingCollections && viewModel.sidebarItems.isEmpty {
VStack(alignment: .leading, spacing: 8) {
ProgressView()
@@ -146,6 +160,15 @@ struct SybilSidebarItemList: View {
}
.buttonStyle(.plain)
.contextMenu {
if item.kind == .chat {
Button {
renameTarget = item
renameTitle = item.title
} label: {
Label("Rename", systemImage: "pencil")
}
}
Button(role: .destructive) {
Task {
await viewModel.deleteItem(item.selection)
@@ -163,6 +186,27 @@ struct SybilSidebarItemList: View {
}
}
}
.alert("Rename Chat", isPresented: isRenameAlertPresented) {
TextField("Title", text: $renameTitle)
Button("Cancel", role: .cancel) {
renameTarget = nil
renameTitle = ""
}
Button("Save") {
let target = renameTarget
let title = renameTitle
renameTarget = nil
renameTitle = ""
if let target, case let .chat(chatID) = target.selection {
Task {
await viewModel.renameChat(chatID: chatID, title: title)
}
}
}
.disabled(renameTitle.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty)
}
}
}
struct SybilSidebarRow: View {

View File

@@ -851,6 +851,40 @@ final class SybilViewModel {
}
}
func renameChat(chatID: String, title: String) async {
guard isAuthenticated else {
return
}
let trimmedTitle = title.trimmingCharacters(in: .whitespacesAndNewlines)
guard !trimmedTitle.isEmpty else {
errorMessage = "Enter a chat title."
return
}
SybilLog.info(SybilLog.ui, "Renaming chat \(chatID)")
errorMessage = nil
do {
let updated = try await client().updateChatTitle(chatID: chatID, title: trimmedTitle)
chats.removeAll(where: { $0.id == updated.id })
chats.insert(updated, at: 0)
upsertWorkspaceChat(updated)
if selectedChat?.id == updated.id {
selectedChat?.title = updated.title
selectedChat?.updatedAt = updated.updatedAt
selectedChat?.initiatedProvider = updated.initiatedProvider
selectedChat?.initiatedModel = updated.initiatedModel
selectedChat?.lastUsedProvider = updated.lastUsedProvider
selectedChat?.lastUsedModel = updated.lastUsedModel
}
} catch {
errorMessage = normalizeAPIError(error)
SybilLog.error(SybilLog.ui, "Rename failed", error: error)
}
}
func refreshAfterSettingsChange() async {
SybilLog.info(SybilLog.ui, "Settings changed, reconnecting")
settings.persist()

View File

@@ -9,6 +9,7 @@ private struct MockClientCallSnapshot: Sendable {
var listSearches = 0
var createChat = 0
var getChat = 0
var updateChatTitle = 0
var getSearch = 0
var getActiveRuns = 0
var runCompletionStream = 0
@@ -32,6 +33,7 @@ private actor MockSybilClient: SybilAPIClienting {
private let chatDetails: [String: ChatDetail]
private let searchDetails: [String: SearchDetail]
private let createChatResponse: ChatSummary?
private let updateChatTitleResponses: [String: ChatSummary]
private let activeRunsResponse: ActiveRunsResponse
private var snapshot = MockClientCallSnapshot()
@@ -57,6 +59,7 @@ private actor MockSybilClient: SybilAPIClienting {
chatDetails: [String: ChatDetail] = [:],
searchDetails: [String: SearchDetail] = [:],
createChatResponse: ChatSummary? = nil,
updateChatTitleResponses: [String: ChatSummary] = [:],
activeRunsResponse: ActiveRunsResponse = ActiveRunsResponse(),
workspaceItemsResponse: [WorkspaceItem]? = nil
) {
@@ -66,6 +69,7 @@ private actor MockSybilClient: SybilAPIClienting {
self.chatDetails = chatDetails
self.searchDetails = searchDetails
self.createChatResponse = createChatResponse
self.updateChatTitleResponses = updateChatTitleResponses
self.activeRunsResponse = activeRunsResponse
}
@@ -182,6 +186,14 @@ private actor MockSybilClient: SybilAPIClienting {
return detail
}
func updateChatTitle(chatID: String, title: String) async throws -> ChatSummary {
snapshot.updateChatTitle += 1
guard let summary = updateChatTitleResponses[chatID] else {
throw UnexpectedClientCall()
}
return summary
}
func deleteChat(chatID: String) async throws {
throw UnexpectedClientCall()
}
@@ -461,6 +473,42 @@ private func makeSearchDetail(id: String, date: Date, answer: String) -> SearchD
#expect(viewModel.selectedChat?.messages.first?.content == "refreshed transcript")
}
@MainActor
@Test func renameChatUpdatesSidebarAndSelectedTranscriptTitle() async throws {
let date = Date(timeIntervalSince1970: 1_700_000_150)
let original = makeChatSummary(id: "chat-rename", date: date)
let renamed = ChatSummary(
id: "chat-rename",
title: "Renamed chat",
createdAt: date,
updatedAt: date.addingTimeInterval(60),
initiatedProvider: .openai,
initiatedModel: "gpt-4.1-mini",
lastUsedProvider: .openai,
lastUsedModel: "gpt-4.1-mini"
)
let detail = makeChatDetail(id: "chat-rename", date: date, body: "existing transcript")
let client = MockSybilClient(
chatsResponse: [original],
updateChatTitleResponses: ["chat-rename": renamed]
)
let viewModel = SybilViewModel(settings: testSettings(named: #function)) { _ in client }
viewModel.isAuthenticated = true
viewModel.isCheckingSession = false
viewModel.chats = [original]
viewModel.workspaceItems = [WorkspaceItem(chat: original)]
viewModel.selectedItem = .chat("chat-rename")
viewModel.selectedChat = detail
await viewModel.renameChat(chatID: "chat-rename", title: " Renamed chat ")
let snapshot = await client.currentSnapshot()
#expect(snapshot.updateChatTitle == 1)
#expect(viewModel.sidebarItems.first?.title == "Renamed chat")
#expect(viewModel.selectedChat?.title == "Renamed chat")
#expect(viewModel.errorMessage == nil)
}
@MainActor
@Test func foregroundSearchRefreshReloadsSelectedSearch() async throws {
let date = Date(timeIntervalSince1970: 1_700_000_200)

View File

@@ -754,9 +754,13 @@ export async function registerRoutes(app: FastifyInstance) {
const suggestedRaw = await generateChatTitle(body.content);
const title = normalizeSuggestedTitle(suggestedRaw, fallback);
const chat = await prisma.chat.update({
where: { id: body.chatId },
await prisma.chat.updateMany({
where: { id: body.chatId, title: existing.title },
data: { title },
});
const chat = await prisma.chat.findUnique({
where: { id: body.chatId },
select: {
id: true,
title: true,
@@ -768,6 +772,7 @@ export async function registerRoutes(app: FastifyInstance) {
lastUsedModel: true,
},
});
if (!chat) return app.httpErrors.notFound("chat not found");
return { chat: serializeProviderFields(chat) };
});

View File

@@ -60,6 +60,14 @@ export class SybilApiClient {
return data.chat;
}
async updateChatTitle(chatId: string, title: string) {
const data = await this.request<{ chat: ChatSummary }>(`/v1/chats/${chatId}`, {
method: "PATCH",
body: { title },
});
return data.chat;
}
async suggestChatTitle(body: { chatId: string; content: string }) {
const data = await this.request<{ chat: ChatSummary }>("/v1/chats/title/suggest", {
method: "POST",

View File

@@ -254,6 +254,7 @@ async function main() {
let renderedSidebarItems: SidebarItem[] = [];
let renderedSidebarLines: string[] = [];
let suppressedSidebarSelectEvents = 0;
let isRenamePromptOpen = false;
const screen = blessed.screen({
smartCSR: true,
@@ -361,6 +362,26 @@ async function main() {
},
});
const renamePrompt = (blessed as any).prompt({
parent: screen,
label: " Rename chat ",
border: "line",
tags: true,
keys: true,
vi: true,
mouse: true,
top: "center",
left: "center",
width: "50%",
height: "shrink",
hidden: true,
style: {
border: { fg: "cyan" },
label: { fg: "cyan" },
fg: "white",
},
});
const focusables = [sidebar, transcript, composer] as const;
function getTranscriptViewportHeight() {
@@ -680,7 +701,7 @@ async function main() {
const top = `{bold}${escapeTags(getSelectedTitle())}{/bold} {gray-fg}- Sybil TUI${modeLabel}${isSearchMode ? " • Exa Search" : ""}{/gray-fg}`;
let controls =
"{gray-fg}Controls:{/gray-fg} [tab] focus [esc] command mode [↑/↓] highlight [enter] send/select [n] new chat [/] new search [d] delete [q] quit";
"{gray-fg}Controls:{/gray-fg} [tab] focus [esc] command mode [↑/↓] highlight [enter] send/select [n] new chat [/] new search [r] rename [d] delete [C-r] refresh [q] quit";
if (!isSearchMode) {
controls += `\n{gray-fg}Model:{/gray-fg} provider {cyan-fg}${provider}{/cyan-fg} [p] model {cyan-fg}${escapeTags(model)}{/cyan-fg} [m]`;
controls += providerModelOptions.length === 0 ? " {red-fg}(no models){/red-fg}" : "";
@@ -842,6 +863,27 @@ async function main() {
composer.readInput();
}
function shouldIgnoreGlobalShortcut() {
return isRenamePromptOpen || isTextInputFocused(screen, composer);
}
function promptForChatTitle(currentTitle: string) {
isRenamePromptOpen = true;
updateUI();
return new Promise<string | null>((resolve) => {
renamePrompt.input("Title:", currentTitle, (err: Error | null, value: string | null) => {
isRenamePromptOpen = false;
renamePrompt.hide();
screen.render();
if (err || value === null || value === undefined) {
resolve(null);
return;
}
resolve(value);
});
});
}
function cycleFocus(step: 1 | -1) {
const focused = screen.focused;
const currentIndex = focusables.findIndex((node) => node === focused);
@@ -1302,6 +1344,37 @@ async function main() {
await refreshCollections({ loadSelection: true, scrollToBottomOnLoad: true });
}
async function handleRenameSelection() {
if (!selectedItem || selectedItem.kind !== "chat") return;
const chatId = selectedItem.id;
const summary = chats.find((chat) => chat.id === chatId);
const currentTitle = selectedChat?.id === chatId ? getChatTitle(selectedChat, selectedChat.messages) : summary ? getChatTitle(summary) : "New chat";
const value = await promptForChatTitle(currentTitle);
const title = value?.trim();
if (!title) {
updateUI();
return;
}
setError(null);
const updated = await api.updateChatTitle(chatId, title);
chats = [updated, ...chats.filter((chat) => chat.id !== updated.id)];
workspaceItems = upsertWorkspaceItem(workspaceItems, chatWorkspaceItem(updated));
if (selectedChat?.id === updated.id) {
selectedChat = {
...selectedChat,
title: updated.title,
updatedAt: updated.updatedAt,
initiatedProvider: updated.initiatedProvider,
initiatedModel: updated.initiatedModel,
lastUsedProvider: updated.lastUsedProvider,
lastUsedModel: updated.lastUsedModel,
};
}
updateUI();
}
function cycleProvider() {
const visibleProviders = getVisibleProviders(modelCatalog);
const cycleProviders = visibleProviders.length ? visibleProviders : BASE_PROVIDERS;
@@ -1387,18 +1460,18 @@ async function main() {
});
screen.key(["q"], () => {
if (isTextInputFocused(screen, composer)) return;
if (shouldIgnoreGlobalShortcut()) return;
screen.destroy();
process.exit(0);
});
screen.key(["tab"], () => {
if (isTextInputFocused(screen, composer)) return;
if (shouldIgnoreGlobalShortcut()) return;
cycleFocus(1);
});
screen.key(["S-tab", "backtab"], () => {
if (isTextInputFocused(screen, composer)) return;
if (shouldIgnoreGlobalShortcut()) return;
cycleFocus(-1);
});
@@ -1415,36 +1488,43 @@ async function main() {
});
screen.key(["n"], () => {
if (isTextInputFocused(screen, composer)) return;
if (shouldIgnoreGlobalShortcut()) return;
handleCreateChat();
});
screen.key(["/"], () => {
if (isTextInputFocused(screen, composer)) return;
if (shouldIgnoreGlobalShortcut()) return;
handleCreateSearch();
});
screen.key(["d"], () => {
if (isTextInputFocused(screen, composer)) return;
if (shouldIgnoreGlobalShortcut()) return;
void runAction(async () => {
await handleDeleteSelection();
});
});
screen.key(["p"], () => {
if (isTextInputFocused(screen, composer)) return;
if (shouldIgnoreGlobalShortcut()) return;
if (getIsSearchMode() || isSending) return;
cycleProvider();
});
screen.key(["m"], () => {
if (isTextInputFocused(screen, composer)) return;
if (shouldIgnoreGlobalShortcut()) return;
if (getIsSearchMode() || isSending) return;
cycleModel();
});
screen.key(["r"], () => {
if (isTextInputFocused(screen, composer)) return;
if (shouldIgnoreGlobalShortcut()) return;
void runAction(async () => {
await handleRenameSelection();
});
});
screen.key(["C-r"], () => {
if (shouldIgnoreGlobalShortcut()) return;
void runAction(async () => {
await refreshCollections({ loadSelection: true });
await refreshModels();

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"