introduces workspace items as combined search+chat model

This commit is contained in:
2026-05-17 00:28:09 -07:00
parent a8e765e026
commit 411790ee04
13 changed files with 412 additions and 87 deletions

View File

@@ -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

View File

@@ -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?,

View File

@@ -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
}

View File

@@ -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