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

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

View File

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

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,10 +389,12 @@ final class SybilViewModel {
}
var sidebarItems: [SidebarItem] {
let chatItems: [SidebarItem] = chats.map { chat in
workspaceItems.map { item in
switch item.type {
case .chat:
let initiatedLabel: String?
if let model = chat.initiatedModel?.trimmingCharacters(in: .whitespacesAndNewlines), !model.isEmpty {
if let provider = chat.initiatedProvider {
if let model = item.initiatedModel?.trimmingCharacters(in: .whitespacesAndNewlines), !model.isEmpty {
if let provider = item.initiatedProvider {
initiatedLabel = "\(provider.displayName)\(model)"
} else {
initiatedLabel = model
@@ -401,27 +404,25 @@ final class SybilViewModel {
}
return SidebarItem(
selection: .chat(chat.id),
selection: .chat(item.id),
kind: .chat,
title: chatTitle(title: chat.title, messages: nil),
updatedAt: chat.updatedAt,
title: chatTitle(title: item.title, messages: nil),
updatedAt: item.updatedAt,
initiatedLabel: initiatedLabel,
isRunning: isChatRowRunning(chat.id)
isRunning: isChatRowRunning(item.id)
)
}
let searchItems: [SidebarItem] = searches.map { search in
SidebarItem(
selection: .search(search.id),
case .search:
return SidebarItem(
selection: .search(item.id),
kind: .search,
title: searchTitle(title: search.title, query: search.query),
updatedAt: search.updatedAt,
title: searchTitle(title: item.title, query: item.query),
updatedAt: item.updatedAt,
initiatedLabel: "exa",
isRunning: isSearchRowRunning(search.id)
isRunning: isSearchRowRunning(item.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

View File

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

View File

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

View File

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

View File

@@ -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<SearchSummary, "title" | "query">) {
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();

View File

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

View File

@@ -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,9 +588,34 @@ function getSearchTitle(search: Pick<SearchSummary, "title" | "query">) {
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 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),
@@ -600,8 +625,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),
@@ -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<ChatSummary[]>([]);
const [searches, setSearches] = useState<SearchSummary[]>([]);
const [workspaceItems, setWorkspaceItems] = useState<WorkspaceItem[]>([]);
const [selectedItem, setSelectedItem] = useState<SidebarSelection | null>(null);
const [selectedChat, setSelectedChat] = useState<ChatDetail | null>(null);
const [selectedSearch, setSelectedSearch] = useState<SearchDetail | null>(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,

View File

@@ -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");
}