adds ability to star chats

This commit is contained in:
2026-05-28 22:47:45 -07:00
parent cb8ea935fa
commit a6c2ec664b
16 changed files with 779 additions and 145 deletions

View File

@@ -84,6 +84,16 @@ actor SybilAPIClient: SybilAPIClienting {
return response.chat
}
func updateChatStar(chatID: String, starred: Bool) async throws -> ChatSummary {
let response = try await request(
"/v1/chats/\(chatID)/star",
method: "PATCH",
body: AnyEncodable(StarUpdateBody(starred: starred)),
responseType: ChatCreateResponse.self
)
return response.chat
}
func deleteChat(chatID: String) async throws {
_ = try await request("/v1/chats/\(chatID)", method: "DELETE", responseType: DeleteResponse.self)
}
@@ -128,6 +138,16 @@ actor SybilAPIClient: SybilAPIClienting {
return response.chat
}
func updateSearchStar(searchID: String, starred: Bool) async throws -> SearchSummary {
let response = try await request(
"/v1/searches/\(searchID)/star",
method: "PATCH",
body: AnyEncodable(StarUpdateBody(starred: starred)),
responseType: SearchCreateResponse.self
)
return response.search
}
func deleteSearch(searchID: String) async throws {
_ = try await request("/v1/searches/\(searchID)", method: "DELETE", responseType: DeleteResponse.self)
}
@@ -654,6 +674,10 @@ private struct ChatTitleUpdateBody: Encodable {
var title: String
}
private struct StarUpdateBody: Encodable {
var starred: Bool
}
private struct SearchCreateBody: Encodable {
var title: String?
var query: String?

View File

@@ -12,12 +12,14 @@ protocol SybilAPIClienting: Sendable {
) async throws -> ChatSummary
func getChat(chatID: String) async throws -> ChatDetail
func updateChatTitle(chatID: String, title: String) async throws -> ChatSummary
func updateChatStar(chatID: String, starred: Bool) async throws -> ChatSummary
func deleteChat(chatID: String) async throws
func suggestChatTitle(chatID: String, content: String) async throws -> ChatSummary
func listSearches() async throws -> [SearchSummary]
func createSearch(title: String?, query: String?) async throws -> SearchSummary
func getSearch(searchID: String) async throws -> SearchDetail
func createChatFromSearch(searchID: String, title: String?) async throws -> ChatSummary
func updateSearchStar(searchID: String, starred: Bool) async throws -> SearchSummary
func deleteSearch(searchID: String) async throws
func listModels() async throws -> ModelCatalogResponse
func getActiveRuns() async throws -> ActiveRunsResponse

View File

@@ -154,6 +154,8 @@ public struct ChatSummary: Codable, Identifiable, Hashable, Sendable {
public var title: String?
public var createdAt: Date
public var updatedAt: Date
public var starred = false
public var starredAt: Date?
public var initiatedProvider: Provider?
public var initiatedModel: String?
public var lastUsedProvider: Provider?
@@ -166,6 +168,8 @@ public struct SearchSummary: Codable, Identifiable, Hashable, Sendable {
public var query: String?
public var createdAt: Date
public var updatedAt: Date
public var starred = false
public var starredAt: Date?
}
public enum WorkspaceItemType: String, Codable, Hashable, Sendable {
@@ -180,6 +184,8 @@ public struct WorkspaceItem: Codable, Identifiable, Hashable, Sendable {
public var query: String?
public var createdAt: Date
public var updatedAt: Date
public var starred = false
public var starredAt: Date?
public var initiatedProvider: Provider?
public var initiatedModel: String?
public var lastUsedProvider: Provider?
@@ -192,6 +198,8 @@ public struct WorkspaceItem: Codable, Identifiable, Hashable, Sendable {
self.query = nil
self.createdAt = chat.createdAt
self.updatedAt = chat.updatedAt
self.starred = chat.starred
self.starredAt = chat.starredAt
self.initiatedProvider = chat.initiatedProvider
self.initiatedModel = chat.initiatedModel
self.lastUsedProvider = chat.lastUsedProvider
@@ -205,6 +213,8 @@ public struct WorkspaceItem: Codable, Identifiable, Hashable, Sendable {
self.query = search.query
self.createdAt = search.createdAt
self.updatedAt = search.updatedAt
self.starred = search.starred
self.starredAt = search.starredAt
self.initiatedProvider = nil
self.initiatedModel = nil
self.lastUsedProvider = nil
@@ -218,6 +228,8 @@ public struct WorkspaceItem: Codable, Identifiable, Hashable, Sendable {
title: title,
createdAt: createdAt,
updatedAt: updatedAt,
starred: starred,
starredAt: starredAt,
initiatedProvider: initiatedProvider,
initiatedModel: initiatedModel,
lastUsedProvider: lastUsedProvider,
@@ -232,7 +244,9 @@ public struct WorkspaceItem: Codable, Identifiable, Hashable, Sendable {
title: title,
query: query,
createdAt: createdAt,
updatedAt: updatedAt
updatedAt: updatedAt,
starred: starred,
starredAt: starredAt
)
}
}
@@ -377,6 +391,8 @@ public struct ChatDetail: Codable, Identifiable, Hashable, Sendable {
public var title: String?
public var createdAt: Date
public var updatedAt: Date
public var starred = false
public var starredAt: Date?
public var initiatedProvider: Provider?
public var initiatedModel: String?
public var lastUsedProvider: Provider?
@@ -415,6 +431,8 @@ public struct SearchDetail: Codable, Identifiable, Hashable, Sendable {
public var query: String?
public var createdAt: Date
public var updatedAt: Date
public var starred = false
public var starredAt: Date?
public var requestId: String?
public var latencyMs: Int?
public var error: String?

View File

@@ -160,6 +160,14 @@ struct SybilSidebarItemList: View {
}
.buttonStyle(.plain)
.contextMenu {
Button {
Task {
await viewModel.setItemStarred(item.selection, starred: !item.starred)
}
} label: {
Label(item.starred ? "Unstar" : "Star", systemImage: item.starred ? "star.slash" : "star")
}
if item.kind == .chat {
Button {
renameTarget = item
@@ -245,6 +253,12 @@ struct SybilSidebarRow: View {
.lineLimit(1)
.layoutPriority(1)
if item.starred {
Image(systemName: "star.fill")
.font(.system(size: 10, weight: .semibold))
.foregroundStyle(.yellow)
}
Spacer(minLength: 8)
if item.isRunning {

View File

@@ -34,6 +34,8 @@ struct SidebarItem: Identifiable, Hashable {
var kind: Kind
var title: String
var updatedAt: Date
var starred: Bool
var starredAt: Date?
var initiatedLabel: String?
var isRunning: Bool
}
@@ -408,6 +410,8 @@ final class SybilViewModel {
kind: .chat,
title: chatTitle(title: item.title, messages: nil),
updatedAt: item.updatedAt,
starred: item.starred,
starredAt: item.starredAt,
initiatedLabel: initiatedLabel,
isRunning: isChatRowRunning(item.id)
)
@@ -418,6 +422,8 @@ final class SybilViewModel {
kind: .search,
title: searchTitle(title: item.title, query: item.query),
updatedAt: item.updatedAt,
starred: item.starred,
starredAt: item.starredAt,
initiatedLabel: "exa",
isRunning: isSearchRowRunning(item.id)
)
@@ -681,6 +687,8 @@ final class SybilViewModel {
title: chat.title,
createdAt: chat.createdAt,
updatedAt: chat.updatedAt,
starred: chat.starred,
starredAt: chat.starredAt,
initiatedProvider: chat.initiatedProvider,
initiatedModel: chat.initiatedModel,
lastUsedProvider: chat.lastUsedProvider,
@@ -867,24 +875,41 @@ final class SybilViewModel {
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
}
applyChatSummary(updated, moveToFront: true)
} catch {
errorMessage = normalizeAPIError(error)
SybilLog.error(SybilLog.ui, "Rename failed", error: error)
}
}
func setItemStarred(_ selection: SidebarSelection, starred: Bool) async {
guard isAuthenticated else {
return
}
guard case .settings = selection else {
errorMessage = nil
do {
let client = try client()
switch selection {
case let .chat(chatID):
let updated = try await client.updateChatStar(chatID: chatID, starred: starred)
applyChatSummary(updated, moveToFront: false)
case let .search(searchID):
let updated = try await client.updateSearchStar(searchID: searchID, starred: starred)
applySearchSummary(updated, moveToFront: false)
case .settings:
break
}
} catch {
errorMessage = normalizeAPIError(error)
SybilLog.error(SybilLog.ui, "Star update failed", error: error)
}
return
}
}
func refreshAfterSettingsChange() async {
SybilLog.info(SybilLog.ui, "Settings changed, reconnecting")
settings.persist()
@@ -1415,6 +1440,47 @@ final class SybilViewModel {
searches = items.compactMap(\.searchSummary)
}
private func applyChatSummary(_ chat: ChatSummary, moveToFront: Bool) {
if let existingIndex = chats.firstIndex(where: { $0.id == chat.id }) {
chats.remove(at: existingIndex)
chats.insert(chat, at: moveToFront ? 0 : existingIndex)
} else {
chats.insert(chat, at: 0)
}
upsertWorkspaceChat(chat, moveToFront: moveToFront)
if selectedChat?.id == chat.id {
selectedChat?.title = chat.title
selectedChat?.updatedAt = chat.updatedAt
selectedChat?.starred = chat.starred
selectedChat?.starredAt = chat.starredAt
selectedChat?.initiatedProvider = chat.initiatedProvider
selectedChat?.initiatedModel = chat.initiatedModel
selectedChat?.lastUsedProvider = chat.lastUsedProvider
selectedChat?.lastUsedModel = chat.lastUsedModel
}
}
private func applySearchSummary(_ search: SearchSummary, moveToFront: Bool) {
if let existingIndex = searches.firstIndex(where: { $0.id == search.id }) {
searches.remove(at: existingIndex)
searches.insert(search, at: moveToFront ? 0 : existingIndex)
} else {
searches.insert(search, at: 0)
}
upsertWorkspaceSearch(search, moveToFront: moveToFront)
if selectedSearch?.id == search.id {
selectedSearch?.title = search.title
selectedSearch?.query = search.query
selectedSearch?.updatedAt = search.updatedAt
selectedSearch?.starred = search.starred
selectedSearch?.starredAt = search.starredAt
}
}
private func upsertWorkspaceChat(_ chat: ChatSummary, moveToFront: Bool = true) {
upsertWorkspaceItem(WorkspaceItem(chat: chat), moveToFront: moveToFront)
}
@@ -1779,6 +1845,8 @@ final class SybilViewModel {
title: created.title,
createdAt: created.createdAt,
updatedAt: created.updatedAt,
starred: created.starred,
starredAt: created.starredAt,
initiatedProvider: created.initiatedProvider,
initiatedModel: created.initiatedModel,
lastUsedProvider: created.lastUsedProvider,
@@ -1839,18 +1907,7 @@ final class SybilViewModel {
let titleSeed = !content.isEmpty ? content : SybilChatAttachmentSupport.attachmentSummary(attachments)
let updated = try await client.suggestChatTitle(chatID: chatID, content: titleSeed.isEmpty ? "Uploaded files" : titleSeed)
await MainActor.run {
self.chats = self.chats.map { existing in
if existing.id == updated.id {
return updated
}
return existing
}
self.upsertWorkspaceChat(updated, moveToFront: false)
if self.selectedChat?.id == updated.id {
self.selectedChat?.title = updated.title
self.selectedChat?.updatedAt = updated.updatedAt
}
self.applyChatSummary(updated, moveToFront: false)
}
} catch {
SybilLog.warning(SybilLog.app, "Chat title suggestion failed: \(SybilLog.describe(error))")
@@ -2009,6 +2066,8 @@ final class SybilViewModel {
query: query,
createdAt: currentSelectedSearch?.createdAt ?? now,
updatedAt: now,
starred: currentSelectedSearch?.starred ?? false,
starredAt: currentSelectedSearch?.starredAt,
requestId: nil,
latencyMs: nil,
error: nil,

View File

@@ -10,6 +10,8 @@ private struct MockClientCallSnapshot: Sendable {
var createChat = 0
var getChat = 0
var updateChatTitle = 0
var updateChatStar = 0
var updateSearchStar = 0
var getSearch = 0
var getActiveRuns = 0
var runCompletionStream = 0
@@ -34,6 +36,8 @@ private actor MockSybilClient: SybilAPIClienting {
private let searchDetails: [String: SearchDetail]
private let createChatResponse: ChatSummary?
private let updateChatTitleResponses: [String: ChatSummary]
private let updateChatStarResponses: [String: ChatSummary]
private let updateSearchStarResponses: [String: SearchSummary]
private let activeRunsResponse: ActiveRunsResponse
private var snapshot = MockClientCallSnapshot()
@@ -60,6 +64,8 @@ private actor MockSybilClient: SybilAPIClienting {
searchDetails: [String: SearchDetail] = [:],
createChatResponse: ChatSummary? = nil,
updateChatTitleResponses: [String: ChatSummary] = [:],
updateChatStarResponses: [String: ChatSummary] = [:],
updateSearchStarResponses: [String: SearchSummary] = [:],
activeRunsResponse: ActiveRunsResponse = ActiveRunsResponse(),
workspaceItemsResponse: [WorkspaceItem]? = nil
) {
@@ -70,6 +76,8 @@ private actor MockSybilClient: SybilAPIClienting {
self.searchDetails = searchDetails
self.createChatResponse = createChatResponse
self.updateChatTitleResponses = updateChatTitleResponses
self.updateChatStarResponses = updateChatStarResponses
self.updateSearchStarResponses = updateSearchStarResponses
self.activeRunsResponse = activeRunsResponse
}
@@ -194,6 +202,14 @@ private actor MockSybilClient: SybilAPIClienting {
return summary
}
func updateChatStar(chatID: String, starred: Bool) async throws -> ChatSummary {
snapshot.updateChatStar += 1
guard let summary = updateChatStarResponses[chatID] else {
throw UnexpectedClientCall()
}
return summary
}
func deleteChat(chatID: String) async throws {
throw UnexpectedClientCall()
}
@@ -229,6 +245,14 @@ private actor MockSybilClient: SybilAPIClienting {
throw UnexpectedClientCall()
}
func updateSearchStar(searchID: String, starred: Bool) async throws -> SearchSummary {
snapshot.updateSearchStar += 1
guard let summary = updateSearchStarResponses[searchID] else {
throw UnexpectedClientCall()
}
return summary
}
func deleteSearch(searchID: String) async throws {
throw UnexpectedClientCall()
}
@@ -509,6 +533,41 @@ private func makeSearchDetail(id: String, date: Date, answer: String) -> SearchD
#expect(viewModel.errorMessage == nil)
}
@MainActor
@Test func starringItemsUpdatesSidebarState() async throws {
let date = Date(timeIntervalSince1970: 1_700_000_175)
let chat = makeChatSummary(id: "chat-star", date: date)
let search = makeSearchSummary(id: "search-star", date: date)
var starredChat = chat
starredChat.starred = true
starredChat.starredAt = date.addingTimeInterval(5)
var starredSearch = search
starredSearch.starred = true
starredSearch.starredAt = date.addingTimeInterval(10)
let client = MockSybilClient(
chatsResponse: [chat],
searchesResponse: [search],
updateChatStarResponses: ["chat-star": starredChat],
updateSearchStarResponses: ["search-star": starredSearch]
)
let viewModel = SybilViewModel(settings: testSettings(named: #function)) { _ in client }
viewModel.isAuthenticated = true
viewModel.isCheckingSession = false
viewModel.chats = [chat]
viewModel.searches = [search]
viewModel.workspaceItems = [WorkspaceItem(chat: chat), WorkspaceItem(search: search)]
await viewModel.setItemStarred(.chat("chat-star"), starred: true)
await viewModel.setItemStarred(.search("search-star"), starred: true)
let snapshot = await client.currentSnapshot()
#expect(snapshot.updateChatStar == 1)
#expect(snapshot.updateSearchStar == 1)
#expect(viewModel.sidebarItems.first(where: { $0.selection == .chat("chat-star") })?.starred == true)
#expect(viewModel.sidebarItems.first(where: { $0.selection == .search("search-star") })?.starred == true)
}
@MainActor
@Test func foregroundSearchRefreshReloadsSelectedSearch() async throws {
let date = Date(timeIntervalSince1970: 1_700_000_200)