adds the ability to rename chats
This commit is contained in:
@@ -126,6 +126,8 @@ Behavior notes:
|
|||||||
### `PATCH /v1/chats/:chatId`
|
### `PATCH /v1/chats/:chatId`
|
||||||
- Body: `{ "title": string }`
|
- Body: `{ "title": string }`
|
||||||
- Response: `{ "chat": ChatSummary }`
|
- 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" }`
|
- Not found: `404 { "message": "chat not found" }`
|
||||||
|
|
||||||
### `POST /v1/chats/title/suggest`
|
### `POST /v1/chats/title/suggest`
|
||||||
@@ -140,7 +142,8 @@ Behavior notes:
|
|||||||
|
|
||||||
Behavior notes:
|
Behavior notes:
|
||||||
- If the chat already has a non-empty title, server returns the existing chat unchanged.
|
- 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`
|
### `DELETE /v1/chats/:chatId`
|
||||||
- Response: `{ "deleted": true }`
|
- Response: `{ "deleted": true }`
|
||||||
|
|||||||
@@ -74,6 +74,16 @@ actor SybilAPIClient: SybilAPIClienting {
|
|||||||
return response.chat
|
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 {
|
func deleteChat(chatID: String) async throws {
|
||||||
_ = try await request("/v1/chats/\(chatID)", method: "DELETE", responseType: DeleteResponse.self)
|
_ = try await request("/v1/chats/\(chatID)", method: "DELETE", responseType: DeleteResponse.self)
|
||||||
}
|
}
|
||||||
@@ -640,6 +650,10 @@ private struct ChatCreateBody: Encodable {
|
|||||||
var messages: [CompletionRequestMessage]?
|
var messages: [CompletionRequestMessage]?
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private struct ChatTitleUpdateBody: Encodable {
|
||||||
|
var title: String
|
||||||
|
}
|
||||||
|
|
||||||
private struct SearchCreateBody: Encodable {
|
private struct SearchCreateBody: Encodable {
|
||||||
var title: String?
|
var title: String?
|
||||||
var query: String?
|
var query: String?
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ protocol SybilAPIClienting: Sendable {
|
|||||||
messages: [CompletionRequestMessage]?
|
messages: [CompletionRequestMessage]?
|
||||||
) async throws -> ChatSummary
|
) async throws -> ChatSummary
|
||||||
func getChat(chatID: String) async throws -> ChatDetail
|
func getChat(chatID: String) async throws -> ChatDetail
|
||||||
|
func updateChatTitle(chatID: String, title: String) async throws -> ChatSummary
|
||||||
func deleteChat(chatID: String) async throws
|
func deleteChat(chatID: String) async throws
|
||||||
func suggestChatTitle(chatID: String, content: String) async throws -> ChatSummary
|
func suggestChatTitle(chatID: String, content: String) async throws -> ChatSummary
|
||||||
func listSearches() async throws -> [SearchSummary]
|
func listSearches() async throws -> [SearchSummary]
|
||||||
|
|||||||
@@ -111,56 +111,100 @@ struct SybilSidebarItemList: View {
|
|||||||
@Bindable var viewModel: SybilViewModel
|
@Bindable var viewModel: SybilViewModel
|
||||||
var isSelected: (SidebarItem) -> Bool
|
var isSelected: (SidebarItem) -> Bool
|
||||||
var onSelect: (SidebarItem) -> Void
|
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 {
|
var body: some View {
|
||||||
if viewModel.isLoadingCollections && viewModel.sidebarItems.isEmpty {
|
Group {
|
||||||
VStack(alignment: .leading, spacing: 8) {
|
if viewModel.isLoadingCollections && viewModel.sidebarItems.isEmpty {
|
||||||
ProgressView()
|
VStack(alignment: .leading, spacing: 8) {
|
||||||
.tint(SybilTheme.primary)
|
ProgressView()
|
||||||
Text("Loading conversations…")
|
.tint(SybilTheme.primary)
|
||||||
.font(.sybil(.footnote))
|
Text("Loading conversations…")
|
||||||
.foregroundStyle(SybilTheme.textMuted)
|
.font(.sybil(.footnote))
|
||||||
}
|
.foregroundStyle(SybilTheme.textMuted)
|
||||||
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading)
|
}
|
||||||
.padding(16)
|
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading)
|
||||||
} else if viewModel.sidebarItems.isEmpty {
|
.padding(16)
|
||||||
VStack(spacing: 10) {
|
} else if viewModel.sidebarItems.isEmpty {
|
||||||
Image(systemName: "message.badge")
|
VStack(spacing: 10) {
|
||||||
.font(.system(size: 20, weight: .medium))
|
Image(systemName: "message.badge")
|
||||||
.foregroundStyle(SybilTheme.textMuted)
|
.font(.system(size: 20, weight: .medium))
|
||||||
Text("Start a chat or run your first search.")
|
.foregroundStyle(SybilTheme.textMuted)
|
||||||
.font(.sybil(.footnote))
|
Text("Start a chat or run your first search.")
|
||||||
.multilineTextAlignment(.center)
|
.font(.sybil(.footnote))
|
||||||
.foregroundStyle(SybilTheme.textMuted)
|
.multilineTextAlignment(.center)
|
||||||
}
|
.foregroundStyle(SybilTheme.textMuted)
|
||||||
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
}
|
||||||
.padding(16)
|
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||||
} else {
|
.padding(16)
|
||||||
ScrollView {
|
} else {
|
||||||
LazyVStack(alignment: .leading, spacing: 8) {
|
ScrollView {
|
||||||
ForEach(viewModel.sidebarItems) { item in
|
LazyVStack(alignment: .leading, spacing: 8) {
|
||||||
Button {
|
ForEach(viewModel.sidebarItems) { item in
|
||||||
onSelect(item)
|
Button {
|
||||||
} label: {
|
onSelect(item)
|
||||||
SybilSidebarRow(item: item, isSelected: isSelected(item))
|
|
||||||
}
|
|
||||||
.buttonStyle(.plain)
|
|
||||||
.contextMenu {
|
|
||||||
Button(role: .destructive) {
|
|
||||||
Task {
|
|
||||||
await viewModel.deleteItem(item.selection)
|
|
||||||
}
|
|
||||||
} label: {
|
} 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)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 {
|
func refreshAfterSettingsChange() async {
|
||||||
SybilLog.info(SybilLog.ui, "Settings changed, reconnecting")
|
SybilLog.info(SybilLog.ui, "Settings changed, reconnecting")
|
||||||
settings.persist()
|
settings.persist()
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ private struct MockClientCallSnapshot: Sendable {
|
|||||||
var listSearches = 0
|
var listSearches = 0
|
||||||
var createChat = 0
|
var createChat = 0
|
||||||
var getChat = 0
|
var getChat = 0
|
||||||
|
var updateChatTitle = 0
|
||||||
var getSearch = 0
|
var getSearch = 0
|
||||||
var getActiveRuns = 0
|
var getActiveRuns = 0
|
||||||
var runCompletionStream = 0
|
var runCompletionStream = 0
|
||||||
@@ -32,6 +33,7 @@ private actor MockSybilClient: SybilAPIClienting {
|
|||||||
private let chatDetails: [String: ChatDetail]
|
private let chatDetails: [String: ChatDetail]
|
||||||
private let searchDetails: [String: SearchDetail]
|
private let searchDetails: [String: SearchDetail]
|
||||||
private let createChatResponse: ChatSummary?
|
private let createChatResponse: ChatSummary?
|
||||||
|
private let updateChatTitleResponses: [String: ChatSummary]
|
||||||
private let activeRunsResponse: ActiveRunsResponse
|
private let activeRunsResponse: ActiveRunsResponse
|
||||||
|
|
||||||
private var snapshot = MockClientCallSnapshot()
|
private var snapshot = MockClientCallSnapshot()
|
||||||
@@ -57,6 +59,7 @@ private actor MockSybilClient: SybilAPIClienting {
|
|||||||
chatDetails: [String: ChatDetail] = [:],
|
chatDetails: [String: ChatDetail] = [:],
|
||||||
searchDetails: [String: SearchDetail] = [:],
|
searchDetails: [String: SearchDetail] = [:],
|
||||||
createChatResponse: ChatSummary? = nil,
|
createChatResponse: ChatSummary? = nil,
|
||||||
|
updateChatTitleResponses: [String: ChatSummary] = [:],
|
||||||
activeRunsResponse: ActiveRunsResponse = ActiveRunsResponse(),
|
activeRunsResponse: ActiveRunsResponse = ActiveRunsResponse(),
|
||||||
workspaceItemsResponse: [WorkspaceItem]? = nil
|
workspaceItemsResponse: [WorkspaceItem]? = nil
|
||||||
) {
|
) {
|
||||||
@@ -66,6 +69,7 @@ private actor MockSybilClient: SybilAPIClienting {
|
|||||||
self.chatDetails = chatDetails
|
self.chatDetails = chatDetails
|
||||||
self.searchDetails = searchDetails
|
self.searchDetails = searchDetails
|
||||||
self.createChatResponse = createChatResponse
|
self.createChatResponse = createChatResponse
|
||||||
|
self.updateChatTitleResponses = updateChatTitleResponses
|
||||||
self.activeRunsResponse = activeRunsResponse
|
self.activeRunsResponse = activeRunsResponse
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -182,6 +186,14 @@ private actor MockSybilClient: SybilAPIClienting {
|
|||||||
return detail
|
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 {
|
func deleteChat(chatID: String) async throws {
|
||||||
throw UnexpectedClientCall()
|
throw UnexpectedClientCall()
|
||||||
}
|
}
|
||||||
@@ -461,6 +473,42 @@ private func makeSearchDetail(id: String, date: Date, answer: String) -> SearchD
|
|||||||
#expect(viewModel.selectedChat?.messages.first?.content == "refreshed transcript")
|
#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
|
@MainActor
|
||||||
@Test func foregroundSearchRefreshReloadsSelectedSearch() async throws {
|
@Test func foregroundSearchRefreshReloadsSelectedSearch() async throws {
|
||||||
let date = Date(timeIntervalSince1970: 1_700_000_200)
|
let date = Date(timeIntervalSince1970: 1_700_000_200)
|
||||||
|
|||||||
@@ -754,9 +754,13 @@ export async function registerRoutes(app: FastifyInstance) {
|
|||||||
const suggestedRaw = await generateChatTitle(body.content);
|
const suggestedRaw = await generateChatTitle(body.content);
|
||||||
const title = normalizeSuggestedTitle(suggestedRaw, fallback);
|
const title = normalizeSuggestedTitle(suggestedRaw, fallback);
|
||||||
|
|
||||||
const chat = await prisma.chat.update({
|
await prisma.chat.updateMany({
|
||||||
where: { id: body.chatId },
|
where: { id: body.chatId, title: existing.title },
|
||||||
data: { title },
|
data: { title },
|
||||||
|
});
|
||||||
|
|
||||||
|
const chat = await prisma.chat.findUnique({
|
||||||
|
where: { id: body.chatId },
|
||||||
select: {
|
select: {
|
||||||
id: true,
|
id: true,
|
||||||
title: true,
|
title: true,
|
||||||
@@ -768,6 +772,7 @@ export async function registerRoutes(app: FastifyInstance) {
|
|||||||
lastUsedModel: true,
|
lastUsedModel: true,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
if (!chat) return app.httpErrors.notFound("chat not found");
|
||||||
|
|
||||||
return { chat: serializeProviderFields(chat) };
|
return { chat: serializeProviderFields(chat) };
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -60,6 +60,14 @@ export class SybilApiClient {
|
|||||||
return data.chat;
|
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 }) {
|
async suggestChatTitle(body: { chatId: string; content: string }) {
|
||||||
const data = await this.request<{ chat: ChatSummary }>("/v1/chats/title/suggest", {
|
const data = await this.request<{ chat: ChatSummary }>("/v1/chats/title/suggest", {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
|
|||||||
100
tui/src/index.ts
100
tui/src/index.ts
@@ -254,6 +254,7 @@ async function main() {
|
|||||||
let renderedSidebarItems: SidebarItem[] = [];
|
let renderedSidebarItems: SidebarItem[] = [];
|
||||||
let renderedSidebarLines: string[] = [];
|
let renderedSidebarLines: string[] = [];
|
||||||
let suppressedSidebarSelectEvents = 0;
|
let suppressedSidebarSelectEvents = 0;
|
||||||
|
let isRenamePromptOpen = false;
|
||||||
|
|
||||||
const screen = blessed.screen({
|
const screen = blessed.screen({
|
||||||
smartCSR: true,
|
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;
|
const focusables = [sidebar, transcript, composer] as const;
|
||||||
|
|
||||||
function getTranscriptViewportHeight() {
|
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}`;
|
const top = `{bold}${escapeTags(getSelectedTitle())}{/bold} {gray-fg}- Sybil TUI${modeLabel}${isSearchMode ? " • Exa Search" : ""}{/gray-fg}`;
|
||||||
|
|
||||||
let controls =
|
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) {
|
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 += `\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}" : "";
|
controls += providerModelOptions.length === 0 ? " {red-fg}(no models){/red-fg}" : "";
|
||||||
@@ -842,6 +863,27 @@ async function main() {
|
|||||||
composer.readInput();
|
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) {
|
function cycleFocus(step: 1 | -1) {
|
||||||
const focused = screen.focused;
|
const focused = screen.focused;
|
||||||
const currentIndex = focusables.findIndex((node) => node === focused);
|
const currentIndex = focusables.findIndex((node) => node === focused);
|
||||||
@@ -1302,6 +1344,37 @@ async function main() {
|
|||||||
await refreshCollections({ loadSelection: true, scrollToBottomOnLoad: true });
|
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() {
|
function cycleProvider() {
|
||||||
const visibleProviders = getVisibleProviders(modelCatalog);
|
const visibleProviders = getVisibleProviders(modelCatalog);
|
||||||
const cycleProviders = visibleProviders.length ? visibleProviders : BASE_PROVIDERS;
|
const cycleProviders = visibleProviders.length ? visibleProviders : BASE_PROVIDERS;
|
||||||
@@ -1387,18 +1460,18 @@ async function main() {
|
|||||||
});
|
});
|
||||||
|
|
||||||
screen.key(["q"], () => {
|
screen.key(["q"], () => {
|
||||||
if (isTextInputFocused(screen, composer)) return;
|
if (shouldIgnoreGlobalShortcut()) return;
|
||||||
screen.destroy();
|
screen.destroy();
|
||||||
process.exit(0);
|
process.exit(0);
|
||||||
});
|
});
|
||||||
|
|
||||||
screen.key(["tab"], () => {
|
screen.key(["tab"], () => {
|
||||||
if (isTextInputFocused(screen, composer)) return;
|
if (shouldIgnoreGlobalShortcut()) return;
|
||||||
cycleFocus(1);
|
cycleFocus(1);
|
||||||
});
|
});
|
||||||
|
|
||||||
screen.key(["S-tab", "backtab"], () => {
|
screen.key(["S-tab", "backtab"], () => {
|
||||||
if (isTextInputFocused(screen, composer)) return;
|
if (shouldIgnoreGlobalShortcut()) return;
|
||||||
cycleFocus(-1);
|
cycleFocus(-1);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -1415,36 +1488,43 @@ async function main() {
|
|||||||
});
|
});
|
||||||
|
|
||||||
screen.key(["n"], () => {
|
screen.key(["n"], () => {
|
||||||
if (isTextInputFocused(screen, composer)) return;
|
if (shouldIgnoreGlobalShortcut()) return;
|
||||||
handleCreateChat();
|
handleCreateChat();
|
||||||
});
|
});
|
||||||
|
|
||||||
screen.key(["/"], () => {
|
screen.key(["/"], () => {
|
||||||
if (isTextInputFocused(screen, composer)) return;
|
if (shouldIgnoreGlobalShortcut()) return;
|
||||||
handleCreateSearch();
|
handleCreateSearch();
|
||||||
});
|
});
|
||||||
|
|
||||||
screen.key(["d"], () => {
|
screen.key(["d"], () => {
|
||||||
if (isTextInputFocused(screen, composer)) return;
|
if (shouldIgnoreGlobalShortcut()) return;
|
||||||
void runAction(async () => {
|
void runAction(async () => {
|
||||||
await handleDeleteSelection();
|
await handleDeleteSelection();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
screen.key(["p"], () => {
|
screen.key(["p"], () => {
|
||||||
if (isTextInputFocused(screen, composer)) return;
|
if (shouldIgnoreGlobalShortcut()) return;
|
||||||
if (getIsSearchMode() || isSending) return;
|
if (getIsSearchMode() || isSending) return;
|
||||||
cycleProvider();
|
cycleProvider();
|
||||||
});
|
});
|
||||||
|
|
||||||
screen.key(["m"], () => {
|
screen.key(["m"], () => {
|
||||||
if (isTextInputFocused(screen, composer)) return;
|
if (shouldIgnoreGlobalShortcut()) return;
|
||||||
if (getIsSearchMode() || isSending) return;
|
if (getIsSearchMode() || isSending) return;
|
||||||
cycleModel();
|
cycleModel();
|
||||||
});
|
});
|
||||||
|
|
||||||
screen.key(["r"], () => {
|
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 () => {
|
void runAction(async () => {
|
||||||
await refreshCollections({ loadSelection: true });
|
await refreshCollections({ loadSelection: true });
|
||||||
await refreshModels();
|
await refreshModels();
|
||||||
|
|||||||
169
web/src/App.tsx
169
web/src/App.tsx
@@ -1,5 +1,5 @@
|
|||||||
import { useEffect, useMemo, useRef, useState } from "preact/hooks";
|
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 { Button } from "@/components/ui/button";
|
||||||
import { Textarea } from "@/components/ui/textarea";
|
import { Textarea } from "@/components/ui/textarea";
|
||||||
import { Separator } from "@/components/ui/separator";
|
import { Separator } from "@/components/ui/separator";
|
||||||
@@ -24,6 +24,7 @@ import {
|
|||||||
runCompletionStream,
|
runCompletionStream,
|
||||||
runSearchStream,
|
runSearchStream,
|
||||||
suggestChatTitle,
|
suggestChatTitle,
|
||||||
|
updateChatTitle,
|
||||||
getMessageAttachments,
|
getMessageAttachments,
|
||||||
type ChatAttachment,
|
type ChatAttachment,
|
||||||
type ActiveRunsResponse,
|
type ActiveRunsResponse,
|
||||||
@@ -57,6 +58,9 @@ type ContextMenuState = {
|
|||||||
x: number;
|
x: number;
|
||||||
y: number;
|
y: number;
|
||||||
};
|
};
|
||||||
|
type RenameChatDialogState = {
|
||||||
|
chatId: string;
|
||||||
|
};
|
||||||
type PendingChatState = {
|
type PendingChatState = {
|
||||||
messages: Message[];
|
messages: Message[];
|
||||||
};
|
};
|
||||||
@@ -752,10 +756,15 @@ export default function App() {
|
|||||||
const [isConvertingQuickQuestion, setIsConvertingQuickQuestion] = useState(false);
|
const [isConvertingQuickQuestion, setIsConvertingQuickQuestion] = useState(false);
|
||||||
const [quickQuestionError, setQuickQuestionError] = useState<string | null>(null);
|
const [quickQuestionError, setQuickQuestionError] = useState<string | null>(null);
|
||||||
const [error, setError] = 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 [transcriptTailSpacerHeight, setTranscriptTailSpacerHeight] = useState(TRANSCRIPT_BOTTOM_GAP);
|
||||||
const transcriptContainerRef = useRef<HTMLDivElement>(null);
|
const transcriptContainerRef = useRef<HTMLDivElement>(null);
|
||||||
const transcriptEndRef = useRef<HTMLDivElement>(null);
|
const transcriptEndRef = useRef<HTMLDivElement>(null);
|
||||||
const contextMenuRef = useRef<HTMLDivElement>(null);
|
const contextMenuRef = useRef<HTMLDivElement>(null);
|
||||||
|
const renameChatInputRef = useRef<HTMLInputElement>(null);
|
||||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||||
const dragDepthRef = useRef(0);
|
const dragDepthRef = useRef(0);
|
||||||
const pendingAttachmentsRef = useRef<ChatAttachment[]>([]);
|
const pendingAttachmentsRef = useRef<ChatAttachment[]>([]);
|
||||||
@@ -882,6 +891,11 @@ export default function App() {
|
|||||||
setQuickSubmittedModelSelection(null);
|
setQuickSubmittedModelSelection(null);
|
||||||
setQuickQuestionMessages([]);
|
setQuickQuestionMessages([]);
|
||||||
setQuickQuestionError(null);
|
setQuickQuestionError(null);
|
||||||
|
setContextMenu(null);
|
||||||
|
setRenameChatDialog(null);
|
||||||
|
setRenameChatDraft("");
|
||||||
|
setRenameChatError(null);
|
||||||
|
setIsRenamingChat(false);
|
||||||
setError(null);
|
setError(null);
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -1377,16 +1391,74 @@ export default function App() {
|
|||||||
return () => window.removeEventListener("keydown", handleKeyDown);
|
return () => window.removeEventListener("keydown", handleKeyDown);
|
||||||
}, [filteredSidebarItems, isAuthenticated, isQuickQuestionOpen]);
|
}, [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) => {
|
const openContextMenu = (event: MouseEvent, item: SidebarSelection) => {
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
const menuWidth = 160;
|
const menuWidth = 176;
|
||||||
const menuHeight = 40;
|
const menuHeight = item.kind === "chat" ? 80 : 40;
|
||||||
const padding = 8;
|
const padding = 8;
|
||||||
const x = Math.min(event.clientX, window.innerWidth - menuWidth - padding);
|
const x = Math.min(event.clientX, window.innerWidth - menuWidth - padding);
|
||||||
const y = Math.min(event.clientY, window.innerHeight - menuHeight - padding);
|
const y = Math.min(event.clientY, window.innerHeight - menuHeight - padding);
|
||||||
setContextMenu({ item, x: Math.max(padding, x), y: Math.max(padding, y) });
|
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 () => {
|
const handleDeleteFromContextMenu = async () => {
|
||||||
if (!contextMenu || isItemRunning(contextMenu.item)) return;
|
if (!contextMenu || isItemRunning(contextMenu.item)) return;
|
||||||
const target = contextMenu.item;
|
const target = contextMenu.item;
|
||||||
@@ -1426,6 +1498,15 @@ export default function App() {
|
|||||||
};
|
};
|
||||||
}, [contextMenu]);
|
}, [contextMenu]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!renameChatDialog) return;
|
||||||
|
const timer = window.setTimeout(() => {
|
||||||
|
renameChatInputRef.current?.focus();
|
||||||
|
renameChatInputRef.current?.select();
|
||||||
|
}, 0);
|
||||||
|
return () => window.clearTimeout(timer);
|
||||||
|
}, [renameChatDialog]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!isQuickQuestionOpen) return;
|
if (!isQuickQuestionOpen) return;
|
||||||
const handleKeyDown = (event: KeyboardEvent) => {
|
const handleKeyDown = (event: KeyboardEvent) => {
|
||||||
@@ -2610,8 +2691,21 @@ export default function App() {
|
|||||||
<Menu className="h-4 w-4" />
|
<Menu className="h-4 w-4" />
|
||||||
</Button>
|
</Button>
|
||||||
|
|
||||||
<div>
|
<div className="flex min-w-0 items-center gap-1.5">
|
||||||
<h1 className="text-sm font-semibold text-violet-50 md:text-base">{selectedTitle}</h1>
|
<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>
|
</div>
|
||||||
<div className="flex w-full max-w-xl items-center gap-2 md:w-auto">
|
<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 }}
|
style={{ left: contextMenu.x, top: contextMenu.y }}
|
||||||
onContextMenu={(event) => event.preventDefault()}
|
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
|
<button
|
||||||
type="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"
|
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>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
) : null}
|
) : 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 ? (
|
{isQuickQuestionOpen ? (
|
||||||
<div
|
<div
|
||||||
className="fixed inset-0 z-[60] flex items-center justify-center bg-black/72 p-3 backdrop-blur-md md:p-6"
|
className="fixed inset-0 z-[60] flex items-center justify-center bg-black/72 p-3 backdrop-blur-md md:p-6"
|
||||||
|
|||||||
Reference in New Issue
Block a user