From cb8ea935fa4a03cf54f0f6e0b08a096bcab1af2c Mon Sep 17 00:00:00 2001 From: James Magahern Date: Thu, 28 May 2026 22:22:55 -0700 Subject: [PATCH] adds the ability to rename chats --- docs/api/rest.md | 5 +- .../Sybil/Sources/Sybil/SybilAPIClient.swift | 14 ++ .../Sources/Sybil/SybilAPIClienting.swift | 1 + .../Sources/Sybil/SybilSidebarView.swift | 126 ++++++++----- .../Sybil/Sources/Sybil/SybilViewModel.swift | 34 ++++ .../Sybil/Tests/SybilTests/SybilTests.swift | 48 +++++ server/src/routes.ts | 9 +- tui/src/api.ts | 8 + tui/src/index.ts | 100 +++++++++-- web/src/App.tsx | 169 +++++++++++++++++- 10 files changed, 455 insertions(+), 59 deletions(-) diff --git a/docs/api/rest.md b/docs/api/rest.md index d225143..d1624bd 100644 --- a/docs/api/rest.md +++ b/docs/api/rest.md @@ -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 }` diff --git a/ios/Packages/Sybil/Sources/Sybil/SybilAPIClient.swift b/ios/Packages/Sybil/Sources/Sybil/SybilAPIClient.swift index fdc4eb5..7cfb4b3 100644 --- a/ios/Packages/Sybil/Sources/Sybil/SybilAPIClient.swift +++ b/ios/Packages/Sybil/Sources/Sybil/SybilAPIClient.swift @@ -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? diff --git a/ios/Packages/Sybil/Sources/Sybil/SybilAPIClienting.swift b/ios/Packages/Sybil/Sources/Sybil/SybilAPIClienting.swift index 33c2a15..5e46f5a 100644 --- a/ios/Packages/Sybil/Sources/Sybil/SybilAPIClienting.swift +++ b/ios/Packages/Sybil/Sources/Sybil/SybilAPIClienting.swift @@ -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] diff --git a/ios/Packages/Sybil/Sources/Sybil/SybilSidebarView.swift b/ios/Packages/Sybil/Sources/Sybil/SybilSidebarView.swift index 698afc4..436522b 100644 --- a/ios/Packages/Sybil/Sources/Sybil/SybilSidebarView.swift +++ b/ios/Packages/Sybil/Sources/Sybil/SybilSidebarView.swift @@ -111,56 +111,100 @@ 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 { + Binding { + renameTarget != nil + } set: { isPresented in + if !isPresented { + renameTarget = nil + renameTitle = "" + } + } + } var body: some View { - if viewModel.isLoadingCollections && viewModel.sidebarItems.isEmpty { - VStack(alignment: .leading, spacing: 8) { - ProgressView() - .tint(SybilTheme.primary) - Text("Loading conversations…") - .font(.sybil(.footnote)) - .foregroundStyle(SybilTheme.textMuted) - } - .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading) - .padding(16) - } else if viewModel.sidebarItems.isEmpty { - VStack(spacing: 10) { - Image(systemName: "message.badge") - .font(.system(size: 20, weight: .medium)) - .foregroundStyle(SybilTheme.textMuted) - Text("Start a chat or run your first search.") - .font(.sybil(.footnote)) - .multilineTextAlignment(.center) - .foregroundStyle(SybilTheme.textMuted) - } - .frame(maxWidth: .infinity, maxHeight: .infinity) - .padding(16) - } else { - ScrollView { - LazyVStack(alignment: .leading, spacing: 8) { - ForEach(viewModel.sidebarItems) { item in - Button { - onSelect(item) - } label: { - SybilSidebarRow(item: item, isSelected: isSelected(item)) - } - .buttonStyle(.plain) - .contextMenu { - Button(role: .destructive) { - Task { - await viewModel.deleteItem(item.selection) - } + Group { + if viewModel.isLoadingCollections && viewModel.sidebarItems.isEmpty { + VStack(alignment: .leading, spacing: 8) { + ProgressView() + .tint(SybilTheme.primary) + Text("Loading conversations…") + .font(.sybil(.footnote)) + .foregroundStyle(SybilTheme.textMuted) + } + .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading) + .padding(16) + } else if viewModel.sidebarItems.isEmpty { + VStack(spacing: 10) { + Image(systemName: "message.badge") + .font(.system(size: 20, weight: .medium)) + .foregroundStyle(SybilTheme.textMuted) + Text("Start a chat or run your first search.") + .font(.sybil(.footnote)) + .multilineTextAlignment(.center) + .foregroundStyle(SybilTheme.textMuted) + } + .frame(maxWidth: .infinity, maxHeight: .infinity) + .padding(16) + } else { + ScrollView { + LazyVStack(alignment: .leading, spacing: 8) { + ForEach(viewModel.sidebarItems) { item in + Button { + onSelect(item) } label: { - Label("Delete", systemImage: "trash") + SybilSidebarRow(item: item, isSelected: isSelected(item)) + } + .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) + } + } label: { + Label("Delete", systemImage: "trash") + } } } } + .padding(10) + } + .refreshable { + await viewModel.refreshSidebarCollectionsFromPullToRefresh() } - .padding(10) } - .refreshable { - await viewModel.refreshSidebarCollectionsFromPullToRefresh() + } + .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) } } } diff --git a/ios/Packages/Sybil/Sources/Sybil/SybilViewModel.swift b/ios/Packages/Sybil/Sources/Sybil/SybilViewModel.swift index cb566ca..f0e14c6 100644 --- a/ios/Packages/Sybil/Sources/Sybil/SybilViewModel.swift +++ b/ios/Packages/Sybil/Sources/Sybil/SybilViewModel.swift @@ -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() diff --git a/ios/Packages/Sybil/Tests/SybilTests/SybilTests.swift b/ios/Packages/Sybil/Tests/SybilTests/SybilTests.swift index b58c0b3..198688b 100644 --- a/ios/Packages/Sybil/Tests/SybilTests/SybilTests.swift +++ b/ios/Packages/Sybil/Tests/SybilTests/SybilTests.swift @@ -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) diff --git a/server/src/routes.ts b/server/src/routes.ts index 4a58109..e41712b 100644 --- a/server/src/routes.ts +++ b/server/src/routes.ts @@ -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) }; }); diff --git a/tui/src/api.ts b/tui/src/api.ts index 2a81506..4266fe8 100644 --- a/tui/src/api.ts +++ b/tui/src/api.ts @@ -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", diff --git a/tui/src/index.ts b/tui/src/index.ts index 59c4fad..75655fc 100644 --- a/tui/src/index.ts +++ b/tui/src/index.ts @@ -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((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(); diff --git a/web/src/App.tsx b/web/src/App.tsx index 3caed39..4776acd 100644 --- a/web/src/App.tsx +++ b/web/src/App.tsx @@ -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(null); const [error, setError] = useState(null); + const [renameChatDialog, setRenameChatDialog] = useState(null); + const [renameChatDraft, setRenameChatDraft] = useState(""); + const [renameChatError, setRenameChatError] = useState(null); + const [isRenamingChat, setIsRenamingChat] = useState(false); const [transcriptTailSpacerHeight, setTranscriptTailSpacerHeight] = useState(TRANSCRIPT_BOTTOM_GAP); const transcriptContainerRef = useRef(null); const transcriptEndRef = useRef(null); const contextMenuRef = useRef(null); + const renameChatInputRef = useRef(null); const fileInputRef = useRef(null); const dragDepthRef = useRef(0); const pendingAttachmentsRef = useRef([]); @@ -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() { -
-

{selectedTitle}

+
+

{selectedTitle}

+ {draftKind === null && selectedItem?.kind === "chat" ? ( + + ) : null}
@@ -2783,6 +2877,16 @@ export default function App() { style={{ left: contextMenu.x, top: contextMenu.y }} onContextMenu={(event) => event.preventDefault()} > + {contextMenu.item.kind === "chat" ? ( + + ) : null}
) : null} + {renameChatDialog ? ( +
{ + if (event.target === event.currentTarget && !isRenamingChat) setRenameChatDialog(null); + }} + > +
void handleRenameChatSubmit(event)} + > +
+

+ Rename chat +

+ +
+ { + 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 ?

{renameChatError}

: null} +
+ + +
+
+
+ ) : null} {isQuickQuestionOpen ? (