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

@@ -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,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<Bool> {
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)
}
}
}

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()