adds ability to star chats
This commit is contained in:
@@ -72,6 +72,8 @@ Behavior notes:
|
||||
"title": "optional title",
|
||||
"createdAt": "2026-02-14T00:00:00.000Z",
|
||||
"updatedAt": "2026-02-14T00:00:00.000Z",
|
||||
"starred": true,
|
||||
"starredAt": "2026-02-14T01:00:00.000Z",
|
||||
"initiatedProvider": "openai",
|
||||
"initiatedModel": "gpt-4.1-mini",
|
||||
"lastUsedProvider": "openai",
|
||||
@@ -83,7 +85,9 @@ Behavior notes:
|
||||
"title": "optional title",
|
||||
"query": "search query",
|
||||
"createdAt": "2026-02-14T00:00:00.000Z",
|
||||
"updatedAt": "2026-02-14T00:00:00.000Z"
|
||||
"updatedAt": "2026-02-14T00:00:00.000Z",
|
||||
"starred": false,
|
||||
"starredAt": null
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -93,6 +97,7 @@ Behavior notes:
|
||||
- This endpoint is intended for combined conversation/search lists such as sidebars.
|
||||
- The legacy `GET /v1/chats` and `GET /v1/searches` endpoints remain available for clients that need separate collections.
|
||||
- The response currently combines up to 100 chats and up to 100 searches.
|
||||
- `starred`/`starredAt` are backed by membership in a reserved `Project` with id `starred`; future project folders can reuse the same project item model.
|
||||
|
||||
## Chats
|
||||
|
||||
@@ -130,6 +135,16 @@ Behavior notes:
|
||||
- Renaming updates the returned chat's `updatedAt`.
|
||||
- Not found: `404 { "message": "chat not found" }`
|
||||
|
||||
### `PATCH /v1/chats/:chatId/star`
|
||||
- Body: `{ "starred": boolean }`
|
||||
- Response: `{ "chat": ChatSummary }`
|
||||
- Not found: `404 { "message": "chat not found" }`
|
||||
|
||||
Behavior notes:
|
||||
- Starring adds the chat to the reserved `starred` project and sets `starredAt` to the membership creation time.
|
||||
- Unstarring removes that membership and returns `starred: false`, `starredAt: null`.
|
||||
- This does not modify the chat transcript or chat `updatedAt`.
|
||||
|
||||
### `POST /v1/chats/title/suggest`
|
||||
- Body:
|
||||
```json
|
||||
@@ -282,6 +297,16 @@ Behavior notes:
|
||||
- Body: `{ "title"?: string, "query"?: string }`
|
||||
- Response: `{ "search": SearchSummary }`
|
||||
|
||||
### `PATCH /v1/searches/:searchId/star`
|
||||
- Body: `{ "starred": boolean }`
|
||||
- Response: `{ "search": SearchSummary }`
|
||||
- Not found: `404 { "message": "search not found" }`
|
||||
|
||||
Behavior notes:
|
||||
- Starring adds the search to the reserved `starred` project and sets `starredAt` to the membership creation time.
|
||||
- Unstarring removes that membership and returns `starred: false`, `starredAt: null`.
|
||||
- This does not modify the search results or search `updatedAt`.
|
||||
|
||||
### `DELETE /v1/searches/:searchId`
|
||||
- Response: `{ "deleted": true }`
|
||||
- Not found: `404 { "message": "search not found" }`
|
||||
@@ -354,6 +379,8 @@ Behavior notes:
|
||||
"title": null,
|
||||
"createdAt": "...",
|
||||
"updatedAt": "...",
|
||||
"starred": false,
|
||||
"starredAt": null,
|
||||
"initiatedProvider": "openai|anthropic|xai|hermes-agent|null",
|
||||
"initiatedModel": "string|null",
|
||||
"lastUsedProvider": "openai|anthropic|xai|hermes-agent|null",
|
||||
@@ -402,6 +429,8 @@ Behavior notes:
|
||||
"title": null,
|
||||
"createdAt": "...",
|
||||
"updatedAt": "...",
|
||||
"starred": false,
|
||||
"starredAt": null,
|
||||
"initiatedProvider": "openai|anthropic|xai|hermes-agent|null",
|
||||
"initiatedModel": "string|null",
|
||||
"lastUsedProvider": "openai|anthropic|xai|hermes-agent|null",
|
||||
@@ -412,7 +441,7 @@ Behavior notes:
|
||||
|
||||
`SearchSummary`
|
||||
```json
|
||||
{ "id": "...", "title": null, "query": null, "createdAt": "...", "updatedAt": "..." }
|
||||
{ "id": "...", "title": null, "query": null, "createdAt": "...", "updatedAt": "...", "starred": false, "starredAt": null }
|
||||
```
|
||||
|
||||
`SearchDetail`
|
||||
@@ -423,6 +452,8 @@ Behavior notes:
|
||||
"query": "...",
|
||||
"createdAt": "...",
|
||||
"updatedAt": "...",
|
||||
"starred": false,
|
||||
"starredAt": null,
|
||||
"requestId": "...",
|
||||
"latencyMs": 123,
|
||||
"error": null,
|
||||
|
||||
@@ -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?
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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?
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -0,0 +1,44 @@
|
||||
-- CreateTable
|
||||
CREATE TABLE "Project" (
|
||||
"id" TEXT NOT NULL PRIMARY KEY,
|
||||
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updatedAt" DATETIME NOT NULL,
|
||||
"kind" TEXT NOT NULL DEFAULT 'folder',
|
||||
"title" TEXT NOT NULL,
|
||||
"userId" TEXT,
|
||||
CONSTRAINT "Project_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User" ("id") ON DELETE CASCADE ON UPDATE CASCADE
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "ProjectItem" (
|
||||
"id" TEXT NOT NULL PRIMARY KEY,
|
||||
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"projectId" TEXT NOT NULL,
|
||||
"chatId" TEXT,
|
||||
"searchId" TEXT,
|
||||
CONSTRAINT "ProjectItem_projectId_fkey" FOREIGN KEY ("projectId") REFERENCES "Project" ("id") ON DELETE CASCADE ON UPDATE CASCADE,
|
||||
CONSTRAINT "ProjectItem_chatId_fkey" FOREIGN KEY ("chatId") REFERENCES "Chat" ("id") ON DELETE CASCADE ON UPDATE CASCADE,
|
||||
CONSTRAINT "ProjectItem_searchId_fkey" FOREIGN KEY ("searchId") REFERENCES "Search" ("id") ON DELETE CASCADE ON UPDATE CASCADE,
|
||||
CONSTRAINT "ProjectItem_one_target_check" CHECK (("chatId" IS NOT NULL AND "searchId" IS NULL) OR ("chatId" IS NULL AND "searchId" IS NOT NULL))
|
||||
);
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "Project_kind_idx" ON "Project"("kind");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "Project_userId_idx" ON "Project"("userId");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "ProjectItem_projectId_chatId_key" ON "ProjectItem"("projectId", "chatId");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "ProjectItem_projectId_searchId_key" ON "ProjectItem"("projectId", "searchId");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "ProjectItem_projectId_createdAt_idx" ON "ProjectItem"("projectId", "createdAt");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "ProjectItem_chatId_idx" ON "ProjectItem"("chatId");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "ProjectItem_searchId_idx" ON "ProjectItem"("searchId");
|
||||
@@ -27,6 +27,11 @@ enum SearchSource {
|
||||
exa
|
||||
}
|
||||
|
||||
enum ProjectKind {
|
||||
starred
|
||||
folder
|
||||
}
|
||||
|
||||
model User {
|
||||
id String @id @default(cuid())
|
||||
createdAt DateTime @default(now())
|
||||
@@ -37,6 +42,7 @@ model User {
|
||||
|
||||
chats Chat[]
|
||||
searches Search[]
|
||||
projects Project[]
|
||||
}
|
||||
|
||||
model Chat {
|
||||
@@ -56,6 +62,7 @@ model Chat {
|
||||
|
||||
messages Message[]
|
||||
calls LlmCall[]
|
||||
projectItems ProjectItem[]
|
||||
|
||||
@@index([userId])
|
||||
}
|
||||
@@ -129,6 +136,7 @@ model Search {
|
||||
userId String?
|
||||
|
||||
results SearchResult[]
|
||||
projectItems ProjectItem[]
|
||||
|
||||
@@index([updatedAt])
|
||||
@@index([userId])
|
||||
@@ -156,3 +164,40 @@ model SearchResult {
|
||||
|
||||
@@index([searchId, rank])
|
||||
}
|
||||
|
||||
model Project {
|
||||
id String @id @default(cuid())
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
kind ProjectKind @default(folder)
|
||||
title String
|
||||
|
||||
user User? @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||
userId String?
|
||||
|
||||
items ProjectItem[]
|
||||
|
||||
@@index([kind])
|
||||
@@index([userId])
|
||||
}
|
||||
|
||||
model ProjectItem {
|
||||
id String @id @default(cuid())
|
||||
createdAt DateTime @default(now())
|
||||
|
||||
project Project @relation(fields: [projectId], references: [id], onDelete: Cascade)
|
||||
projectId String
|
||||
|
||||
chat Chat? @relation(fields: [chatId], references: [id], onDelete: Cascade)
|
||||
chatId String?
|
||||
|
||||
search Search? @relation(fields: [searchId], references: [id], onDelete: Cascade)
|
||||
searchId String?
|
||||
|
||||
@@unique([projectId, chatId])
|
||||
@@unique([projectId, searchId])
|
||||
@@index([projectId, createdAt])
|
||||
@@index([chatId])
|
||||
@@index([searchId])
|
||||
}
|
||||
|
||||
@@ -321,6 +321,34 @@ type SearchRunRequest = z.infer<typeof SearchRunBody>;
|
||||
|
||||
const activeChatStreams = new Map<string, ActiveSseStream>();
|
||||
const activeSearchStreams = new Map<string, ActiveSseStream>();
|
||||
const STARRED_PROJECT_ID = "starred";
|
||||
|
||||
const starredProjectItemsSelect = {
|
||||
where: { projectId: STARRED_PROJECT_ID },
|
||||
select: { createdAt: true },
|
||||
take: 1,
|
||||
} as const;
|
||||
|
||||
const chatSummarySelect = {
|
||||
id: true,
|
||||
title: true,
|
||||
createdAt: true,
|
||||
updatedAt: true,
|
||||
initiatedProvider: true,
|
||||
initiatedModel: true,
|
||||
lastUsedProvider: true,
|
||||
lastUsedModel: true,
|
||||
projectItems: starredProjectItemsSelect,
|
||||
} as const;
|
||||
|
||||
const searchSummarySelect = {
|
||||
id: true,
|
||||
title: true,
|
||||
query: true,
|
||||
createdAt: true,
|
||||
updatedAt: true,
|
||||
projectItems: starredProjectItemsSelect,
|
||||
} as const;
|
||||
|
||||
function getErrorMessage(err: unknown) {
|
||||
return err instanceof Error ? err.message : String(err);
|
||||
@@ -330,32 +358,111 @@ function compareUpdatedAtDesc(a: { updatedAt: Date | string }, b: { updatedAt: D
|
||||
return new Date(b.updatedAt).getTime() - new Date(a.updatedAt).getTime();
|
||||
}
|
||||
|
||||
function serializeStarFields(item: { projectItems?: Array<{ createdAt: Date }> }) {
|
||||
const star = item.projectItems?.[0];
|
||||
return {
|
||||
starred: Boolean(star),
|
||||
starredAt: star?.createdAt ?? null,
|
||||
};
|
||||
}
|
||||
|
||||
function serializeChatLike<T extends Record<string, any>>(chat: T) {
|
||||
const { projectItems: _projectItems, ...rest } = chat;
|
||||
return {
|
||||
...serializeProviderFields(rest),
|
||||
...serializeStarFields(chat),
|
||||
};
|
||||
}
|
||||
|
||||
function serializeSearchLike<T extends Record<string, any>>(search: T) {
|
||||
const { projectItems: _projectItems, ...rest } = search;
|
||||
return {
|
||||
...rest,
|
||||
...serializeStarFields(search),
|
||||
};
|
||||
}
|
||||
|
||||
async function ensureStarredProject() {
|
||||
await prisma.project.upsert({
|
||||
where: { id: STARRED_PROJECT_ID },
|
||||
update: {},
|
||||
create: {
|
||||
id: STARRED_PROJECT_ID,
|
||||
kind: "starred" as any,
|
||||
title: "Starred",
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
async function getChatSummary(chatId: string) {
|
||||
const chat = await prisma.chat.findUnique({
|
||||
where: { id: chatId },
|
||||
select: chatSummarySelect,
|
||||
});
|
||||
return chat ? serializeChatLike(chat) : null;
|
||||
}
|
||||
|
||||
async function getSearchSummary(searchId: string) {
|
||||
const search = await prisma.search.findUnique({
|
||||
where: { id: searchId },
|
||||
select: searchSummarySelect,
|
||||
});
|
||||
return search ? serializeSearchLike(search) : null;
|
||||
}
|
||||
|
||||
async function setChatStarred(chatId: string, starred: boolean) {
|
||||
const exists = await prisma.chat.findUnique({ where: { id: chatId }, select: { id: true } });
|
||||
if (!exists) return null;
|
||||
|
||||
if (starred) {
|
||||
await ensureStarredProject();
|
||||
await prisma.projectItem.upsert({
|
||||
where: { projectId_chatId: { projectId: STARRED_PROJECT_ID, chatId } },
|
||||
update: {},
|
||||
create: { projectId: STARRED_PROJECT_ID, chatId },
|
||||
});
|
||||
} else {
|
||||
await prisma.projectItem.deleteMany({ where: { projectId: STARRED_PROJECT_ID, chatId } });
|
||||
}
|
||||
|
||||
return getChatSummary(chatId);
|
||||
}
|
||||
|
||||
async function setSearchStarred(searchId: string, starred: boolean) {
|
||||
const exists = await prisma.search.findUnique({ where: { id: searchId }, select: { id: true } });
|
||||
if (!exists) return null;
|
||||
|
||||
if (starred) {
|
||||
await ensureStarredProject();
|
||||
await prisma.projectItem.upsert({
|
||||
where: { projectId_searchId: { projectId: STARRED_PROJECT_ID, searchId } },
|
||||
update: {},
|
||||
create: { projectId: STARRED_PROJECT_ID, searchId },
|
||||
});
|
||||
} else {
|
||||
await prisma.projectItem.deleteMany({ where: { projectId: STARRED_PROJECT_ID, searchId } });
|
||||
}
|
||||
|
||||
return getSearchSummary(searchId);
|
||||
}
|
||||
|
||||
async function listWorkspaceItems() {
|
||||
const [chats, searches] = await Promise.all([
|
||||
prisma.chat.findMany({
|
||||
orderBy: { updatedAt: "desc" },
|
||||
take: 100,
|
||||
select: {
|
||||
id: true,
|
||||
title: true,
|
||||
createdAt: true,
|
||||
updatedAt: true,
|
||||
initiatedProvider: true,
|
||||
initiatedModel: true,
|
||||
lastUsedProvider: true,
|
||||
lastUsedModel: true,
|
||||
},
|
||||
select: chatSummarySelect,
|
||||
}),
|
||||
prisma.search.findMany({
|
||||
orderBy: { updatedAt: "desc" },
|
||||
take: 100,
|
||||
select: { id: true, title: true, query: true, createdAt: true, updatedAt: true },
|
||||
select: searchSummarySelect,
|
||||
}),
|
||||
]);
|
||||
|
||||
return [
|
||||
...chats.map((chat) => ({ type: "chat" as const, ...serializeProviderFields(chat) })),
|
||||
...searches.map((search) => ({ type: "search" as const, ...search })),
|
||||
...chats.map((chat) => ({ type: "chat" as const, ...serializeChatLike(chat) })),
|
||||
...searches.map((search) => ({ type: "search" as const, ...serializeSearchLike(search) })),
|
||||
].sort(compareUpdatedAtDesc);
|
||||
}
|
||||
|
||||
@@ -562,12 +669,15 @@ async function executeSearchRunStream(searchId: string, body: SearchRunRequest,
|
||||
|
||||
const search = await prisma.search.findUnique({
|
||||
where: { id: searchId },
|
||||
include: { results: { orderBy: { rank: "asc" } } },
|
||||
include: {
|
||||
results: { orderBy: { rank: "asc" } },
|
||||
projectItems: starredProjectItemsSelect,
|
||||
},
|
||||
});
|
||||
if (!search) {
|
||||
stream.complete({ event: "error", data: { message: "search not found" } });
|
||||
} else {
|
||||
stream.complete({ event: "done", data: { search } });
|
||||
stream.complete({ event: "done", data: { search: serializeSearchLike(search) } });
|
||||
}
|
||||
} catch (err) {
|
||||
const message = getErrorMessage(err);
|
||||
@@ -621,18 +731,9 @@ export async function registerRoutes(app: FastifyInstance) {
|
||||
const chats = await prisma.chat.findMany({
|
||||
orderBy: { updatedAt: "desc" },
|
||||
take: 100,
|
||||
select: {
|
||||
id: true,
|
||||
title: true,
|
||||
createdAt: true,
|
||||
updatedAt: true,
|
||||
initiatedProvider: true,
|
||||
initiatedModel: true,
|
||||
lastUsedProvider: true,
|
||||
lastUsedModel: true,
|
||||
},
|
||||
select: chatSummarySelect,
|
||||
});
|
||||
return { chats: chats.map((chat) => serializeProviderFields(chat)) };
|
||||
return { chats: chats.map((chat) => serializeChatLike(chat)) };
|
||||
});
|
||||
|
||||
app.post("/v1/chats", async (req) => {
|
||||
@@ -681,18 +782,9 @@ export async function registerRoutes(app: FastifyInstance) {
|
||||
}
|
||||
: undefined,
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
title: true,
|
||||
createdAt: true,
|
||||
updatedAt: true,
|
||||
initiatedProvider: true,
|
||||
initiatedModel: true,
|
||||
lastUsedProvider: true,
|
||||
lastUsedModel: true,
|
||||
},
|
||||
select: chatSummarySelect,
|
||||
});
|
||||
return { chat: serializeProviderFields(chat) };
|
||||
return { chat: serializeChatLike(chat) };
|
||||
});
|
||||
|
||||
app.patch("/v1/chats/:chatId", async (req) => {
|
||||
@@ -709,21 +801,21 @@ export async function registerRoutes(app: FastifyInstance) {
|
||||
|
||||
if (updated.count === 0) return app.httpErrors.notFound("chat not found");
|
||||
|
||||
const chat = await prisma.chat.findUnique({
|
||||
where: { id: chatId },
|
||||
select: {
|
||||
id: true,
|
||||
title: true,
|
||||
createdAt: true,
|
||||
updatedAt: true,
|
||||
initiatedProvider: true,
|
||||
initiatedModel: true,
|
||||
lastUsedProvider: true,
|
||||
lastUsedModel: true,
|
||||
},
|
||||
});
|
||||
const chat = await getChatSummary(chatId);
|
||||
if (!chat) return app.httpErrors.notFound("chat not found");
|
||||
return { chat: serializeProviderFields(chat) };
|
||||
return { chat };
|
||||
});
|
||||
|
||||
app.patch("/v1/chats/:chatId/star", async (req) => {
|
||||
requireAdmin(req);
|
||||
const Params = z.object({ chatId: z.string() });
|
||||
const Body = z.object({ starred: z.boolean() });
|
||||
const { chatId } = Params.parse(req.params);
|
||||
const body = Body.parse(req.body ?? {});
|
||||
|
||||
const chat = await setChatStarred(chatId, body.starred);
|
||||
if (!chat) return app.httpErrors.notFound("chat not found");
|
||||
return { chat };
|
||||
});
|
||||
|
||||
app.post("/v1/chats/title/suggest", async (req) => {
|
||||
@@ -736,19 +828,10 @@ export async function registerRoutes(app: FastifyInstance) {
|
||||
|
||||
const existing = await prisma.chat.findUnique({
|
||||
where: { id: body.chatId },
|
||||
select: {
|
||||
id: true,
|
||||
title: true,
|
||||
createdAt: true,
|
||||
updatedAt: true,
|
||||
initiatedProvider: true,
|
||||
initiatedModel: true,
|
||||
lastUsedProvider: true,
|
||||
lastUsedModel: true,
|
||||
},
|
||||
select: chatSummarySelect,
|
||||
});
|
||||
if (!existing) return app.httpErrors.notFound("chat not found");
|
||||
if (existing.title?.trim()) return { chat: serializeProviderFields(existing) };
|
||||
if (existing.title?.trim()) return { chat: serializeChatLike(existing) };
|
||||
|
||||
const fallback = body.content.split(/\r?\n/)[0]?.trim().slice(0, 48) || "New chat";
|
||||
const suggestedRaw = await generateChatTitle(body.content);
|
||||
@@ -759,22 +842,10 @@ export async function registerRoutes(app: FastifyInstance) {
|
||||
data: { title },
|
||||
});
|
||||
|
||||
const chat = await prisma.chat.findUnique({
|
||||
where: { id: body.chatId },
|
||||
select: {
|
||||
id: true,
|
||||
title: true,
|
||||
createdAt: true,
|
||||
updatedAt: true,
|
||||
initiatedProvider: true,
|
||||
initiatedModel: true,
|
||||
lastUsedProvider: true,
|
||||
lastUsedModel: true,
|
||||
},
|
||||
});
|
||||
const chat = await getChatSummary(body.chatId);
|
||||
if (!chat) return app.httpErrors.notFound("chat not found");
|
||||
|
||||
return { chat: serializeProviderFields(chat) };
|
||||
return { chat };
|
||||
});
|
||||
|
||||
app.delete("/v1/chats/:chatId", async (req) => {
|
||||
@@ -799,9 +870,9 @@ export async function registerRoutes(app: FastifyInstance) {
|
||||
const searches = await prisma.search.findMany({
|
||||
orderBy: { updatedAt: "desc" },
|
||||
take: 100,
|
||||
select: { id: true, title: true, query: true, createdAt: true, updatedAt: true },
|
||||
select: searchSummarySelect,
|
||||
});
|
||||
return { searches };
|
||||
return { searches: searches.map((search) => serializeSearchLike(search)) };
|
||||
});
|
||||
|
||||
app.post("/v1/searches", async (req) => {
|
||||
@@ -815,8 +886,20 @@ export async function registerRoutes(app: FastifyInstance) {
|
||||
title: title || null,
|
||||
query,
|
||||
},
|
||||
select: { id: true, title: true, query: true, createdAt: true, updatedAt: true },
|
||||
select: searchSummarySelect,
|
||||
});
|
||||
return { search: serializeSearchLike(search) };
|
||||
});
|
||||
|
||||
app.patch("/v1/searches/:searchId/star", async (req) => {
|
||||
requireAdmin(req);
|
||||
const Params = z.object({ searchId: z.string() });
|
||||
const Body = z.object({ starred: z.boolean() });
|
||||
const { searchId } = Params.parse(req.params);
|
||||
const body = Body.parse(req.body ?? {});
|
||||
|
||||
const search = await setSearchStarred(searchId, body.starred);
|
||||
if (!search) return app.httpErrors.notFound("search not found");
|
||||
return { search };
|
||||
});
|
||||
|
||||
@@ -843,10 +926,13 @@ export async function registerRoutes(app: FastifyInstance) {
|
||||
|
||||
const search = await prisma.search.findUnique({
|
||||
where: { id: searchId },
|
||||
include: { results: { orderBy: { rank: "asc" } } },
|
||||
include: {
|
||||
results: { orderBy: { rank: "asc" } },
|
||||
projectItems: starredProjectItemsSelect,
|
||||
},
|
||||
});
|
||||
if (!search) return app.httpErrors.notFound("search not found");
|
||||
return { search };
|
||||
return { search: serializeSearchLike(search) };
|
||||
});
|
||||
|
||||
app.post("/v1/searches/:searchId/chat", async (req) => {
|
||||
@@ -882,19 +968,10 @@ export async function registerRoutes(app: FastifyInstance) {
|
||||
},
|
||||
},
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
title: true,
|
||||
createdAt: true,
|
||||
updatedAt: true,
|
||||
initiatedProvider: true,
|
||||
initiatedModel: true,
|
||||
lastUsedProvider: true,
|
||||
lastUsedModel: true,
|
||||
},
|
||||
select: chatSummarySelect,
|
||||
});
|
||||
|
||||
return { chat: serializeProviderFields(chat) };
|
||||
return { chat: serializeChatLike(chat) };
|
||||
});
|
||||
|
||||
app.post("/v1/searches/:searchId/run", async (req) => {
|
||||
@@ -979,10 +1056,13 @@ export async function registerRoutes(app: FastifyInstance) {
|
||||
|
||||
const search = await prisma.search.findUnique({
|
||||
where: { id: searchId },
|
||||
include: { results: { orderBy: { rank: "asc" } } },
|
||||
include: {
|
||||
results: { orderBy: { rank: "asc" } },
|
||||
projectItems: starredProjectItemsSelect,
|
||||
},
|
||||
});
|
||||
if (!search) return app.httpErrors.notFound("search not found");
|
||||
return { search };
|
||||
return { search: serializeSearchLike(search) };
|
||||
} catch (err: any) {
|
||||
await prisma.search.update({
|
||||
where: { id: searchId },
|
||||
@@ -1037,10 +1117,14 @@ export async function registerRoutes(app: FastifyInstance) {
|
||||
|
||||
const chat = await prisma.chat.findUnique({
|
||||
where: { id: chatId },
|
||||
include: { messages: { orderBy: { createdAt: "asc" } }, calls: { orderBy: { createdAt: "desc" } } },
|
||||
include: {
|
||||
messages: { orderBy: { createdAt: "asc" } },
|
||||
calls: { orderBy: { createdAt: "desc" } },
|
||||
projectItems: starredProjectItemsSelect,
|
||||
},
|
||||
});
|
||||
if (!chat) return app.httpErrors.notFound("chat not found");
|
||||
return { chat: serializeProviderFields(chat) };
|
||||
return { chat: serializeChatLike(chat) };
|
||||
});
|
||||
|
||||
app.post("/v1/chats/:chatId/messages", async (req) => {
|
||||
|
||||
@@ -68,6 +68,14 @@ export class SybilApiClient {
|
||||
return data.chat;
|
||||
}
|
||||
|
||||
async updateChatStar(chatId: string, starred: boolean) {
|
||||
const data = await this.request<{ chat: ChatSummary }>(`/v1/chats/${chatId}/star`, {
|
||||
method: "PATCH",
|
||||
body: { starred },
|
||||
});
|
||||
return data.chat;
|
||||
}
|
||||
|
||||
async suggestChatTitle(body: { chatId: string; content: string }) {
|
||||
const data = await this.request<{ chat: ChatSummary }>("/v1/chats/title/suggest", {
|
||||
method: "POST",
|
||||
@@ -98,6 +106,14 @@ export class SybilApiClient {
|
||||
return data.search;
|
||||
}
|
||||
|
||||
async updateSearchStar(searchId: string, starred: boolean) {
|
||||
const data = await this.request<{ search: SearchSummary }>(`/v1/searches/${searchId}/star`, {
|
||||
method: "PATCH",
|
||||
body: { starred },
|
||||
});
|
||||
return data.search;
|
||||
}
|
||||
|
||||
async deleteSearch(searchId: string) {
|
||||
await this.request<{ deleted: true }>(`/v1/searches/${searchId}`, { method: "DELETE" });
|
||||
}
|
||||
|
||||
@@ -20,6 +20,8 @@ type SidebarItem = SidebarSelection & {
|
||||
title: string;
|
||||
updatedAt: string;
|
||||
createdAt: string;
|
||||
starred: boolean;
|
||||
starredAt: string | null;
|
||||
initiatedProvider: Provider | null;
|
||||
initiatedModel: string | null;
|
||||
lastUsedProvider: Provider | null;
|
||||
@@ -131,6 +133,8 @@ function buildSidebarItems(items: WorkspaceItem[]): SidebarItem[] {
|
||||
title: getChatTitle(chat),
|
||||
updatedAt: chat.updatedAt,
|
||||
createdAt: chat.createdAt,
|
||||
starred: chat.starred,
|
||||
starredAt: chat.starredAt,
|
||||
initiatedProvider: chat.initiatedProvider,
|
||||
initiatedModel: chat.initiatedModel,
|
||||
lastUsedProvider: chat.lastUsedProvider,
|
||||
@@ -145,6 +149,8 @@ function buildSidebarItems(items: WorkspaceItem[]): SidebarItem[] {
|
||||
title: getSearchTitle(search),
|
||||
updatedAt: search.updatedAt,
|
||||
createdAt: search.createdAt,
|
||||
starred: search.starred,
|
||||
starredAt: search.starredAt,
|
||||
initiatedProvider: null,
|
||||
initiatedModel: null,
|
||||
lastUsedProvider: null,
|
||||
@@ -521,12 +527,13 @@ async function main() {
|
||||
? ["No chats/searches yet. Press n or /. "]
|
||||
: items.map((item) => {
|
||||
const kind = item.kind === "chat" ? "C" : "S";
|
||||
const star = item.starred ? "{yellow-fg}★{/yellow-fg} " : " ";
|
||||
const title = truncate(item.title, 36);
|
||||
const initiatedLabel =
|
||||
item.kind === "chat" && item.initiatedModel
|
||||
? ` | ${getProviderLabel(item.initiatedProvider)} ${truncate(item.initiatedModel, 16)}`
|
||||
: "";
|
||||
return `${kind} ${title} {gray-fg}${formatDate(item.updatedAt)}${escapeTags(initiatedLabel)}{/gray-fg}`;
|
||||
return `${star}${kind} ${title} {gray-fg}${formatDate(item.updatedAt)}${escapeTags(initiatedLabel)}{/gray-fg}`;
|
||||
});
|
||||
|
||||
const linesChanged =
|
||||
@@ -701,7 +708,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 [r] rename [d] delete [C-r] refresh [q] quit";
|
||||
"{gray-fg}Controls:{/gray-fg} [tab] focus [esc] command mode [↑/↓] highlight [enter] send/select [n] new chat [/] new search [s] star [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}" : "";
|
||||
@@ -952,10 +959,20 @@ async function main() {
|
||||
pendingTitleGeneration.add(chatId);
|
||||
try {
|
||||
const updated = await api.suggestChatTitle({ chatId, content });
|
||||
chats = chats.map((chat) => (chat.id === updated.id ? { ...chat, title: updated.title, updatedAt: updated.updatedAt } : chat));
|
||||
chats = chats.map((chat) => (chat.id === updated.id ? updated : chat));
|
||||
workspaceItems = workspaceItems.map((item) => (item.type === "chat" && item.id === updated.id ? chatWorkspaceItem(updated) : item));
|
||||
if (selectedChat?.id === updated.id) {
|
||||
selectedChat = { ...selectedChat, title: updated.title, updatedAt: updated.updatedAt };
|
||||
selectedChat = {
|
||||
...selectedChat,
|
||||
title: updated.title,
|
||||
updatedAt: updated.updatedAt,
|
||||
starred: updated.starred,
|
||||
starredAt: updated.starredAt,
|
||||
initiatedProvider: updated.initiatedProvider,
|
||||
initiatedModel: updated.initiatedModel,
|
||||
lastUsedProvider: updated.lastUsedProvider,
|
||||
lastUsedModel: updated.lastUsedModel,
|
||||
};
|
||||
}
|
||||
updateUI();
|
||||
} catch {
|
||||
@@ -1006,6 +1023,8 @@ async function main() {
|
||||
title: chat.title,
|
||||
createdAt: chat.createdAt,
|
||||
updatedAt: chat.updatedAt,
|
||||
starred: chat.starred,
|
||||
starredAt: chat.starredAt,
|
||||
initiatedProvider: chat.initiatedProvider,
|
||||
initiatedModel: chat.initiatedModel,
|
||||
lastUsedProvider: chat.lastUsedProvider,
|
||||
@@ -1182,6 +1201,8 @@ async function main() {
|
||||
query,
|
||||
createdAt: nowIso,
|
||||
updatedAt: nowIso,
|
||||
starred: false,
|
||||
starredAt: null,
|
||||
requestId: null,
|
||||
latencyMs: null,
|
||||
error: null,
|
||||
@@ -1375,6 +1396,57 @@ async function main() {
|
||||
updateUI();
|
||||
}
|
||||
|
||||
async function handleToggleStarSelection() {
|
||||
if (!selectedItem) return;
|
||||
|
||||
const currentItem = getSidebarItems().find((item) => item.kind === selectedItem?.kind && item.id === selectedItem?.id);
|
||||
const nextStarred = !currentItem?.starred;
|
||||
setError(null);
|
||||
|
||||
if (selectedItem.kind === "chat") {
|
||||
const updated = await api.updateChatStar(selectedItem.id, nextStarred);
|
||||
chats = chats.map((chat) => (chat.id === updated.id ? updated : chat));
|
||||
if (!chats.some((chat) => chat.id === updated.id)) chats = [updated, ...chats];
|
||||
workspaceItems = workspaceItems.map((item) => (item.type === "chat" && item.id === updated.id ? chatWorkspaceItem(updated) : item));
|
||||
if (!workspaceItems.some((item) => item.type === "chat" && item.id === updated.id)) {
|
||||
workspaceItems = [chatWorkspaceItem(updated), ...workspaceItems];
|
||||
}
|
||||
if (selectedChat?.id === updated.id) {
|
||||
selectedChat = {
|
||||
...selectedChat,
|
||||
title: updated.title,
|
||||
updatedAt: updated.updatedAt,
|
||||
starred: updated.starred,
|
||||
starredAt: updated.starredAt,
|
||||
initiatedProvider: updated.initiatedProvider,
|
||||
initiatedModel: updated.initiatedModel,
|
||||
lastUsedProvider: updated.lastUsedProvider,
|
||||
lastUsedModel: updated.lastUsedModel,
|
||||
};
|
||||
}
|
||||
} else {
|
||||
const updated = await api.updateSearchStar(selectedItem.id, nextStarred);
|
||||
searches = searches.map((search) => (search.id === updated.id ? updated : search));
|
||||
if (!searches.some((search) => search.id === updated.id)) searches = [updated, ...searches];
|
||||
workspaceItems = workspaceItems.map((item) => (item.type === "search" && item.id === updated.id ? searchWorkspaceItem(updated) : item));
|
||||
if (!workspaceItems.some((item) => item.type === "search" && item.id === updated.id)) {
|
||||
workspaceItems = [searchWorkspaceItem(updated), ...workspaceItems];
|
||||
}
|
||||
if (selectedSearch?.id === updated.id) {
|
||||
selectedSearch = {
|
||||
...selectedSearch,
|
||||
title: updated.title,
|
||||
query: updated.query,
|
||||
updatedAt: updated.updatedAt,
|
||||
starred: updated.starred,
|
||||
starredAt: updated.starredAt,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
updateUI();
|
||||
}
|
||||
|
||||
function cycleProvider() {
|
||||
const visibleProviders = getVisibleProviders(modelCatalog);
|
||||
const cycleProviders = visibleProviders.length ? visibleProviders : BASE_PROVIDERS;
|
||||
@@ -1504,6 +1576,13 @@ async function main() {
|
||||
});
|
||||
});
|
||||
|
||||
screen.key(["s"], () => {
|
||||
if (shouldIgnoreGlobalShortcut()) return;
|
||||
void runAction(async () => {
|
||||
await handleToggleStarSelection();
|
||||
});
|
||||
});
|
||||
|
||||
screen.key(["p"], () => {
|
||||
if (shouldIgnoreGlobalShortcut()) return;
|
||||
if (getIsSearchMode() || isSending) return;
|
||||
|
||||
@@ -15,6 +15,8 @@ export type ChatSummary = {
|
||||
title: string | null;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
starred: boolean;
|
||||
starredAt: string | null;
|
||||
initiatedProvider: Provider | null;
|
||||
initiatedModel: string | null;
|
||||
lastUsedProvider: Provider | null;
|
||||
@@ -27,6 +29,8 @@ export type SearchSummary = {
|
||||
query: string | null;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
starred: boolean;
|
||||
starredAt: string | null;
|
||||
};
|
||||
|
||||
export type ChatWorkspaceItem = ChatSummary & {
|
||||
@@ -66,6 +70,8 @@ export type ChatDetail = {
|
||||
title: string | null;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
starred: boolean;
|
||||
starredAt: string | null;
|
||||
initiatedProvider: Provider | null;
|
||||
initiatedModel: string | null;
|
||||
lastUsedProvider: Provider | null;
|
||||
@@ -95,6 +101,8 @@ export type SearchDetail = {
|
||||
query: string | null;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
starred: boolean;
|
||||
starredAt: string | null;
|
||||
requestId: string | null;
|
||||
latencyMs: number | null;
|
||||
error: string | null;
|
||||
|
||||
159
web/src/App.tsx
159
web/src/App.tsx
@@ -1,5 +1,5 @@
|
||||
import { useEffect, useMemo, useRef, useState } from "preact/hooks";
|
||||
import { Check, ChevronDown, Globe2, LoaderCircle, Menu, MessageSquare, Paperclip, Pencil, Plus, Rabbit, Search, SendHorizontal, Trash2, X } from "lucide-preact";
|
||||
import { Check, ChevronDown, Globe2, LoaderCircle, Menu, MessageSquare, Paperclip, Pencil, Plus, Rabbit, Search, SendHorizontal, Star, Trash2, X } from "lucide-preact";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import { Separator } from "@/components/ui/separator";
|
||||
@@ -25,6 +25,8 @@ import {
|
||||
runSearchStream,
|
||||
suggestChatTitle,
|
||||
updateChatTitle,
|
||||
updateChatStar,
|
||||
updateSearchStar,
|
||||
getMessageAttachments,
|
||||
type ChatAttachment,
|
||||
type ActiveRunsResponse,
|
||||
@@ -48,6 +50,8 @@ type SidebarItem = SidebarSelection & {
|
||||
title: string;
|
||||
updatedAt: string;
|
||||
createdAt: string;
|
||||
starred: boolean;
|
||||
starredAt: string | null;
|
||||
initiatedProvider: Provider | null;
|
||||
initiatedModel: string | null;
|
||||
lastUsedProvider: Provider | null;
|
||||
@@ -625,6 +629,8 @@ function buildSidebarItems(items: WorkspaceItem[]): SidebarItem[] {
|
||||
title: getChatTitle(chat),
|
||||
updatedAt: chat.updatedAt,
|
||||
createdAt: chat.createdAt,
|
||||
starred: chat.starred,
|
||||
starredAt: chat.starredAt,
|
||||
initiatedProvider: chat.initiatedProvider,
|
||||
initiatedModel: chat.initiatedModel,
|
||||
lastUsedProvider: chat.lastUsedProvider,
|
||||
@@ -639,6 +645,8 @@ function buildSidebarItems(items: WorkspaceItem[]): SidebarItem[] {
|
||||
title: getSearchTitle(search),
|
||||
updatedAt: search.updatedAt,
|
||||
createdAt: search.createdAt,
|
||||
starred: search.starred,
|
||||
starredAt: search.starredAt,
|
||||
initiatedProvider: null,
|
||||
initiatedModel: null,
|
||||
lastUsedProvider: null,
|
||||
@@ -690,7 +698,13 @@ function getSidebarSectionLabel(value: string) {
|
||||
}
|
||||
|
||||
function buildSidebarSections(items: SidebarItem[]) {
|
||||
return items.reduce<Array<{ label: string; items: SidebarItem[] }>>((sections, item) => {
|
||||
const starred = items
|
||||
.filter((item) => item.starred)
|
||||
.sort((a, b) => new Date(b.starredAt ?? b.updatedAt).getTime() - new Date(a.starredAt ?? a.updatedAt).getTime());
|
||||
const unstarred = items.filter((item) => !item.starred);
|
||||
|
||||
const sections = starred.length ? [{ label: "STARRED", items: starred }] : [];
|
||||
return unstarred.reduce<Array<{ label: string; items: SidebarItem[] }>>((sections, item) => {
|
||||
const label = getSidebarSectionLabel(item.updatedAt);
|
||||
const section = sections.find((candidate) => candidate.label === label);
|
||||
if (section) {
|
||||
@@ -699,7 +713,7 @@ function buildSidebarSections(items: SidebarItem[]) {
|
||||
sections.push({ label, items: [item] });
|
||||
}
|
||||
return sections;
|
||||
}, []);
|
||||
}, sections);
|
||||
}
|
||||
|
||||
export default function App() {
|
||||
@@ -1253,6 +1267,11 @@ export default function App() {
|
||||
return chats.find((chat) => chat.id === selectedItem.id) ?? null;
|
||||
}, [chats, selectedItem]);
|
||||
|
||||
const selectedSidebarItem = useMemo(() => {
|
||||
if (!selectedItem) return null;
|
||||
return sidebarItems.find((item) => item.kind === selectedItem.kind && item.id === selectedItem.id) ?? null;
|
||||
}, [selectedItem, sidebarItems]);
|
||||
|
||||
const selectedSearchSummary = useMemo(() => {
|
||||
if (!selectedItem || selectedItem.kind !== "search") return null;
|
||||
return searches.find((search) => search.id === selectedItem.id) ?? null;
|
||||
@@ -1399,6 +1418,57 @@ export default function App() {
|
||||
return sidebarItem?.title ?? "New chat";
|
||||
};
|
||||
|
||||
const applyChatSummary = (updatedChat: ChatSummary, moveToFront = true) => {
|
||||
setChats((current) => {
|
||||
const withoutExisting = current.filter((chat) => chat.id !== updatedChat.id);
|
||||
if (moveToFront) return [updatedChat, ...withoutExisting];
|
||||
const existingIndex = current.findIndex((chat) => chat.id === updatedChat.id);
|
||||
if (existingIndex < 0) return [updatedChat, ...current];
|
||||
const next = [...current];
|
||||
next[existingIndex] = updatedChat;
|
||||
return next;
|
||||
});
|
||||
setWorkspaceItems((current) => upsertWorkspaceItem(current, chatWorkspaceItem(updatedChat), moveToFront));
|
||||
setSelectedChat((current) => {
|
||||
if (!current || current.id !== updatedChat.id) return current;
|
||||
return {
|
||||
...current,
|
||||
title: updatedChat.title,
|
||||
updatedAt: updatedChat.updatedAt,
|
||||
starred: updatedChat.starred,
|
||||
starredAt: updatedChat.starredAt,
|
||||
initiatedProvider: updatedChat.initiatedProvider,
|
||||
initiatedModel: updatedChat.initiatedModel,
|
||||
lastUsedProvider: updatedChat.lastUsedProvider,
|
||||
lastUsedModel: updatedChat.lastUsedModel,
|
||||
};
|
||||
});
|
||||
};
|
||||
|
||||
const applySearchSummary = (updatedSearch: SearchSummary, moveToFront = true) => {
|
||||
setSearches((current) => {
|
||||
const withoutExisting = current.filter((search) => search.id !== updatedSearch.id);
|
||||
if (moveToFront) return [updatedSearch, ...withoutExisting];
|
||||
const existingIndex = current.findIndex((search) => search.id === updatedSearch.id);
|
||||
if (existingIndex < 0) return [updatedSearch, ...current];
|
||||
const next = [...current];
|
||||
next[existingIndex] = updatedSearch;
|
||||
return next;
|
||||
});
|
||||
setWorkspaceItems((current) => upsertWorkspaceItem(current, searchWorkspaceItem(updatedSearch), moveToFront));
|
||||
setSelectedSearch((current) => {
|
||||
if (!current || current.id !== updatedSearch.id) return current;
|
||||
return {
|
||||
...current,
|
||||
title: updatedSearch.title,
|
||||
query: updatedSearch.query,
|
||||
updatedAt: updatedSearch.updatedAt,
|
||||
starred: updatedSearch.starred,
|
||||
starredAt: updatedSearch.starredAt,
|
||||
};
|
||||
});
|
||||
};
|
||||
|
||||
const openRenameChatDialog = (chatId: string) => {
|
||||
setContextMenu(null);
|
||||
setRenameChatDraft(getRenameSeedTitle(chatId));
|
||||
@@ -1409,7 +1479,7 @@ export default function App() {
|
||||
const openContextMenu = (event: MouseEvent, item: SidebarSelection) => {
|
||||
event.preventDefault();
|
||||
const menuWidth = 176;
|
||||
const menuHeight = item.kind === "chat" ? 80 : 40;
|
||||
const menuHeight = item.kind === "chat" ? 120 : 80;
|
||||
const padding = 8;
|
||||
const x = Math.min(event.clientX, window.innerWidth - menuWidth - padding);
|
||||
const y = Math.min(event.clientY, window.innerHeight - menuHeight - padding);
|
||||
@@ -1431,20 +1501,7 @@ export default function App() {
|
||||
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,
|
||||
};
|
||||
});
|
||||
applyChatSummary(updatedChat);
|
||||
setRenameChatDialog(null);
|
||||
setRenameChatDraft("");
|
||||
} catch (err) {
|
||||
@@ -1459,6 +1516,30 @@ export default function App() {
|
||||
}
|
||||
};
|
||||
|
||||
const handleToggleStar = async (target: SidebarSelection) => {
|
||||
const current = sidebarItems.find((item) => item.kind === target.kind && item.id === target.id);
|
||||
const nextStarred = !current?.starred;
|
||||
setContextMenu(null);
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
if (target.kind === "chat") {
|
||||
const updatedChat = await updateChatStar(target.id, nextStarred);
|
||||
applyChatSummary(updatedChat, false);
|
||||
} else {
|
||||
const updatedSearch = await updateSearchStar(target.id, nextStarred);
|
||||
applySearchSummary(updatedSearch, false);
|
||||
}
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : String(err);
|
||||
if (message.includes("bearer token")) {
|
||||
handleAuthFailure(message);
|
||||
} else {
|
||||
setError(message);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const handleDeleteFromContextMenu = async () => {
|
||||
if (!contextMenu || isItemRunning(contextMenu.item)) return;
|
||||
const target = contextMenu.item;
|
||||
@@ -1681,6 +1762,8 @@ export default function App() {
|
||||
title: chat.title,
|
||||
createdAt: chat.createdAt,
|
||||
updatedAt: chat.updatedAt,
|
||||
starred: chat.starred,
|
||||
starredAt: chat.starredAt,
|
||||
initiatedProvider: chat.initiatedProvider,
|
||||
initiatedModel: chat.initiatedModel,
|
||||
lastUsedProvider: chat.lastUsedProvider,
|
||||
@@ -1901,6 +1984,8 @@ export default function App() {
|
||||
query,
|
||||
createdAt: currentSearch?.createdAt ?? nowIso,
|
||||
updatedAt: nowIso,
|
||||
starred: currentSearch?.starred ?? false,
|
||||
starredAt: currentSearch?.starredAt ?? null,
|
||||
requestId: null,
|
||||
latencyMs: null,
|
||||
error: null,
|
||||
@@ -2258,6 +2343,8 @@ export default function App() {
|
||||
title: chat.title,
|
||||
createdAt: chat.createdAt,
|
||||
updatedAt: chat.updatedAt,
|
||||
starred: chat.starred,
|
||||
starredAt: chat.starredAt,
|
||||
initiatedProvider: chat.initiatedProvider,
|
||||
initiatedModel: chat.initiatedModel,
|
||||
lastUsedProvider: chat.lastUsedProvider,
|
||||
@@ -2434,6 +2521,8 @@ export default function App() {
|
||||
title: chat.title,
|
||||
createdAt: chat.createdAt,
|
||||
updatedAt: chat.updatedAt,
|
||||
starred: chat.starred,
|
||||
starredAt: chat.starredAt,
|
||||
initiatedProvider: chat.initiatedProvider,
|
||||
initiatedModel: chat.initiatedModel,
|
||||
lastUsedProvider: chat.lastUsedProvider,
|
||||
@@ -2653,6 +2742,12 @@ export default function App() {
|
||||
</span>
|
||||
<span className="flex min-w-0 flex-1 items-center gap-1.5">
|
||||
<span className="truncate text-sm font-semibold">{item.title}</span>
|
||||
{item.starred ? (
|
||||
<Star
|
||||
className={cn("h-3.5 w-3.5 shrink-0 fill-amber-300", active ? "text-amber-200" : "text-amber-300/90")}
|
||||
aria-label="Starred"
|
||||
/>
|
||||
) : null}
|
||||
{itemIsRunning ? (
|
||||
<LoaderCircle
|
||||
className={cn("h-3.5 w-3.5 shrink-0 animate-spin", active ? "text-cyan-100" : "text-cyan-300/90")}
|
||||
@@ -2693,6 +2788,19 @@ export default function App() {
|
||||
|
||||
<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 ? (
|
||||
<Button
|
||||
type="button"
|
||||
size="icon"
|
||||
variant="ghost"
|
||||
className="h-7 w-7 shrink-0 text-violet-100/72 hover:text-violet-50"
|
||||
onClick={() => void handleToggleStar(selectedItem)}
|
||||
title={selectedSidebarItem?.starred ? "Unstar" : "Star"}
|
||||
aria-label={selectedSidebarItem?.starred ? "Unstar" : "Star"}
|
||||
>
|
||||
<Star className={cn("h-3.5 w-3.5", selectedSidebarItem?.starred ? "fill-amber-300 text-amber-300" : "")} />
|
||||
</Button>
|
||||
) : null}
|
||||
{draftKind === null && selectedItem?.kind === "chat" ? (
|
||||
<Button
|
||||
type="button"
|
||||
@@ -2877,6 +2985,21 @@ export default function App() {
|
||||
style={{ left: contextMenu.x, top: contextMenu.y }}
|
||||
onContextMenu={(event) => event.preventDefault()}
|
||||
>
|
||||
<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={() => void handleToggleStar(contextMenu.item)}
|
||||
>
|
||||
<Star
|
||||
className={cn(
|
||||
"h-3.5 w-3.5",
|
||||
sidebarItems.find((item) => item.kind === contextMenu.item.kind && item.id === contextMenu.item.id)?.starred
|
||||
? "fill-amber-300 text-amber-300"
|
||||
: ""
|
||||
)}
|
||||
/>
|
||||
{sidebarItems.find((item) => item.kind === contextMenu.item.kind && item.id === contextMenu.item.id)?.starred ? "Unstar" : "Star"}
|
||||
</button>
|
||||
{contextMenu.item.kind === "chat" ? (
|
||||
<button
|
||||
type="button"
|
||||
|
||||
@@ -3,6 +3,8 @@ export type ChatSummary = {
|
||||
title: string | null;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
starred: boolean;
|
||||
starredAt: string | null;
|
||||
initiatedProvider: Provider | null;
|
||||
initiatedModel: string | null;
|
||||
lastUsedProvider: Provider | null;
|
||||
@@ -15,6 +17,8 @@ export type SearchSummary = {
|
||||
query: string | null;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
starred: boolean;
|
||||
starredAt: string | null;
|
||||
};
|
||||
|
||||
export type ChatWorkspaceItem = ChatSummary & {
|
||||
@@ -54,6 +58,8 @@ export type ChatDetail = {
|
||||
title: string | null;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
starred: boolean;
|
||||
starredAt: string | null;
|
||||
initiatedProvider: Provider | null;
|
||||
initiatedModel: string | null;
|
||||
lastUsedProvider: Provider | null;
|
||||
@@ -83,6 +89,8 @@ export type SearchDetail = {
|
||||
query: string | null;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
starred: boolean;
|
||||
starredAt: string | null;
|
||||
requestId: string | null;
|
||||
latencyMs: number | null;
|
||||
error: string | null;
|
||||
@@ -263,6 +271,14 @@ export async function updateChatTitle(chatId: string, title: string) {
|
||||
return data.chat;
|
||||
}
|
||||
|
||||
export async function updateChatStar(chatId: string, starred: boolean) {
|
||||
const data = await api<{ chat: ChatSummary }>(`/v1/chats/${chatId}/star`, {
|
||||
method: "PATCH",
|
||||
body: JSON.stringify({ starred }),
|
||||
});
|
||||
return data.chat;
|
||||
}
|
||||
|
||||
export async function suggestChatTitle(body: { chatId: string; content: string }) {
|
||||
const data = await api<{ chat: ChatSummary }>("/v1/chats/title/suggest", {
|
||||
method: "POST",
|
||||
@@ -293,6 +309,14 @@ export async function getSearch(searchId: string) {
|
||||
return data.search;
|
||||
}
|
||||
|
||||
export async function updateSearchStar(searchId: string, starred: boolean) {
|
||||
const data = await api<{ search: SearchSummary }>(`/v1/searches/${searchId}/star`, {
|
||||
method: "PATCH",
|
||||
body: JSON.stringify({ starred }),
|
||||
});
|
||||
return data.search;
|
||||
}
|
||||
|
||||
export async function createChatFromSearch(searchId: string, body?: { title?: string }) {
|
||||
const data = await api<{ chat: ChatSummary }>(`/v1/searches/${searchId}/chat`, {
|
||||
method: "POST",
|
||||
|
||||
@@ -106,6 +106,8 @@ export default function SearchRoutePage() {
|
||||
query: trimmed,
|
||||
createdAt: nowIso,
|
||||
updatedAt: nowIso,
|
||||
starred: false,
|
||||
starredAt: null,
|
||||
requestId: null,
|
||||
latencyMs: null,
|
||||
error: null,
|
||||
@@ -132,6 +134,8 @@ export default function SearchRoutePage() {
|
||||
query: created.query,
|
||||
createdAt: created.createdAt,
|
||||
updatedAt: created.updatedAt,
|
||||
starred: created.starred,
|
||||
starredAt: created.starredAt,
|
||||
}
|
||||
: current
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user