diff --git a/docs/api/rest.md b/docs/api/rest.md index d1624bd..c0db6fa 100644 --- a/docs/api/rest.md +++ b/docs/api/rest.md @@ -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, diff --git a/ios/Packages/Sybil/Sources/Sybil/SybilAPIClient.swift b/ios/Packages/Sybil/Sources/Sybil/SybilAPIClient.swift index 7cfb4b3..4dd09a7 100644 --- a/ios/Packages/Sybil/Sources/Sybil/SybilAPIClient.swift +++ b/ios/Packages/Sybil/Sources/Sybil/SybilAPIClient.swift @@ -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? diff --git a/ios/Packages/Sybil/Sources/Sybil/SybilAPIClienting.swift b/ios/Packages/Sybil/Sources/Sybil/SybilAPIClienting.swift index 5e46f5a..c26ceba 100644 --- a/ios/Packages/Sybil/Sources/Sybil/SybilAPIClienting.swift +++ b/ios/Packages/Sybil/Sources/Sybil/SybilAPIClienting.swift @@ -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 diff --git a/ios/Packages/Sybil/Sources/Sybil/SybilModels.swift b/ios/Packages/Sybil/Sources/Sybil/SybilModels.swift index 87f735d..c368e06 100644 --- a/ios/Packages/Sybil/Sources/Sybil/SybilModels.swift +++ b/ios/Packages/Sybil/Sources/Sybil/SybilModels.swift @@ -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? diff --git a/ios/Packages/Sybil/Sources/Sybil/SybilSidebarView.swift b/ios/Packages/Sybil/Sources/Sybil/SybilSidebarView.swift index 436522b..1348d18 100644 --- a/ios/Packages/Sybil/Sources/Sybil/SybilSidebarView.swift +++ b/ios/Packages/Sybil/Sources/Sybil/SybilSidebarView.swift @@ -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 { diff --git a/ios/Packages/Sybil/Sources/Sybil/SybilViewModel.swift b/ios/Packages/Sybil/Sources/Sybil/SybilViewModel.swift index f0e14c6..afd97aa 100644 --- a/ios/Packages/Sybil/Sources/Sybil/SybilViewModel.swift +++ b/ios/Packages/Sybil/Sources/Sybil/SybilViewModel.swift @@ -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, diff --git a/ios/Packages/Sybil/Tests/SybilTests/SybilTests.swift b/ios/Packages/Sybil/Tests/SybilTests/SybilTests.swift index 198688b..2c938ce 100644 --- a/ios/Packages/Sybil/Tests/SybilTests/SybilTests.swift +++ b/ios/Packages/Sybil/Tests/SybilTests/SybilTests.swift @@ -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) diff --git a/server/prisma/migrations/20260529090000_add_project_starred_items/migration.sql b/server/prisma/migrations/20260529090000_add_project_starred_items/migration.sql new file mode 100644 index 0000000..c33cd88 --- /dev/null +++ b/server/prisma/migrations/20260529090000_add_project_starred_items/migration.sql @@ -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"); diff --git a/server/prisma/schema.prisma b/server/prisma/schema.prisma index a9d3dd3..5562a08 100644 --- a/server/prisma/schema.prisma +++ b/server/prisma/schema.prisma @@ -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]) +} diff --git a/server/src/routes.ts b/server/src/routes.ts index e41712b..6646890 100644 --- a/server/src/routes.ts +++ b/server/src/routes.ts @@ -321,6 +321,34 @@ type SearchRunRequest = z.infer; const activeChatStreams = new Map(); const activeSearchStreams = new Map(); +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>(chat: T) { + const { projectItems: _projectItems, ...rest } = chat; + return { + ...serializeProviderFields(rest), + ...serializeStarFields(chat), + }; +} + +function serializeSearchLike>(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) => { diff --git a/tui/src/api.ts b/tui/src/api.ts index 4266fe8..85f8da7 100644 --- a/tui/src/api.ts +++ b/tui/src/api.ts @@ -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" }); } diff --git a/tui/src/index.ts b/tui/src/index.ts index 75655fc..9e62ff1 100644 --- a/tui/src/index.ts +++ b/tui/src/index.ts @@ -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; diff --git a/tui/src/types.ts b/tui/src/types.ts index c6b3232..2700efe 100644 --- a/tui/src/types.ts +++ b/tui/src/types.ts @@ -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; diff --git a/web/src/App.tsx b/web/src/App.tsx index 4776acd..fa9951a 100644 --- a/web/src/App.tsx +++ b/web/src/App.tsx @@ -1,5 +1,5 @@ import { useEffect, useMemo, useRef, useState } from "preact/hooks"; -import { Check, ChevronDown, Globe2, LoaderCircle, Menu, MessageSquare, Paperclip, 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>((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>((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() { {item.title} + {item.starred ? ( + + ) : null} {itemIsRunning ? (

{selectedTitle}

+ {draftKind === null && selectedItem ? ( + + ) : null} {draftKind === null && selectedItem?.kind === "chat" ? ( {contextMenu.item.kind === "chat" ? (