introduces workspace items as combined search+chat model
This commit is contained in:
@@ -57,6 +57,42 @@ Behavior notes:
|
|||||||
- Clients should use this after app start or page refresh to restore per-row generating indicators.
|
- Clients should use this after app start or page refresh to restore per-row generating indicators.
|
||||||
- The lists are not durable across server restarts.
|
- 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
|
## Chats
|
||||||
|
|
||||||
### `GET /v1/chats`
|
### `GET /v1/chats`
|
||||||
|
|||||||
@@ -24,8 +24,8 @@ targets:
|
|||||||
GENERATE_INFOPLIST_FILE: YES
|
GENERATE_INFOPLIST_FILE: YES
|
||||||
INFOPLIST_FILE: Apps/Sybil/Info.plist
|
INFOPLIST_FILE: Apps/Sybil/Info.plist
|
||||||
ASSETCATALOG_COMPILER_APPICON_NAME: AppIcon
|
ASSETCATALOG_COMPILER_APPICON_NAME: AppIcon
|
||||||
MARKETING_VERSION: 1.8
|
MARKETING_VERSION: 1.9
|
||||||
CURRENT_PROJECT_VERSION: 9
|
CURRENT_PROJECT_VERSION: 10
|
||||||
INFOPLIST_KEY_CFBundleDisplayName: Sybil
|
INFOPLIST_KEY_CFBundleDisplayName: Sybil
|
||||||
INFOPLIST_KEY_ITSAppUsesNonExemptEncryption: NO
|
INFOPLIST_KEY_ITSAppUsesNonExemptEncryption: NO
|
||||||
INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents: YES
|
INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents: YES
|
||||||
|
|||||||
@@ -44,6 +44,11 @@ actor SybilAPIClient: SybilAPIClienting {
|
|||||||
try await request("/v1/auth/session", method: "GET", responseType: AuthSession.self)
|
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] {
|
func listChats() async throws -> [ChatSummary] {
|
||||||
let response = try await request("/v1/chats", method: "GET", responseType: ChatListResponse.self)
|
let response = try await request("/v1/chats", method: "GET", responseType: ChatListResponse.self)
|
||||||
return response.chats
|
return response.chats
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import Foundation
|
|||||||
|
|
||||||
protocol SybilAPIClienting: Sendable {
|
protocol SybilAPIClienting: Sendable {
|
||||||
func verifySession() async throws -> AuthSession
|
func verifySession() async throws -> AuthSession
|
||||||
|
func listWorkspaceItems() async throws -> [WorkspaceItem]
|
||||||
func listChats() async throws -> [ChatSummary]
|
func listChats() async throws -> [ChatSummary]
|
||||||
func createChat(
|
func createChat(
|
||||||
title: String?,
|
title: String?,
|
||||||
|
|||||||
@@ -168,6 +168,75 @@ public struct SearchSummary: Codable, Identifiable, Hashable, Sendable {
|
|||||||
public var updatedAt: Date
|
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 struct Message: Codable, Identifiable, Hashable, Sendable {
|
||||||
public var id: String
|
public var id: String
|
||||||
public var createdAt: Date
|
public var createdAt: Date
|
||||||
@@ -524,6 +593,10 @@ struct SearchListResponse: Codable {
|
|||||||
var searches: [SearchSummary]
|
var searches: [SearchSummary]
|
||||||
}
|
}
|
||||||
|
|
||||||
|
struct WorkspaceListResponse: Codable {
|
||||||
|
var items: [WorkspaceItem]
|
||||||
|
}
|
||||||
|
|
||||||
struct ChatDetailResponse: Codable {
|
struct ChatDetailResponse: Codable {
|
||||||
var chat: ChatDetail
|
var chat: ChatDetail
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -95,6 +95,7 @@ final class SybilViewModel {
|
|||||||
|
|
||||||
var chats: [ChatSummary] = []
|
var chats: [ChatSummary] = []
|
||||||
var searches: [SearchSummary] = []
|
var searches: [SearchSummary] = []
|
||||||
|
var workspaceItems: [WorkspaceItem] = []
|
||||||
|
|
||||||
var selectedItem: SidebarSelection?
|
var selectedItem: SidebarSelection?
|
||||||
var selectedChat: ChatDetail?
|
var selectedChat: ChatDetail?
|
||||||
@@ -388,10 +389,12 @@ final class SybilViewModel {
|
|||||||
}
|
}
|
||||||
|
|
||||||
var sidebarItems: [SidebarItem] {
|
var sidebarItems: [SidebarItem] {
|
||||||
let chatItems: [SidebarItem] = chats.map { chat in
|
workspaceItems.map { item in
|
||||||
|
switch item.type {
|
||||||
|
case .chat:
|
||||||
let initiatedLabel: String?
|
let initiatedLabel: String?
|
||||||
if let model = chat.initiatedModel?.trimmingCharacters(in: .whitespacesAndNewlines), !model.isEmpty {
|
if let model = item.initiatedModel?.trimmingCharacters(in: .whitespacesAndNewlines), !model.isEmpty {
|
||||||
if let provider = chat.initiatedProvider {
|
if let provider = item.initiatedProvider {
|
||||||
initiatedLabel = "\(provider.displayName) • \(model)"
|
initiatedLabel = "\(provider.displayName) • \(model)"
|
||||||
} else {
|
} else {
|
||||||
initiatedLabel = model
|
initiatedLabel = model
|
||||||
@@ -401,27 +404,25 @@ final class SybilViewModel {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return SidebarItem(
|
return SidebarItem(
|
||||||
selection: .chat(chat.id),
|
selection: .chat(item.id),
|
||||||
kind: .chat,
|
kind: .chat,
|
||||||
title: chatTitle(title: chat.title, messages: nil),
|
title: chatTitle(title: item.title, messages: nil),
|
||||||
updatedAt: chat.updatedAt,
|
updatedAt: item.updatedAt,
|
||||||
initiatedLabel: initiatedLabel,
|
initiatedLabel: initiatedLabel,
|
||||||
isRunning: isChatRowRunning(chat.id)
|
isRunning: isChatRowRunning(item.id)
|
||||||
)
|
)
|
||||||
}
|
|
||||||
|
|
||||||
let searchItems: [SidebarItem] = searches.map { search in
|
case .search:
|
||||||
SidebarItem(
|
return SidebarItem(
|
||||||
selection: .search(search.id),
|
selection: .search(item.id),
|
||||||
kind: .search,
|
kind: .search,
|
||||||
title: searchTitle(title: search.title, query: search.query),
|
title: searchTitle(title: item.title, query: item.query),
|
||||||
updatedAt: search.updatedAt,
|
updatedAt: item.updatedAt,
|
||||||
initiatedLabel: "exa",
|
initiatedLabel: "exa",
|
||||||
isRunning: isSearchRowRunning(search.id)
|
isRunning: isSearchRowRunning(item.id)
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
}
|
||||||
return (chatItems + searchItems).sorted { $0.updatedAt > $1.updatedAt }
|
|
||||||
}
|
}
|
||||||
|
|
||||||
var selectedChatSummary: ChatSummary? {
|
var selectedChatSummary: ChatSummary? {
|
||||||
@@ -502,6 +503,7 @@ final class SybilViewModel {
|
|||||||
authMode = nil
|
authMode = nil
|
||||||
chats = []
|
chats = []
|
||||||
searches = []
|
searches = []
|
||||||
|
workspaceItems = []
|
||||||
selectedItem = .settings
|
selectedItem = .settings
|
||||||
selectedChat = nil
|
selectedChat = nil
|
||||||
selectedSearch = nil
|
selectedSearch = nil
|
||||||
@@ -671,6 +673,7 @@ final class SybilViewModel {
|
|||||||
setProvider(submittedProvider, model: submittedModel)
|
setProvider(submittedProvider, model: submittedModel)
|
||||||
chats.removeAll(where: { $0.id == chat.id })
|
chats.removeAll(where: { $0.id == chat.id })
|
||||||
chats.insert(chat, at: 0)
|
chats.insert(chat, at: 0)
|
||||||
|
upsertWorkspaceChat(chat)
|
||||||
draftKind = nil
|
draftKind = nil
|
||||||
selectedItem = .chat(chat.id)
|
selectedItem = .chat(chat.id)
|
||||||
selectedChat = ChatDetail(
|
selectedChat = ChatDetail(
|
||||||
@@ -1034,6 +1037,7 @@ final class SybilViewModel {
|
|||||||
guard selectedItem == sourceSelection, draftKind == nil else {
|
guard selectedItem == sourceSelection, draftKind == nil else {
|
||||||
chats.removeAll(where: { $0.id == chat.id })
|
chats.removeAll(where: { $0.id == chat.id })
|
||||||
chats.insert(chat, at: 0)
|
chats.insert(chat, at: 0)
|
||||||
|
upsertWorkspaceChat(chat)
|
||||||
isCreatingSearchChat = false
|
isCreatingSearchChat = false
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -1045,6 +1049,7 @@ final class SybilViewModel {
|
|||||||
|
|
||||||
chats.removeAll(where: { $0.id == chat.id })
|
chats.removeAll(where: { $0.id == chat.id })
|
||||||
chats.insert(chat, at: 0)
|
chats.insert(chat, at: 0)
|
||||||
|
upsertWorkspaceChat(chat)
|
||||||
|
|
||||||
selectedItem = .chat(chat.id)
|
selectedItem = .chat(chat.id)
|
||||||
selectedSearch = nil
|
selectedSearch = nil
|
||||||
@@ -1148,18 +1153,16 @@ final class SybilViewModel {
|
|||||||
errorMessage = nil
|
errorMessage = nil
|
||||||
|
|
||||||
do {
|
do {
|
||||||
async let chatsValue = client.listChats()
|
async let workspaceItemsValue = client.listWorkspaceItems()
|
||||||
async let searchesValue = client.listSearches()
|
|
||||||
async let activeRunsValue = client.getActiveRuns()
|
async let activeRunsValue = client.getActiveRuns()
|
||||||
let (nextChats, nextSearches, nextActiveRuns) = try await (chatsValue, searchesValue, activeRunsValue)
|
let (nextWorkspaceItems, nextActiveRuns) = try await (workspaceItemsValue, activeRunsValue)
|
||||||
|
|
||||||
chats = nextChats
|
applyWorkspaceItems(nextWorkspaceItems)
|
||||||
searches = nextSearches
|
|
||||||
applyActiveRuns(nextActiveRuns)
|
applyActiveRuns(nextActiveRuns)
|
||||||
|
|
||||||
SybilLog.info(
|
SybilLog.info(
|
||||||
SybilLog.app,
|
SybilLog.app,
|
||||||
"Loaded collections: \(nextChats.count) chats, \(nextSearches.count) searches"
|
"Loaded collections: \(chats.count) chats, \(searches.count) searches"
|
||||||
)
|
)
|
||||||
|
|
||||||
do {
|
do {
|
||||||
@@ -1176,7 +1179,7 @@ final class SybilViewModel {
|
|||||||
if case .settings = selectedItem {
|
if case .settings = selectedItem {
|
||||||
nextSelection = .settings
|
nextSelection = .settings
|
||||||
} else if let currentSelection = selectedItem,
|
} else if let currentSelection = selectedItem,
|
||||||
hasSelection(currentSelection, chats: nextChats, searches: nextSearches) {
|
hasSelection(currentSelection, chats: chats, searches: searches) {
|
||||||
nextSelection = currentSelection
|
nextSelection = currentSelection
|
||||||
} else {
|
} else {
|
||||||
nextSelection = sidebarItems.first?.selection
|
nextSelection = sidebarItems.first?.selection
|
||||||
@@ -1248,18 +1251,16 @@ final class SybilViewModel {
|
|||||||
|
|
||||||
do {
|
do {
|
||||||
let client = try client()
|
let client = try client()
|
||||||
async let chatsValue = client.listChats()
|
async let workspaceItemsValue = client.listWorkspaceItems()
|
||||||
async let searchesValue = client.listSearches()
|
|
||||||
async let activeRunsValue = client.getActiveRuns()
|
async let activeRunsValue = client.getActiveRuns()
|
||||||
let (nextChats, nextSearches, nextActiveRuns) = try await (chatsValue, searchesValue, activeRunsValue)
|
let (nextWorkspaceItems, nextActiveRuns) = try await (workspaceItemsValue, activeRunsValue)
|
||||||
|
|
||||||
chats = nextChats
|
applyWorkspaceItems(nextWorkspaceItems)
|
||||||
searches = nextSearches
|
|
||||||
applyActiveRuns(nextActiveRuns)
|
applyActiveRuns(nextActiveRuns)
|
||||||
|
|
||||||
SybilLog.info(
|
SybilLog.info(
|
||||||
SybilLog.app,
|
SybilLog.app,
|
||||||
"Refreshed collections: \(nextChats.count) chats, \(nextSearches.count) searches"
|
"Refreshed collections: \(chats.count) chats, \(searches.count) searches"
|
||||||
)
|
)
|
||||||
errorMessage = nil
|
errorMessage = nil
|
||||||
|
|
||||||
@@ -1277,10 +1278,10 @@ final class SybilViewModel {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if let preferredSelection,
|
if let preferredSelection,
|
||||||
hasSelection(preferredSelection, chats: nextChats, searches: nextSearches) {
|
hasSelection(preferredSelection, chats: chats, searches: searches) {
|
||||||
selectedItem = preferredSelection
|
selectedItem = preferredSelection
|
||||||
} else if let existing = selectedItem,
|
} else if let existing = selectedItem,
|
||||||
hasSelection(existing, chats: nextChats, searches: nextSearches) {
|
hasSelection(existing, chats: chats, searches: searches) {
|
||||||
selectedItem = existing
|
selectedItem = existing
|
||||||
} else {
|
} else {
|
||||||
selectedItem = sidebarItems.first?.selection
|
selectedItem = sidebarItems.first?.selection
|
||||||
@@ -1374,6 +1375,34 @@ final class SybilViewModel {
|
|||||||
serverActiveSearchIDs = Set(activeRuns.searches)
|
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() {
|
private func attachToVisibleActiveRunIfNeeded() {
|
||||||
guard draftKind == nil else {
|
guard draftKind == nil else {
|
||||||
return
|
return
|
||||||
@@ -1705,6 +1734,7 @@ final class SybilViewModel {
|
|||||||
|
|
||||||
chats.removeAll(where: { $0.id == created.id })
|
chats.removeAll(where: { $0.id == created.id })
|
||||||
chats.insert(created, at: 0)
|
chats.insert(created, at: 0)
|
||||||
|
upsertWorkspaceChat(created)
|
||||||
|
|
||||||
if shouldShowCreatedChat {
|
if shouldShowCreatedChat {
|
||||||
draftKind = nil
|
draftKind = nil
|
||||||
@@ -1781,6 +1811,7 @@ final class SybilViewModel {
|
|||||||
}
|
}
|
||||||
return existing
|
return existing
|
||||||
}
|
}
|
||||||
|
self.upsertWorkspaceChat(updated, moveToFront: false)
|
||||||
|
|
||||||
if self.selectedChat?.id == updated.id {
|
if self.selectedChat?.id == updated.id {
|
||||||
self.selectedChat?.title = updated.title
|
self.selectedChat?.title = updated.title
|
||||||
@@ -1918,6 +1949,7 @@ final class SybilViewModel {
|
|||||||
|
|
||||||
searches.removeAll(where: { $0.id == created.id })
|
searches.removeAll(where: { $0.id == created.id })
|
||||||
searches.insert(created, at: 0)
|
searches.insert(created, at: 0)
|
||||||
|
upsertWorkspaceSearch(created)
|
||||||
|
|
||||||
if shouldShowCreatedSearch {
|
if shouldShowCreatedSearch {
|
||||||
draftKind = nil
|
draftKind = nil
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import Testing
|
|||||||
@testable import Sybil
|
@testable import Sybil
|
||||||
|
|
||||||
private struct MockClientCallSnapshot: Sendable {
|
private struct MockClientCallSnapshot: Sendable {
|
||||||
|
var listWorkspaceItems = 0
|
||||||
var listChats = 0
|
var listChats = 0
|
||||||
var listSearches = 0
|
var listSearches = 0
|
||||||
var createChat = 0
|
var createChat = 0
|
||||||
@@ -27,6 +28,7 @@ private struct UnexpectedClientCall: Error {}
|
|||||||
private actor MockSybilClient: SybilAPIClienting {
|
private actor MockSybilClient: SybilAPIClienting {
|
||||||
private let chatsResponse: [ChatSummary]
|
private let chatsResponse: [ChatSummary]
|
||||||
private let searchesResponse: [SearchSummary]
|
private let searchesResponse: [SearchSummary]
|
||||||
|
private let workspaceItemsResponse: [WorkspaceItem]
|
||||||
private let chatDetails: [String: ChatDetail]
|
private let chatDetails: [String: ChatDetail]
|
||||||
private let searchDetails: [String: SearchDetail]
|
private let searchDetails: [String: SearchDetail]
|
||||||
private let createChatResponse: ChatSummary?
|
private let createChatResponse: ChatSummary?
|
||||||
@@ -55,16 +57,22 @@ private actor MockSybilClient: SybilAPIClienting {
|
|||||||
chatDetails: [String: ChatDetail] = [:],
|
chatDetails: [String: ChatDetail] = [:],
|
||||||
searchDetails: [String: SearchDetail] = [:],
|
searchDetails: [String: SearchDetail] = [:],
|
||||||
createChatResponse: ChatSummary? = nil,
|
createChatResponse: ChatSummary? = nil,
|
||||||
activeRunsResponse: ActiveRunsResponse = ActiveRunsResponse()
|
activeRunsResponse: ActiveRunsResponse = ActiveRunsResponse(),
|
||||||
|
workspaceItemsResponse: [WorkspaceItem]? = nil
|
||||||
) {
|
) {
|
||||||
self.chatsResponse = chatsResponse
|
self.chatsResponse = chatsResponse
|
||||||
self.searchesResponse = searchesResponse
|
self.searchesResponse = searchesResponse
|
||||||
|
self.workspaceItemsResponse = workspaceItemsResponse ?? Self.makeWorkspaceItems(chats: chatsResponse, searches: searchesResponse)
|
||||||
self.chatDetails = chatDetails
|
self.chatDetails = chatDetails
|
||||||
self.searchDetails = searchDetails
|
self.searchDetails = searchDetails
|
||||||
self.createChatResponse = createChatResponse
|
self.createChatResponse = createChatResponse
|
||||||
self.activeRunsResponse = activeRunsResponse
|
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 {
|
func currentSnapshot() -> MockClientCallSnapshot {
|
||||||
snapshot
|
snapshot
|
||||||
}
|
}
|
||||||
@@ -127,6 +135,15 @@ private actor MockSybilClient: SybilAPIClienting {
|
|||||||
AuthSession(authenticated: true, mode: "open")
|
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] {
|
func listChats() async throws -> [ChatSummary] {
|
||||||
snapshot.listChats += 1
|
snapshot.listChats += 1
|
||||||
if listChatsDelayNanoseconds > 0 {
|
if listChatsDelayNanoseconds > 0 {
|
||||||
@@ -389,8 +406,9 @@ private func makeSearchDetail(id: String, date: Date, answer: String) -> SearchD
|
|||||||
await viewModel.refreshVisibleContent(refreshCollections: true, refreshSelection: false)
|
await viewModel.refreshVisibleContent(refreshCollections: true, refreshSelection: false)
|
||||||
|
|
||||||
let snapshot = await client.currentSnapshot()
|
let snapshot = await client.currentSnapshot()
|
||||||
#expect(snapshot.listChats == 1)
|
#expect(snapshot.listWorkspaceItems == 1)
|
||||||
#expect(snapshot.listSearches == 1)
|
#expect(snapshot.listChats == 0)
|
||||||
|
#expect(snapshot.listSearches == 0)
|
||||||
#expect(snapshot.getChat == 0)
|
#expect(snapshot.getChat == 0)
|
||||||
#expect(snapshot.getSearch == 0)
|
#expect(snapshot.getSearch == 0)
|
||||||
#expect(viewModel.selectedItem == .chat("chat-1"))
|
#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)
|
await viewModel.refreshVisibleContent(refreshCollections: false, refreshSelection: true)
|
||||||
|
|
||||||
let snapshot = await client.currentSnapshot()
|
let snapshot = await client.currentSnapshot()
|
||||||
|
#expect(snapshot.listWorkspaceItems == 0)
|
||||||
#expect(snapshot.listChats == 0)
|
#expect(snapshot.listChats == 0)
|
||||||
#expect(snapshot.listSearches == 0)
|
#expect(snapshot.listSearches == 0)
|
||||||
#expect(snapshot.getChat == 1)
|
#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)
|
await viewModel.refreshVisibleContent(refreshCollections: false, refreshSelection: true)
|
||||||
|
|
||||||
let snapshot = await client.currentSnapshot()
|
let snapshot = await client.currentSnapshot()
|
||||||
|
#expect(snapshot.listWorkspaceItems == 0)
|
||||||
#expect(snapshot.listChats == 0)
|
#expect(snapshot.listChats == 0)
|
||||||
#expect(snapshot.listSearches == 0)
|
#expect(snapshot.listSearches == 0)
|
||||||
#expect(snapshot.getSearch == 1)
|
#expect(snapshot.getSearch == 1)
|
||||||
|
|||||||
@@ -326,6 +326,39 @@ function getErrorMessage(err: unknown) {
|
|||||||
return err instanceof Error ? err.message : String(err);
|
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) {
|
function writeSseEvent(reply: FastifyReply, event: SseStreamEvent) {
|
||||||
if (reply.raw.destroyed || reply.raw.writableEnded) return;
|
if (reply.raw.destroyed || reply.raw.writableEnded) return;
|
||||||
reply.raw.write(`event: ${event.event}\n`);
|
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) => {
|
app.get("/v1/chats", async (req) => {
|
||||||
requireAdmin(req);
|
requireAdmin(req);
|
||||||
const chats = await prisma.chat.findMany({
|
const chats = await prisma.chat.findMany({
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import type {
|
|||||||
SearchStreamHandlers,
|
SearchStreamHandlers,
|
||||||
SearchSummary,
|
SearchSummary,
|
||||||
SessionStatus,
|
SessionStatus,
|
||||||
|
WorkspaceItem,
|
||||||
} from "./types.js";
|
} from "./types.js";
|
||||||
|
|
||||||
type RequestOptions = {
|
type RequestOptions = {
|
||||||
@@ -41,6 +42,11 @@ export class SybilApiClient {
|
|||||||
return data.chats;
|
return data.chats;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async listWorkspaceItems() {
|
||||||
|
const data = await this.request<{ items: WorkspaceItem[] }>("/v1/workspace-items");
|
||||||
|
return data.items;
|
||||||
|
}
|
||||||
|
|
||||||
async createChat(title?: string) {
|
async createChat(title?: string) {
|
||||||
const data = await this.request<{ chat: ChatSummary }>("/v1/chats", {
|
const data = await this.request<{ chat: ChatSummary }>("/v1/chats", {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import type {
|
|||||||
SearchDetail,
|
SearchDetail,
|
||||||
SearchSummary,
|
SearchSummary,
|
||||||
ToolCallEvent,
|
ToolCallEvent,
|
||||||
|
WorkspaceItem,
|
||||||
} from "./types.js";
|
} from "./types.js";
|
||||||
|
|
||||||
type SidebarSelection = { kind: "chat" | "search"; id: string };
|
type SidebarSelection = { kind: "chat" | "search"; id: string };
|
||||||
@@ -93,9 +94,38 @@ function getSearchTitle(search: Pick<SearchSummary, "title" | "query">) {
|
|||||||
return "New search";
|
return "New search";
|
||||||
}
|
}
|
||||||
|
|
||||||
function buildSidebarItems(chats: ChatSummary[], searches: SearchSummary[]): SidebarItem[] {
|
function chatWorkspaceItem(chat: ChatSummary): WorkspaceItem {
|
||||||
const items: SidebarItem[] = [
|
return { type: "chat", ...chat };
|
||||||
...chats.map((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,
|
kind: "chat" as const,
|
||||||
id: chat.id,
|
id: chat.id,
|
||||||
title: getChatTitle(chat),
|
title: getChatTitle(chat),
|
||||||
@@ -105,8 +135,11 @@ function buildSidebarItems(chats: ChatSummary[], searches: SearchSummary[]): Sid
|
|||||||
initiatedModel: chat.initiatedModel,
|
initiatedModel: chat.initiatedModel,
|
||||||
lastUsedProvider: chat.lastUsedProvider,
|
lastUsedProvider: chat.lastUsedProvider,
|
||||||
lastUsedModel: chat.lastUsedModel,
|
lastUsedModel: chat.lastUsedModel,
|
||||||
})),
|
};
|
||||||
...searches.map((search) => ({
|
}
|
||||||
|
|
||||||
|
const search = item;
|
||||||
|
return {
|
||||||
kind: "search" as const,
|
kind: "search" as const,
|
||||||
id: search.id,
|
id: search.id,
|
||||||
title: getSearchTitle(search),
|
title: getSearchTitle(search),
|
||||||
@@ -116,10 +149,8 @@ function buildSidebarItems(chats: ChatSummary[], searches: SearchSummary[]): Sid
|
|||||||
initiatedModel: null,
|
initiatedModel: null,
|
||||||
lastUsedProvider: null,
|
lastUsedProvider: null,
|
||||||
lastUsedModel: null,
|
lastUsedModel: null,
|
||||||
})),
|
};
|
||||||
];
|
});
|
||||||
|
|
||||||
return items.sort((a, b) => new Date(b.updatedAt).getTime() - new Date(a.updatedAt).getTime());
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function asToolLogMetadata(value: unknown): ToolLogMetadata | null {
|
function asToolLogMetadata(value: unknown): ToolLogMetadata | null {
|
||||||
@@ -195,6 +226,7 @@ async function main() {
|
|||||||
let authMode: "open" | "token" | null = null;
|
let authMode: "open" | "token" | null = null;
|
||||||
let chats: ChatSummary[] = [];
|
let chats: ChatSummary[] = [];
|
||||||
let searches: SearchSummary[] = [];
|
let searches: SearchSummary[] = [];
|
||||||
|
let workspaceItems: WorkspaceItem[] = [];
|
||||||
let selectedItem: SidebarSelection | null = null;
|
let selectedItem: SidebarSelection | null = null;
|
||||||
let selectedChat: ChatDetail | null = null;
|
let selectedChat: ChatDetail | null = null;
|
||||||
let selectedSearch: SearchDetail | null = null;
|
let selectedSearch: SearchDetail | null = null;
|
||||||
@@ -377,7 +409,7 @@ async function main() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function getSidebarItems() {
|
function getSidebarItems() {
|
||||||
return buildSidebarItems(chats, searches);
|
return buildSidebarItems(workspaceItems);
|
||||||
}
|
}
|
||||||
|
|
||||||
function getSelectedChatSummary() {
|
function getSelectedChatSummary() {
|
||||||
@@ -701,6 +733,7 @@ async function main() {
|
|||||||
function resetWorkspaceState() {
|
function resetWorkspaceState() {
|
||||||
chats = [];
|
chats = [];
|
||||||
searches = [];
|
searches = [];
|
||||||
|
workspaceItems = [];
|
||||||
selectedItem = null;
|
selectedItem = null;
|
||||||
selectedChat = null;
|
selectedChat = null;
|
||||||
selectedSearch = null;
|
selectedSearch = null;
|
||||||
@@ -767,11 +800,13 @@ async function main() {
|
|||||||
updateUI();
|
updateUI();
|
||||||
|
|
||||||
try {
|
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;
|
chats = nextChats;
|
||||||
searches = nextSearches;
|
searches = nextSearches;
|
||||||
|
|
||||||
const nextItems = buildSidebarItems(nextChats, nextSearches);
|
const nextItems = buildSidebarItems(nextWorkspaceItems);
|
||||||
if (options?.preferredSelection && hasItem(nextItems, options.preferredSelection)) {
|
if (options?.preferredSelection && hasItem(nextItems, options.preferredSelection)) {
|
||||||
selectedItem = options.preferredSelection;
|
selectedItem = options.preferredSelection;
|
||||||
draftKind = null;
|
draftKind = null;
|
||||||
@@ -876,6 +911,7 @@ async function main() {
|
|||||||
try {
|
try {
|
||||||
const updated = await api.suggestChatTitle({ chatId, content });
|
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 ? { ...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) {
|
if (selectedChat?.id === updated.id) {
|
||||||
selectedChat = { ...selectedChat, title: updated.title, updatedAt: updated.updatedAt };
|
selectedChat = { ...selectedChat, title: updated.title, updatedAt: updated.updatedAt };
|
||||||
}
|
}
|
||||||
@@ -920,6 +956,7 @@ async function main() {
|
|||||||
chatId = chat.id;
|
chatId = chat.id;
|
||||||
draftKind = null;
|
draftKind = null;
|
||||||
chats = [chat, ...chats.filter((existing) => existing.id !== chat.id)];
|
chats = [chat, ...chats.filter((existing) => existing.id !== chat.id)];
|
||||||
|
workspaceItems = upsertWorkspaceItem(workspaceItems, chatWorkspaceItem(chat));
|
||||||
selectedItem = { kind: "chat", id: chat.id };
|
selectedItem = { kind: "chat", id: chat.id };
|
||||||
pendingChatState = pendingChatState ? { ...pendingChatState, chatId } : pendingChatState;
|
pendingChatState = pendingChatState ? { ...pendingChatState, chatId } : pendingChatState;
|
||||||
selectedChat = {
|
selectedChat = {
|
||||||
@@ -1085,6 +1122,7 @@ async function main() {
|
|||||||
draftKind = null;
|
draftKind = null;
|
||||||
selectedItem = { kind: "search", id: searchId };
|
selectedItem = { kind: "search", id: searchId };
|
||||||
searches = [search, ...searches.filter((existing) => existing.id !== search.id)];
|
searches = [search, ...searches.filter((existing) => existing.id !== search.id)];
|
||||||
|
workspaceItems = upsertWorkspaceItem(workspaceItems, searchWorkspaceItem(search));
|
||||||
selectedChat = null;
|
selectedChat = null;
|
||||||
forceScrollToBottom = true;
|
forceScrollToBottom = true;
|
||||||
updateUI();
|
updateUI();
|
||||||
|
|||||||
@@ -29,6 +29,16 @@ export type SearchSummary = {
|
|||||||
updatedAt: string;
|
updatedAt: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export type ChatWorkspaceItem = ChatSummary & {
|
||||||
|
type: "chat";
|
||||||
|
};
|
||||||
|
|
||||||
|
export type SearchWorkspaceItem = SearchSummary & {
|
||||||
|
type: "search";
|
||||||
|
};
|
||||||
|
|
||||||
|
export type WorkspaceItem = ChatWorkspaceItem | SearchWorkspaceItem;
|
||||||
|
|
||||||
export type Message = {
|
export type Message = {
|
||||||
id: string;
|
id: string;
|
||||||
createdAt: string;
|
createdAt: string;
|
||||||
|
|||||||
@@ -20,8 +20,7 @@ import {
|
|||||||
getChat,
|
getChat,
|
||||||
listModels,
|
listModels,
|
||||||
getSearch,
|
getSearch,
|
||||||
listChats,
|
listWorkspaceItems,
|
||||||
listSearches,
|
|
||||||
runCompletionStream,
|
runCompletionStream,
|
||||||
runSearchStream,
|
runSearchStream,
|
||||||
suggestChatTitle,
|
suggestChatTitle,
|
||||||
@@ -37,6 +36,7 @@ import {
|
|||||||
type SearchDetail,
|
type SearchDetail,
|
||||||
type SearchSummary,
|
type SearchSummary,
|
||||||
type ToolCallEvent,
|
type ToolCallEvent,
|
||||||
|
type WorkspaceItem,
|
||||||
} from "@/lib/api";
|
} from "@/lib/api";
|
||||||
import { useSessionAuth } from "@/hooks/use-session-auth";
|
import { useSessionAuth } from "@/hooks/use-session-auth";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
@@ -588,9 +588,34 @@ function getSearchTitle(search: Pick<SearchSummary, "title" | "query">) {
|
|||||||
return "New search";
|
return "New search";
|
||||||
}
|
}
|
||||||
|
|
||||||
function buildSidebarItems(chats: ChatSummary[], searches: SearchSummary[]): SidebarItem[] {
|
function chatWorkspaceItem(chat: ChatSummary): WorkspaceItem {
|
||||||
const items: SidebarItem[] = [
|
return { type: "chat", ...chat };
|
||||||
...chats.map((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,
|
kind: "chat" as const,
|
||||||
id: chat.id,
|
id: chat.id,
|
||||||
title: getChatTitle(chat),
|
title: getChatTitle(chat),
|
||||||
@@ -600,8 +625,11 @@ function buildSidebarItems(chats: ChatSummary[], searches: SearchSummary[]): Sid
|
|||||||
initiatedModel: chat.initiatedModel,
|
initiatedModel: chat.initiatedModel,
|
||||||
lastUsedProvider: chat.lastUsedProvider,
|
lastUsedProvider: chat.lastUsedProvider,
|
||||||
lastUsedModel: chat.lastUsedModel,
|
lastUsedModel: chat.lastUsedModel,
|
||||||
})),
|
};
|
||||||
...searches.map((search) => ({
|
}
|
||||||
|
|
||||||
|
const search = item;
|
||||||
|
return {
|
||||||
kind: "search" as const,
|
kind: "search" as const,
|
||||||
id: search.id,
|
id: search.id,
|
||||||
title: getSearchTitle(search),
|
title: getSearchTitle(search),
|
||||||
@@ -611,10 +639,21 @@ function buildSidebarItems(chats: ChatSummary[], searches: SearchSummary[]): Sid
|
|||||||
initiatedModel: null,
|
initiatedModel: null,
|
||||||
lastUsedProvider: null,
|
lastUsedProvider: null,
|
||||||
lastUsedModel: 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 {
|
function buildActiveRunsState(activeRuns: ActiveRunsResponse): ActiveRunsState {
|
||||||
@@ -675,6 +714,7 @@ export default function App() {
|
|||||||
|
|
||||||
const [chats, setChats] = useState<ChatSummary[]>([]);
|
const [chats, setChats] = useState<ChatSummary[]>([]);
|
||||||
const [searches, setSearches] = useState<SearchSummary[]>([]);
|
const [searches, setSearches] = useState<SearchSummary[]>([]);
|
||||||
|
const [workspaceItems, setWorkspaceItems] = useState<WorkspaceItem[]>([]);
|
||||||
const [selectedItem, setSelectedItem] = useState<SidebarSelection | null>(null);
|
const [selectedItem, setSelectedItem] = useState<SidebarSelection | null>(null);
|
||||||
const [selectedChat, setSelectedChat] = useState<ChatDetail | null>(null);
|
const [selectedChat, setSelectedChat] = useState<ChatDetail | null>(null);
|
||||||
const [selectedSearch, setSelectedSearch] = useState<SearchDetail | null>(null);
|
const [selectedSearch, setSelectedSearch] = useState<SearchDetail | null>(null);
|
||||||
@@ -801,7 +841,7 @@ export default function App() {
|
|||||||
pendingAttachmentsRef.current = pendingAttachments;
|
pendingAttachmentsRef.current = pendingAttachments;
|
||||||
}, [pendingAttachments]);
|
}, [pendingAttachments]);
|
||||||
|
|
||||||
const sidebarItems = useMemo(() => buildSidebarItems(chats, searches), [chats, searches]);
|
const sidebarItems = useMemo(() => buildSidebarItems(workspaceItems), [workspaceItems]);
|
||||||
const filteredSidebarItems = useMemo(() => {
|
const filteredSidebarItems = useMemo(() => {
|
||||||
const query = sidebarQuery.trim().toLowerCase();
|
const query = sidebarQuery.trim().toLowerCase();
|
||||||
if (!query) return sidebarItems;
|
if (!query) return sidebarItems;
|
||||||
@@ -817,6 +857,7 @@ export default function App() {
|
|||||||
const resetWorkspaceState = () => {
|
const resetWorkspaceState = () => {
|
||||||
setChats([]);
|
setChats([]);
|
||||||
setSearches([]);
|
setSearches([]);
|
||||||
|
setWorkspaceItems([]);
|
||||||
setSelectedItem(null);
|
setSelectedItem(null);
|
||||||
setSelectedChat(null);
|
setSelectedChat(null);
|
||||||
setSelectedSearch(null);
|
setSelectedSearch(null);
|
||||||
@@ -852,15 +893,16 @@ export default function App() {
|
|||||||
const refreshCollections = async (preferredSelection?: SidebarSelection) => {
|
const refreshCollections = async (preferredSelection?: SidebarSelection) => {
|
||||||
setIsLoadingCollections(true);
|
setIsLoadingCollections(true);
|
||||||
try {
|
try {
|
||||||
const [nextChats, nextSearches] = await Promise.all([listChats(), listSearches()]);
|
const nextWorkspaceItems = await listWorkspaceItems();
|
||||||
const nextItems = buildSidebarItems(nextChats, nextSearches);
|
const { chats: nextChats, searches: nextSearches } = splitWorkspaceItems(nextWorkspaceItems);
|
||||||
|
setWorkspaceItems(nextWorkspaceItems);
|
||||||
setChats(nextChats);
|
setChats(nextChats);
|
||||||
setSearches(nextSearches);
|
setSearches(nextSearches);
|
||||||
|
|
||||||
setSelectedItem((current) => {
|
setSelectedItem((current) => {
|
||||||
const hasItem = (candidate: SidebarSelection | null) => {
|
const hasItem = (candidate: SidebarSelection | null) => {
|
||||||
if (!candidate) return false;
|
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)) {
|
if (preferredSelection && hasItem(preferredSelection)) {
|
||||||
@@ -869,8 +911,8 @@ export default function App() {
|
|||||||
if (hasItem(current)) {
|
if (hasItem(current)) {
|
||||||
return current;
|
return current;
|
||||||
}
|
}
|
||||||
const first = nextItems[0];
|
const first = nextWorkspaceItems[0];
|
||||||
return first ? { kind: first.kind, id: first.id } : null;
|
return first ? { kind: first.type, id: first.id } : null;
|
||||||
});
|
});
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
const message = err instanceof Error ? err.message : String(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);
|
const withoutExisting = current.filter((existing) => existing.id !== chat.id);
|
||||||
return [chat, ...withoutExisting];
|
return [chat, ...withoutExisting];
|
||||||
});
|
});
|
||||||
|
setWorkspaceItems((current) => upsertWorkspaceItem(current, chatWorkspaceItem(chat)));
|
||||||
setSelectedItem({ kind: "chat", id: chatId });
|
setSelectedItem({ kind: "chat", id: chatId });
|
||||||
setSelectedChat({
|
setSelectedChat({
|
||||||
id: chat.id,
|
id: chat.id,
|
||||||
@@ -1616,6 +1659,7 @@ export default function App() {
|
|||||||
return { ...chat, title: updatedChat.title, updatedAt: updatedChat.updatedAt };
|
return { ...chat, title: updatedChat.title, updatedAt: updatedChat.updatedAt };
|
||||||
})
|
})
|
||||||
);
|
);
|
||||||
|
setWorkspaceItems((current) => upsertWorkspaceItem(current, chatWorkspaceItem(updatedChat), false));
|
||||||
setSelectedChat((current) => {
|
setSelectedChat((current) => {
|
||||||
if (!current || current.id !== updatedChat.id) return current;
|
if (!current || current.id !== updatedChat.id) return current;
|
||||||
return { ...current, title: updatedChat.title, updatedAt: updatedChat.updatedAt };
|
return { ...current, title: updatedChat.title, updatedAt: updatedChat.updatedAt };
|
||||||
@@ -1748,6 +1792,11 @@ export default function App() {
|
|||||||
searchId = search.id;
|
searchId = search.id;
|
||||||
setDraftKind(null);
|
setDraftKind(null);
|
||||||
setSelectedItem({ kind: "search", id: searchId });
|
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) {
|
if (!searchId) {
|
||||||
@@ -2121,6 +2170,7 @@ export default function App() {
|
|||||||
const withoutExisting = current.filter((existing) => existing.id !== chat.id);
|
const withoutExisting = current.filter((existing) => existing.id !== chat.id);
|
||||||
return [chat, ...withoutExisting];
|
return [chat, ...withoutExisting];
|
||||||
});
|
});
|
||||||
|
setWorkspaceItems((current) => upsertWorkspaceItem(current, chatWorkspaceItem(chat)));
|
||||||
setSelectedItem({ kind: "chat", id: chat.id });
|
setSelectedItem({ kind: "chat", id: chat.id });
|
||||||
setSelectedChat({
|
setSelectedChat({
|
||||||
id: chat.id,
|
id: chat.id,
|
||||||
@@ -2296,6 +2346,7 @@ export default function App() {
|
|||||||
const withoutExisting = current.filter((existing) => existing.id !== chat.id);
|
const withoutExisting = current.filter((existing) => existing.id !== chat.id);
|
||||||
return [chat, ...withoutExisting];
|
return [chat, ...withoutExisting];
|
||||||
});
|
});
|
||||||
|
setWorkspaceItems((current) => upsertWorkspaceItem(current, chatWorkspaceItem(chat)));
|
||||||
setSelectedItem({ kind: "chat", id: chat.id });
|
setSelectedItem({ kind: "chat", id: chat.id });
|
||||||
setSelectedChat({
|
setSelectedChat({
|
||||||
id: chat.id,
|
id: chat.id,
|
||||||
|
|||||||
@@ -17,6 +17,16 @@ export type SearchSummary = {
|
|||||||
updatedAt: string;
|
updatedAt: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export type ChatWorkspaceItem = ChatSummary & {
|
||||||
|
type: "chat";
|
||||||
|
};
|
||||||
|
|
||||||
|
export type SearchWorkspaceItem = SearchSummary & {
|
||||||
|
type: "search";
|
||||||
|
};
|
||||||
|
|
||||||
|
export type WorkspaceItem = ChatWorkspaceItem | SearchWorkspaceItem;
|
||||||
|
|
||||||
export type Message = {
|
export type Message = {
|
||||||
id: string;
|
id: string;
|
||||||
createdAt: string;
|
createdAt: string;
|
||||||
@@ -214,6 +224,11 @@ export async function listChats() {
|
|||||||
return data.chats;
|
return data.chats;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function listWorkspaceItems() {
|
||||||
|
const data = await api<{ items: WorkspaceItem[] }>("/v1/workspace-items");
|
||||||
|
return data.items;
|
||||||
|
}
|
||||||
|
|
||||||
export async function verifySession() {
|
export async function verifySession() {
|
||||||
return api<{ authenticated: true; mode: "open" | "token" }>("/v1/auth/session");
|
return api<{ authenticated: true; mode: "open" | "token" }>("/v1/auth/session");
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user