diff --git a/docs/api/rest.md b/docs/api/rest.md index 7d537fc..09c9c44 100644 --- a/docs/api/rest.md +++ b/docs/api/rest.md @@ -57,6 +57,42 @@ Behavior notes: - Clients should use this after app start or page refresh to restore per-row generating indicators. - The lists are not durable across server restarts. +## Workspace Items + +### `GET /v1/workspace-items` +- Response: `{ "items": WorkspaceItem[] }` +- `WorkspaceItem` is a discriminated union sorted by `updatedAt` descending: +```json +{ + "items": [ + { + "type": "chat", + "id": "chat-id", + "title": "optional title", + "createdAt": "2026-02-14T00:00:00.000Z", + "updatedAt": "2026-02-14T00:00:00.000Z", + "initiatedProvider": "openai", + "initiatedModel": "gpt-4.1-mini", + "lastUsedProvider": "openai", + "lastUsedModel": "gpt-4.1-mini" + }, + { + "type": "search", + "id": "search-id", + "title": "optional title", + "query": "search query", + "createdAt": "2026-02-14T00:00:00.000Z", + "updatedAt": "2026-02-14T00:00:00.000Z" + } + ] +} +``` + +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. + ## Chats ### `GET /v1/chats` diff --git a/ios/Apps/Sybil/project.yml b/ios/Apps/Sybil/project.yml index b2bf87c..29cd8e0 100644 --- a/ios/Apps/Sybil/project.yml +++ b/ios/Apps/Sybil/project.yml @@ -24,8 +24,8 @@ targets: GENERATE_INFOPLIST_FILE: YES INFOPLIST_FILE: Apps/Sybil/Info.plist ASSETCATALOG_COMPILER_APPICON_NAME: AppIcon - MARKETING_VERSION: 1.8 - CURRENT_PROJECT_VERSION: 9 + MARKETING_VERSION: 1.9 + CURRENT_PROJECT_VERSION: 10 INFOPLIST_KEY_CFBundleDisplayName: Sybil INFOPLIST_KEY_ITSAppUsesNonExemptEncryption: NO INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents: YES diff --git a/ios/Packages/Sybil/Sources/Sybil/SybilAPIClient.swift b/ios/Packages/Sybil/Sources/Sybil/SybilAPIClient.swift index 5f9ba48..fdc4eb5 100644 --- a/ios/Packages/Sybil/Sources/Sybil/SybilAPIClient.swift +++ b/ios/Packages/Sybil/Sources/Sybil/SybilAPIClient.swift @@ -44,6 +44,11 @@ actor SybilAPIClient: SybilAPIClienting { try await request("/v1/auth/session", method: "GET", responseType: AuthSession.self) } + func listWorkspaceItems() async throws -> [WorkspaceItem] { + let response = try await request("/v1/workspace-items", method: "GET", responseType: WorkspaceListResponse.self) + return response.items + } + func listChats() async throws -> [ChatSummary] { let response = try await request("/v1/chats", method: "GET", responseType: ChatListResponse.self) return response.chats diff --git a/ios/Packages/Sybil/Sources/Sybil/SybilAPIClienting.swift b/ios/Packages/Sybil/Sources/Sybil/SybilAPIClienting.swift index c7e961e..33c2a15 100644 --- a/ios/Packages/Sybil/Sources/Sybil/SybilAPIClienting.swift +++ b/ios/Packages/Sybil/Sources/Sybil/SybilAPIClienting.swift @@ -2,6 +2,7 @@ import Foundation protocol SybilAPIClienting: Sendable { func verifySession() async throws -> AuthSession + func listWorkspaceItems() async throws -> [WorkspaceItem] func listChats() async throws -> [ChatSummary] func createChat( title: String?, diff --git a/ios/Packages/Sybil/Sources/Sybil/SybilModels.swift b/ios/Packages/Sybil/Sources/Sybil/SybilModels.swift index bb0dce4..87f735d 100644 --- a/ios/Packages/Sybil/Sources/Sybil/SybilModels.swift +++ b/ios/Packages/Sybil/Sources/Sybil/SybilModels.swift @@ -168,6 +168,75 @@ public struct SearchSummary: Codable, Identifiable, Hashable, Sendable { public var updatedAt: Date } +public enum WorkspaceItemType: String, Codable, Hashable, Sendable { + case chat + case search +} + +public struct WorkspaceItem: Codable, Identifiable, Hashable, Sendable { + public var type: WorkspaceItemType + public var id: String + public var title: String? + public var query: String? + public var createdAt: Date + public var updatedAt: Date + public var initiatedProvider: Provider? + public var initiatedModel: String? + public var lastUsedProvider: Provider? + public var lastUsedModel: String? + + public init(chat: ChatSummary) { + self.type = .chat + self.id = chat.id + self.title = chat.title + self.query = nil + self.createdAt = chat.createdAt + self.updatedAt = chat.updatedAt + self.initiatedProvider = chat.initiatedProvider + self.initiatedModel = chat.initiatedModel + self.lastUsedProvider = chat.lastUsedProvider + self.lastUsedModel = chat.lastUsedModel + } + + public init(search: SearchSummary) { + self.type = .search + self.id = search.id + self.title = search.title + self.query = search.query + self.createdAt = search.createdAt + self.updatedAt = search.updatedAt + self.initiatedProvider = nil + self.initiatedModel = nil + self.lastUsedProvider = nil + self.lastUsedModel = nil + } + + public var chatSummary: ChatSummary? { + guard type == .chat else { return nil } + return ChatSummary( + id: id, + title: title, + createdAt: createdAt, + updatedAt: updatedAt, + initiatedProvider: initiatedProvider, + initiatedModel: initiatedModel, + lastUsedProvider: lastUsedProvider, + lastUsedModel: lastUsedModel + ) + } + + public var searchSummary: SearchSummary? { + guard type == .search else { return nil } + return SearchSummary( + id: id, + title: title, + query: query, + createdAt: createdAt, + updatedAt: updatedAt + ) + } +} + public struct Message: Codable, Identifiable, Hashable, Sendable { public var id: String public var createdAt: Date @@ -524,6 +593,10 @@ struct SearchListResponse: Codable { var searches: [SearchSummary] } +struct WorkspaceListResponse: Codable { + var items: [WorkspaceItem] +} + struct ChatDetailResponse: Codable { var chat: ChatDetail } diff --git a/ios/Packages/Sybil/Sources/Sybil/SybilViewModel.swift b/ios/Packages/Sybil/Sources/Sybil/SybilViewModel.swift index d01abed..cb566ca 100644 --- a/ios/Packages/Sybil/Sources/Sybil/SybilViewModel.swift +++ b/ios/Packages/Sybil/Sources/Sybil/SybilViewModel.swift @@ -95,6 +95,7 @@ final class SybilViewModel { var chats: [ChatSummary] = [] var searches: [SearchSummary] = [] + var workspaceItems: [WorkspaceItem] = [] var selectedItem: SidebarSelection? var selectedChat: ChatDetail? @@ -388,40 +389,40 @@ final class SybilViewModel { } var sidebarItems: [SidebarItem] { - let chatItems: [SidebarItem] = chats.map { chat in - let initiatedLabel: String? - if let model = chat.initiatedModel?.trimmingCharacters(in: .whitespacesAndNewlines), !model.isEmpty { - if let provider = chat.initiatedProvider { - initiatedLabel = "\(provider.displayName) • \(model)" + workspaceItems.map { item in + switch item.type { + case .chat: + let initiatedLabel: String? + if let model = item.initiatedModel?.trimmingCharacters(in: .whitespacesAndNewlines), !model.isEmpty { + if let provider = item.initiatedProvider { + initiatedLabel = "\(provider.displayName) • \(model)" + } else { + initiatedLabel = model + } } else { - initiatedLabel = model + initiatedLabel = nil } - } else { - initiatedLabel = nil + + return SidebarItem( + selection: .chat(item.id), + kind: .chat, + title: chatTitle(title: item.title, messages: nil), + updatedAt: item.updatedAt, + initiatedLabel: initiatedLabel, + isRunning: isChatRowRunning(item.id) + ) + + case .search: + return SidebarItem( + selection: .search(item.id), + kind: .search, + title: searchTitle(title: item.title, query: item.query), + updatedAt: item.updatedAt, + initiatedLabel: "exa", + isRunning: isSearchRowRunning(item.id) + ) } - - return SidebarItem( - selection: .chat(chat.id), - kind: .chat, - title: chatTitle(title: chat.title, messages: nil), - updatedAt: chat.updatedAt, - initiatedLabel: initiatedLabel, - isRunning: isChatRowRunning(chat.id) - ) } - - let searchItems: [SidebarItem] = searches.map { search in - SidebarItem( - selection: .search(search.id), - kind: .search, - title: searchTitle(title: search.title, query: search.query), - updatedAt: search.updatedAt, - initiatedLabel: "exa", - isRunning: isSearchRowRunning(search.id) - ) - } - - return (chatItems + searchItems).sorted { $0.updatedAt > $1.updatedAt } } var selectedChatSummary: ChatSummary? { @@ -502,6 +503,7 @@ final class SybilViewModel { authMode = nil chats = [] searches = [] + workspaceItems = [] selectedItem = .settings selectedChat = nil selectedSearch = nil @@ -671,6 +673,7 @@ final class SybilViewModel { setProvider(submittedProvider, model: submittedModel) chats.removeAll(where: { $0.id == chat.id }) chats.insert(chat, at: 0) + upsertWorkspaceChat(chat) draftKind = nil selectedItem = .chat(chat.id) selectedChat = ChatDetail( @@ -1034,6 +1037,7 @@ final class SybilViewModel { guard selectedItem == sourceSelection, draftKind == nil else { chats.removeAll(where: { $0.id == chat.id }) chats.insert(chat, at: 0) + upsertWorkspaceChat(chat) isCreatingSearchChat = false return } @@ -1045,6 +1049,7 @@ final class SybilViewModel { chats.removeAll(where: { $0.id == chat.id }) chats.insert(chat, at: 0) + upsertWorkspaceChat(chat) selectedItem = .chat(chat.id) selectedSearch = nil @@ -1148,18 +1153,16 @@ final class SybilViewModel { errorMessage = nil do { - async let chatsValue = client.listChats() - async let searchesValue = client.listSearches() + async let workspaceItemsValue = client.listWorkspaceItems() async let activeRunsValue = client.getActiveRuns() - let (nextChats, nextSearches, nextActiveRuns) = try await (chatsValue, searchesValue, activeRunsValue) + let (nextWorkspaceItems, nextActiveRuns) = try await (workspaceItemsValue, activeRunsValue) - chats = nextChats - searches = nextSearches + applyWorkspaceItems(nextWorkspaceItems) applyActiveRuns(nextActiveRuns) SybilLog.info( SybilLog.app, - "Loaded collections: \(nextChats.count) chats, \(nextSearches.count) searches" + "Loaded collections: \(chats.count) chats, \(searches.count) searches" ) do { @@ -1176,7 +1179,7 @@ final class SybilViewModel { if case .settings = selectedItem { nextSelection = .settings } else if let currentSelection = selectedItem, - hasSelection(currentSelection, chats: nextChats, searches: nextSearches) { + hasSelection(currentSelection, chats: chats, searches: searches) { nextSelection = currentSelection } else { nextSelection = sidebarItems.first?.selection @@ -1248,18 +1251,16 @@ final class SybilViewModel { do { let client = try client() - async let chatsValue = client.listChats() - async let searchesValue = client.listSearches() + async let workspaceItemsValue = client.listWorkspaceItems() async let activeRunsValue = client.getActiveRuns() - let (nextChats, nextSearches, nextActiveRuns) = try await (chatsValue, searchesValue, activeRunsValue) + let (nextWorkspaceItems, nextActiveRuns) = try await (workspaceItemsValue, activeRunsValue) - chats = nextChats - searches = nextSearches + applyWorkspaceItems(nextWorkspaceItems) applyActiveRuns(nextActiveRuns) SybilLog.info( SybilLog.app, - "Refreshed collections: \(nextChats.count) chats, \(nextSearches.count) searches" + "Refreshed collections: \(chats.count) chats, \(searches.count) searches" ) errorMessage = nil @@ -1277,10 +1278,10 @@ final class SybilViewModel { } if let preferredSelection, - hasSelection(preferredSelection, chats: nextChats, searches: nextSearches) { + hasSelection(preferredSelection, chats: chats, searches: searches) { selectedItem = preferredSelection } else if let existing = selectedItem, - hasSelection(existing, chats: nextChats, searches: nextSearches) { + hasSelection(existing, chats: chats, searches: searches) { selectedItem = existing } else { selectedItem = sidebarItems.first?.selection @@ -1374,6 +1375,34 @@ final class SybilViewModel { serverActiveSearchIDs = Set(activeRuns.searches) } + private func applyWorkspaceItems(_ items: [WorkspaceItem]) { + workspaceItems = items + chats = items.compactMap(\.chatSummary) + searches = items.compactMap(\.searchSummary) + } + + private func upsertWorkspaceChat(_ chat: ChatSummary, moveToFront: Bool = true) { + upsertWorkspaceItem(WorkspaceItem(chat: chat), moveToFront: moveToFront) + } + + private func upsertWorkspaceSearch(_ search: SearchSummary, moveToFront: Bool = true) { + upsertWorkspaceItem(WorkspaceItem(search: search), moveToFront: moveToFront) + } + + private func upsertWorkspaceItem(_ item: WorkspaceItem, moveToFront: Bool) { + if let existingIndex = workspaceItems.firstIndex(where: { $0.type == item.type && $0.id == item.id }) { + workspaceItems.remove(at: existingIndex) + if moveToFront { + workspaceItems.insert(item, at: 0) + } else { + workspaceItems.insert(item, at: existingIndex) + } + return + } + + workspaceItems.insert(item, at: 0) + } + private func attachToVisibleActiveRunIfNeeded() { guard draftKind == nil else { return @@ -1705,6 +1734,7 @@ final class SybilViewModel { chats.removeAll(where: { $0.id == created.id }) chats.insert(created, at: 0) + upsertWorkspaceChat(created) if shouldShowCreatedChat { draftKind = nil @@ -1781,6 +1811,7 @@ final class SybilViewModel { } return existing } + self.upsertWorkspaceChat(updated, moveToFront: false) if self.selectedChat?.id == updated.id { self.selectedChat?.title = updated.title @@ -1918,6 +1949,7 @@ final class SybilViewModel { searches.removeAll(where: { $0.id == created.id }) searches.insert(created, at: 0) + upsertWorkspaceSearch(created) if shouldShowCreatedSearch { draftKind = nil diff --git a/ios/Packages/Sybil/Tests/SybilTests/SybilTests.swift b/ios/Packages/Sybil/Tests/SybilTests/SybilTests.swift index 8a13dc2..b58c0b3 100644 --- a/ios/Packages/Sybil/Tests/SybilTests/SybilTests.swift +++ b/ios/Packages/Sybil/Tests/SybilTests/SybilTests.swift @@ -4,6 +4,7 @@ import Testing @testable import Sybil private struct MockClientCallSnapshot: Sendable { + var listWorkspaceItems = 0 var listChats = 0 var listSearches = 0 var createChat = 0 @@ -27,6 +28,7 @@ private struct UnexpectedClientCall: Error {} private actor MockSybilClient: SybilAPIClienting { private let chatsResponse: [ChatSummary] private let searchesResponse: [SearchSummary] + private let workspaceItemsResponse: [WorkspaceItem] private let chatDetails: [String: ChatDetail] private let searchDetails: [String: SearchDetail] private let createChatResponse: ChatSummary? @@ -55,16 +57,22 @@ private actor MockSybilClient: SybilAPIClienting { chatDetails: [String: ChatDetail] = [:], searchDetails: [String: SearchDetail] = [:], createChatResponse: ChatSummary? = nil, - activeRunsResponse: ActiveRunsResponse = ActiveRunsResponse() + activeRunsResponse: ActiveRunsResponse = ActiveRunsResponse(), + workspaceItemsResponse: [WorkspaceItem]? = nil ) { self.chatsResponse = chatsResponse self.searchesResponse = searchesResponse + self.workspaceItemsResponse = workspaceItemsResponse ?? Self.makeWorkspaceItems(chats: chatsResponse, searches: searchesResponse) self.chatDetails = chatDetails self.searchDetails = searchDetails self.createChatResponse = createChatResponse self.activeRunsResponse = activeRunsResponse } + private static func makeWorkspaceItems(chats: [ChatSummary], searches: [SearchSummary]) -> [WorkspaceItem] { + (chats.map { WorkspaceItem(chat: $0) } + searches.map { WorkspaceItem(search: $0) }).sorted { $0.updatedAt > $1.updatedAt } + } + func currentSnapshot() -> MockClientCallSnapshot { snapshot } @@ -127,6 +135,15 @@ private actor MockSybilClient: SybilAPIClienting { AuthSession(authenticated: true, mode: "open") } + func listWorkspaceItems() async throws -> [WorkspaceItem] { + snapshot.listWorkspaceItems += 1 + let delay = max(listChatsDelayNanoseconds, listSearchesDelayNanoseconds) + if delay > 0 { + try await Task.sleep(nanoseconds: delay) + } + return workspaceItemsResponse + } + func listChats() async throws -> [ChatSummary] { snapshot.listChats += 1 if listChatsDelayNanoseconds > 0 { @@ -389,8 +406,9 @@ private func makeSearchDetail(id: String, date: Date, answer: String) -> SearchD await viewModel.refreshVisibleContent(refreshCollections: true, refreshSelection: false) let snapshot = await client.currentSnapshot() - #expect(snapshot.listChats == 1) - #expect(snapshot.listSearches == 1) + #expect(snapshot.listWorkspaceItems == 1) + #expect(snapshot.listChats == 0) + #expect(snapshot.listSearches == 0) #expect(snapshot.getChat == 0) #expect(snapshot.getSearch == 0) #expect(viewModel.selectedItem == .chat("chat-1")) @@ -436,6 +454,7 @@ private func makeSearchDetail(id: String, date: Date, answer: String) -> SearchD await viewModel.refreshVisibleContent(refreshCollections: false, refreshSelection: true) let snapshot = await client.currentSnapshot() + #expect(snapshot.listWorkspaceItems == 0) #expect(snapshot.listChats == 0) #expect(snapshot.listSearches == 0) #expect(snapshot.getChat == 1) @@ -455,6 +474,7 @@ private func makeSearchDetail(id: String, date: Date, answer: String) -> SearchD await viewModel.refreshVisibleContent(refreshCollections: false, refreshSelection: true) let snapshot = await client.currentSnapshot() + #expect(snapshot.listWorkspaceItems == 0) #expect(snapshot.listChats == 0) #expect(snapshot.listSearches == 0) #expect(snapshot.getSearch == 1) diff --git a/server/src/routes.ts b/server/src/routes.ts index 139c517..4a58109 100644 --- a/server/src/routes.ts +++ b/server/src/routes.ts @@ -326,6 +326,39 @@ function getErrorMessage(err: unknown) { return err instanceof Error ? err.message : String(err); } +function compareUpdatedAtDesc(a: { updatedAt: Date | string }, b: { updatedAt: Date | string }) { + return new Date(b.updatedAt).getTime() - new Date(a.updatedAt).getTime(); +} + +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, + }, + }), + prisma.search.findMany({ + orderBy: { updatedAt: "desc" }, + take: 100, + select: { id: true, title: true, query: true, createdAt: true, updatedAt: true }, + }), + ]); + + return [ + ...chats.map((chat) => ({ type: "chat" as const, ...serializeProviderFields(chat) })), + ...searches.map((search) => ({ type: "search" as const, ...search })), + ].sort(compareUpdatedAtDesc); +} + function writeSseEvent(reply: FastifyReply, event: SseStreamEvent) { if (reply.raw.destroyed || reply.raw.writableEnded) return; reply.raw.write(`event: ${event.event}\n`); @@ -578,6 +611,11 @@ export async function registerRoutes(app: FastifyInstance) { }; }); + app.get("/v1/workspace-items", async (req) => { + requireAdmin(req); + return { items: await listWorkspaceItems() }; + }); + app.get("/v1/chats", async (req) => { requireAdmin(req); const chats = await prisma.chat.findMany({ diff --git a/tui/src/api.ts b/tui/src/api.ts index fe7698a..2a81506 100644 --- a/tui/src/api.ts +++ b/tui/src/api.ts @@ -10,6 +10,7 @@ import type { SearchStreamHandlers, SearchSummary, SessionStatus, + WorkspaceItem, } from "./types.js"; type RequestOptions = { @@ -41,6 +42,11 @@ export class SybilApiClient { return data.chats; } + async listWorkspaceItems() { + const data = await this.request<{ items: WorkspaceItem[] }>("/v1/workspace-items"); + return data.items; + } + async createChat(title?: string) { const data = await this.request<{ chat: ChatSummary }>("/v1/chats", { method: "POST", diff --git a/tui/src/index.ts b/tui/src/index.ts index 9c5af6c..59c4fad 100644 --- a/tui/src/index.ts +++ b/tui/src/index.ts @@ -11,6 +11,7 @@ import type { SearchDetail, SearchSummary, ToolCallEvent, + WorkspaceItem, } from "./types.js"; type SidebarSelection = { kind: "chat" | "search"; id: string }; @@ -93,9 +94,38 @@ function getSearchTitle(search: Pick) { return "New search"; } -function buildSidebarItems(chats: ChatSummary[], searches: SearchSummary[]): SidebarItem[] { - const items: SidebarItem[] = [ - ...chats.map((chat) => ({ +function chatWorkspaceItem(chat: ChatSummary): WorkspaceItem { + return { type: "chat", ...chat }; +} + +function searchWorkspaceItem(search: SearchSummary): WorkspaceItem { + return { type: "search", ...search }; +} + +function splitWorkspaceItems(items: WorkspaceItem[]) { + const chats: ChatSummary[] = []; + const searches: SearchSummary[] = []; + for (const item of items) { + if (item.type === "chat") { + const { type: _type, ...chat } = item; + chats.push(chat); + } else { + const { type: _type, ...search } = item; + searches.push(search); + } + } + return { chats, searches }; +} + +function upsertWorkspaceItem(items: WorkspaceItem[], item: WorkspaceItem) { + return [item, ...items.filter((existing) => existing.type !== item.type || existing.id !== item.id)]; +} + +function buildSidebarItems(items: WorkspaceItem[]): SidebarItem[] { + return items.map((item) => { + if (item.type === "chat") { + const chat = item; + return { kind: "chat" as const, id: chat.id, title: getChatTitle(chat), @@ -105,8 +135,11 @@ function buildSidebarItems(chats: ChatSummary[], searches: SearchSummary[]): Sid initiatedModel: chat.initiatedModel, lastUsedProvider: chat.lastUsedProvider, lastUsedModel: chat.lastUsedModel, - })), - ...searches.map((search) => ({ + }; + } + + const search = item; + return { kind: "search" as const, id: search.id, title: getSearchTitle(search), @@ -116,10 +149,8 @@ function buildSidebarItems(chats: ChatSummary[], searches: SearchSummary[]): Sid initiatedModel: null, lastUsedProvider: null, lastUsedModel: null, - })), - ]; - - return items.sort((a, b) => new Date(b.updatedAt).getTime() - new Date(a.updatedAt).getTime()); + }; + }); } function asToolLogMetadata(value: unknown): ToolLogMetadata | null { @@ -195,6 +226,7 @@ async function main() { let authMode: "open" | "token" | null = null; let chats: ChatSummary[] = []; let searches: SearchSummary[] = []; + let workspaceItems: WorkspaceItem[] = []; let selectedItem: SidebarSelection | null = null; let selectedChat: ChatDetail | null = null; let selectedSearch: SearchDetail | null = null; @@ -377,7 +409,7 @@ async function main() { } function getSidebarItems() { - return buildSidebarItems(chats, searches); + return buildSidebarItems(workspaceItems); } function getSelectedChatSummary() { @@ -701,6 +733,7 @@ async function main() { function resetWorkspaceState() { chats = []; searches = []; + workspaceItems = []; selectedItem = null; selectedChat = null; selectedSearch = null; @@ -767,11 +800,13 @@ async function main() { updateUI(); try { - const [nextChats, nextSearches] = await Promise.all([api.listChats(), api.listSearches()]); + const nextWorkspaceItems = await api.listWorkspaceItems(); + const { chats: nextChats, searches: nextSearches } = splitWorkspaceItems(nextWorkspaceItems); + workspaceItems = nextWorkspaceItems; chats = nextChats; searches = nextSearches; - const nextItems = buildSidebarItems(nextChats, nextSearches); + const nextItems = buildSidebarItems(nextWorkspaceItems); if (options?.preferredSelection && hasItem(nextItems, options.preferredSelection)) { selectedItem = options.preferredSelection; draftKind = null; @@ -876,6 +911,7 @@ async function main() { try { const updated = await api.suggestChatTitle({ chatId, content }); chats = chats.map((chat) => (chat.id === updated.id ? { ...chat, title: updated.title, updatedAt: updated.updatedAt } : 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 }; } @@ -920,6 +956,7 @@ async function main() { chatId = chat.id; draftKind = null; chats = [chat, ...chats.filter((existing) => existing.id !== chat.id)]; + workspaceItems = upsertWorkspaceItem(workspaceItems, chatWorkspaceItem(chat)); selectedItem = { kind: "chat", id: chat.id }; pendingChatState = pendingChatState ? { ...pendingChatState, chatId } : pendingChatState; selectedChat = { @@ -1085,6 +1122,7 @@ async function main() { draftKind = null; selectedItem = { kind: "search", id: searchId }; searches = [search, ...searches.filter((existing) => existing.id !== search.id)]; + workspaceItems = upsertWorkspaceItem(workspaceItems, searchWorkspaceItem(search)); selectedChat = null; forceScrollToBottom = true; updateUI(); diff --git a/tui/src/types.ts b/tui/src/types.ts index 2af459c..c6b3232 100644 --- a/tui/src/types.ts +++ b/tui/src/types.ts @@ -29,6 +29,16 @@ export type SearchSummary = { updatedAt: string; }; +export type ChatWorkspaceItem = ChatSummary & { + type: "chat"; +}; + +export type SearchWorkspaceItem = SearchSummary & { + type: "search"; +}; + +export type WorkspaceItem = ChatWorkspaceItem | SearchWorkspaceItem; + export type Message = { id: string; createdAt: string; diff --git a/web/src/App.tsx b/web/src/App.tsx index 451609b..3caed39 100644 --- a/web/src/App.tsx +++ b/web/src/App.tsx @@ -20,8 +20,7 @@ import { getChat, listModels, getSearch, - listChats, - listSearches, + listWorkspaceItems, runCompletionStream, runSearchStream, suggestChatTitle, @@ -37,6 +36,7 @@ import { type SearchDetail, type SearchSummary, type ToolCallEvent, + type WorkspaceItem, } from "@/lib/api"; import { useSessionAuth } from "@/hooks/use-session-auth"; import { cn } from "@/lib/utils"; @@ -588,20 +588,48 @@ function getSearchTitle(search: Pick) { return "New search"; } -function buildSidebarItems(chats: ChatSummary[], searches: SearchSummary[]): SidebarItem[] { - const items: SidebarItem[] = [ - ...chats.map((chat) => ({ - kind: "chat" as const, - id: chat.id, - title: getChatTitle(chat), - updatedAt: chat.updatedAt, - createdAt: chat.createdAt, - initiatedProvider: chat.initiatedProvider, - initiatedModel: chat.initiatedModel, - lastUsedProvider: chat.lastUsedProvider, - lastUsedModel: chat.lastUsedModel, - })), - ...searches.map((search) => ({ +function chatWorkspaceItem(chat: ChatSummary): WorkspaceItem { + return { type: "chat", ...chat }; +} + +function searchWorkspaceItem(search: SearchSummary): WorkspaceItem { + return { type: "search", ...search }; +} + +function splitWorkspaceItems(items: WorkspaceItem[]) { + const chats: ChatSummary[] = []; + const searches: SearchSummary[] = []; + for (const item of items) { + if (item.type === "chat") { + const { type: _type, ...chat } = item; + chats.push(chat); + } else { + const { type: _type, ...search } = item; + searches.push(search); + } + } + return { chats, searches }; +} + +function buildSidebarItems(items: WorkspaceItem[]): SidebarItem[] { + return items.map((item) => { + if (item.type === "chat") { + const chat = item; + return { + kind: "chat" as const, + id: chat.id, + title: getChatTitle(chat), + updatedAt: chat.updatedAt, + createdAt: chat.createdAt, + initiatedProvider: chat.initiatedProvider, + initiatedModel: chat.initiatedModel, + lastUsedProvider: chat.lastUsedProvider, + lastUsedModel: chat.lastUsedModel, + }; + } + + const search = item; + return { kind: "search" as const, id: search.id, title: getSearchTitle(search), @@ -611,10 +639,21 @@ function buildSidebarItems(chats: ChatSummary[], searches: SearchSummary[]): Sid initiatedModel: null, lastUsedProvider: null, lastUsedModel: null, - })), - ]; + }; + }); +} - return items.sort((a, b) => new Date(b.updatedAt).getTime() - new Date(a.updatedAt).getTime()); +function upsertWorkspaceItem(items: WorkspaceItem[], item: WorkspaceItem, moveToFront = true) { + const withoutExisting = items.filter((existing) => existing.type !== item.type || existing.id !== item.id); + if (moveToFront) { + return [item, ...withoutExisting]; + } + + const existingIndex = items.findIndex((existing) => existing.type === item.type && existing.id === item.id); + if (existingIndex < 0) return [item, ...items]; + const next = [...items]; + next[existingIndex] = item; + return next; } function buildActiveRunsState(activeRuns: ActiveRunsResponse): ActiveRunsState { @@ -675,6 +714,7 @@ export default function App() { const [chats, setChats] = useState([]); const [searches, setSearches] = useState([]); + const [workspaceItems, setWorkspaceItems] = useState([]); const [selectedItem, setSelectedItem] = useState(null); const [selectedChat, setSelectedChat] = useState(null); const [selectedSearch, setSelectedSearch] = useState(null); @@ -801,7 +841,7 @@ export default function App() { pendingAttachmentsRef.current = pendingAttachments; }, [pendingAttachments]); - const sidebarItems = useMemo(() => buildSidebarItems(chats, searches), [chats, searches]); + const sidebarItems = useMemo(() => buildSidebarItems(workspaceItems), [workspaceItems]); const filteredSidebarItems = useMemo(() => { const query = sidebarQuery.trim().toLowerCase(); if (!query) return sidebarItems; @@ -817,6 +857,7 @@ export default function App() { const resetWorkspaceState = () => { setChats([]); setSearches([]); + setWorkspaceItems([]); setSelectedItem(null); setSelectedChat(null); setSelectedSearch(null); @@ -852,15 +893,16 @@ export default function App() { const refreshCollections = async (preferredSelection?: SidebarSelection) => { setIsLoadingCollections(true); try { - const [nextChats, nextSearches] = await Promise.all([listChats(), listSearches()]); - const nextItems = buildSidebarItems(nextChats, nextSearches); + const nextWorkspaceItems = await listWorkspaceItems(); + const { chats: nextChats, searches: nextSearches } = splitWorkspaceItems(nextWorkspaceItems); + setWorkspaceItems(nextWorkspaceItems); setChats(nextChats); setSearches(nextSearches); setSelectedItem((current) => { const hasItem = (candidate: SidebarSelection | null) => { if (!candidate) return false; - return nextItems.some((item) => item.kind === candidate.kind && item.id === candidate.id); + return nextWorkspaceItems.some((item) => item.type === candidate.kind && item.id === candidate.id); }; if (preferredSelection && hasItem(preferredSelection)) { @@ -869,8 +911,8 @@ export default function App() { if (hasItem(current)) { return current; } - const first = nextItems[0]; - return first ? { kind: first.kind, id: first.id } : null; + const first = nextWorkspaceItems[0]; + return first ? { kind: first.type, id: first.id } : null; }); } catch (err) { const message = err instanceof Error ? err.message : String(err); @@ -1551,6 +1593,7 @@ export default function App() { const withoutExisting = current.filter((existing) => existing.id !== chat.id); return [chat, ...withoutExisting]; }); + setWorkspaceItems((current) => upsertWorkspaceItem(current, chatWorkspaceItem(chat))); setSelectedItem({ kind: "chat", id: chatId }); setSelectedChat({ id: chat.id, @@ -1616,6 +1659,7 @@ export default function App() { return { ...chat, title: updatedChat.title, updatedAt: updatedChat.updatedAt }; }) ); + setWorkspaceItems((current) => upsertWorkspaceItem(current, chatWorkspaceItem(updatedChat), false)); setSelectedChat((current) => { if (!current || current.id !== updatedChat.id) return current; return { ...current, title: updatedChat.title, updatedAt: updatedChat.updatedAt }; @@ -1748,6 +1792,11 @@ export default function App() { searchId = search.id; setDraftKind(null); setSelectedItem({ kind: "search", id: searchId }); + setSearches((current) => { + const withoutExisting = current.filter((existing) => existing.id !== search.id); + return [search, ...withoutExisting]; + }); + setWorkspaceItems((current) => upsertWorkspaceItem(current, searchWorkspaceItem(search))); } if (!searchId) { @@ -2121,6 +2170,7 @@ export default function App() { const withoutExisting = current.filter((existing) => existing.id !== chat.id); return [chat, ...withoutExisting]; }); + setWorkspaceItems((current) => upsertWorkspaceItem(current, chatWorkspaceItem(chat))); setSelectedItem({ kind: "chat", id: chat.id }); setSelectedChat({ id: chat.id, @@ -2296,6 +2346,7 @@ export default function App() { const withoutExisting = current.filter((existing) => existing.id !== chat.id); return [chat, ...withoutExisting]; }); + setWorkspaceItems((current) => upsertWorkspaceItem(current, chatWorkspaceItem(chat))); setSelectedItem({ kind: "chat", id: chat.id }); setSelectedChat({ id: chat.id, diff --git a/web/src/lib/api.ts b/web/src/lib/api.ts index cbd86d9..5a4b9f1 100644 --- a/web/src/lib/api.ts +++ b/web/src/lib/api.ts @@ -17,6 +17,16 @@ export type SearchSummary = { updatedAt: string; }; +export type ChatWorkspaceItem = ChatSummary & { + type: "chat"; +}; + +export type SearchWorkspaceItem = SearchSummary & { + type: "search"; +}; + +export type WorkspaceItem = ChatWorkspaceItem | SearchWorkspaceItem; + export type Message = { id: string; createdAt: string; @@ -214,6 +224,11 @@ export async function listChats() { return data.chats; } +export async function listWorkspaceItems() { + const data = await api<{ items: WorkspaceItem[] }>("/v1/workspace-items"); + return data.items; +} + export async function verifySession() { return api<{ authenticated: true; mode: "open" | "token" }>("/v1/auth/session"); }