7 Commits

19 changed files with 555 additions and 313 deletions

View File

@@ -40,6 +40,7 @@ Chat upload limits:
```
- OpenAI model lists are filtered to models that are expected to work with the backend's Responses API implementation.
- `hermes-agent` is included only when `HERMES_AGENT_API_KEY` is configured. Set it to Hermes `API_SERVER_KEY`, or any non-empty value if that local server does not require auth. `HERMES_AGENT_API_BASE_URL` defaults to `http://127.0.0.1:8642/v1`; set `HERMES_AGENT_MODEL` only when you need an additional fallback/override model id.
- The backend loads provider model lists at startup and refreshes them about once every 24 hours. If a later provider refresh fails, the response keeps the last loaded model list for that provider and sets `error` to the latest failure message.
## Active Runs
@@ -57,6 +58,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.7
CURRENT_PROJECT_VERSION: 8
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

@@ -7,9 +7,6 @@ struct SybilChatTranscriptView: View {
var isSending: Bool
var topContentInset: CGFloat = 0
var bottomContentInset: CGFloat = 0
var tailSpacerHeight: CGFloat = 0
var onViewportHeightChange: ((CGFloat) -> Void)? = nil
var onPendingAssistantHeightChange: ((CGFloat) -> Void)? = nil
private var hasPendingAssistant: Bool {
messages.contains { message in
@@ -23,16 +20,6 @@ struct SybilChatTranscriptView: View {
ForEach(messages.reversed()) { message in
MessageBubble(message: message, isSending: isSending)
.frame(maxWidth: .infinity)
.background {
if isStreamingPendingAssistant(message) {
GeometryReader { proxy in
Color.clear.preference(
key: SybilPendingAssistantHeightPreferenceKey.self,
value: proxy.size.height
)
}
}
}
.scaleEffect(x: 1, y: -1)
}
@@ -46,39 +33,13 @@ struct SybilChatTranscriptView: View {
}
.frame(maxWidth: .infinity, alignment: .leading)
.padding(.horizontal, 14)
.padding(.top, 18 + bottomContentInset + tailSpacerHeight)
.padding(.top, 18 + bottomContentInset)
.padding(.bottom, 18 + topContentInset)
}
.frame(maxWidth: .infinity, alignment: .leading)
.scrollDismissesKeyboard(.interactively)
.background {
GeometryReader { proxy in
Color.clear
.onAppear {
onViewportHeightChange?(proxy.size.height)
}
.onChange(of: proxy.size.height) { _, height in
onViewportHeightChange?(height)
}
}
}
.onPreferenceChange(SybilPendingAssistantHeightPreferenceKey.self) { height in
onPendingAssistantHeightChange?(height)
}
.scaleEffect(x: 1, y: -1)
}
private func isStreamingPendingAssistant(_ message: Message) -> Bool {
isSending && message.id.hasPrefix("temp-assistant-")
}
}
private struct SybilPendingAssistantHeightPreferenceKey: PreferenceKey {
static let defaultValue: CGFloat = 0
static func reduce(value: inout CGFloat, nextValue: () -> CGFloat) {
value = max(value, nextValue())
}
}
private struct MessageBubble: View {

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

@@ -219,6 +219,11 @@ struct SybilQuickQuestionView: View {
}
private func submitQuestion() {
guard viewModel.canSendQuickQuestion else {
return
}
promptFocused = false
_ = viewModel.sendQuickQuestion()
}
}

View File

@@ -159,10 +159,7 @@ struct SybilSidebarItemList: View {
.padding(10)
}
.refreshable {
await viewModel.refreshVisibleContent(
refreshCollections: true,
refreshSelection: false
)
await viewModel.refreshSidebarCollectionsFromPullToRefresh()
}
}
}

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(
@@ -911,6 +914,23 @@ final class SybilViewModel {
}
}
func refreshSidebarCollectionsFromPullToRefresh() async {
guard isAuthenticated, !isCheckingSession else {
return
}
SybilLog.info(
SybilLog.ui,
"Sidebar pull-to-refresh requested"
)
let preferredSelection = selectedItem
let refreshTask = Task { @MainActor in
await refreshCollections(preferredSelection: preferredSelection, refreshSelection: false)
}
await refreshTask.value
}
func sendComposer() async {
let content = composer.trimmingCharacters(in: .whitespacesAndNewlines)
let attachments = composerAttachments
@@ -1017,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
}
@@ -1028,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
@@ -1131,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 {
@@ -1159,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
@@ -1231,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
@@ -1260,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
@@ -1276,7 +1294,9 @@ final class SybilViewModel {
attachToVisibleActiveRunIfNeeded()
}
} catch {
if shouldSuppressInactiveTransportError(error) {
if isCancellation(error) {
SybilLog.debug(SybilLog.app, "Collection refresh cancelled")
} else if shouldSuppressInactiveTransportError(error) {
SybilLog.info(SybilLog.app, "Suppressing collection refresh transport interruption while app is inactive")
} else {
errorMessage = normalizeAPIError(error)
@@ -1355,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
@@ -1686,6 +1734,7 @@ final class SybilViewModel {
chats.removeAll(where: { $0.id == created.id })
chats.insert(created, at: 0)
upsertWorkspaceChat(created)
if shouldShowCreatedChat {
draftKind = nil
@@ -1762,6 +1811,7 @@ final class SybilViewModel {
}
return existing
}
self.upsertWorkspaceChat(updated, moveToFront: false)
if self.selectedChat?.id == updated.id {
self.selectedChat?.title = updated.title
@@ -1899,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

@@ -26,10 +26,6 @@ struct SybilWorkspaceView: View {
@State private var isShowingPhotoPicker = false
@State private var photoPickerItems: [PhotosPickerItem] = []
@State private var isComposerDropTargeted = false
@State private var transcriptTailSpacerHeight = SybilTranscriptTailSpacer.minimumHeight
@State private var transcriptTailSpacerTargetHeight = SybilTranscriptTailSpacer.minimumHeight
@State private var transcriptViewportHeight: CGFloat = 0
@State private var pendingAssistantBaselineHeight: CGFloat?
@State private var newChatSwipeOffset: CGFloat = 0
@State private var newChatSwipeCompletionOffset: CGFloat = 0
@State private var newChatSwipeContainerWidth: CGFloat = NewChatSwipeMetrics.referenceWidth
@@ -42,10 +38,6 @@ struct SybilWorkspaceView: View {
private let customWorkspaceNavigationContentInset: CGFloat = 96
private let composerOverlayContentInset: CGFloat = 112
private var visibleTranscriptTailSpacerHeight: CGFloat {
viewModel.showsComposer && !viewModel.isSearchMode ? transcriptTailSpacerHeight : 0
}
private var isSettingsSelected: Bool {
if case .settings = viewModel.selectedItem {
return true
@@ -153,17 +145,6 @@ struct SybilWorkspaceView: View {
}
resetNewChatSwipe(animated: false)
}
.onChange(of: transcriptScrollContextID) { _, _ in
handleTranscriptContextChange()
}
.onChange(of: viewModel.isSendingVisibleChat) { wasSending, isSending in
handleVisibleChatSendingChange(wasSending: wasSending, isSending: isSending)
}
.onChange(of: viewModel.errorMessage) { _, message in
if message != nil && !viewModel.isSendingVisibleChat {
resetTranscriptTailSpacer(animated: true)
}
}
.task(id: composerFocusPolicyID) {
await applyComposerFocusPolicy()
}
@@ -213,14 +194,7 @@ struct SybilWorkspaceView: View {
isLoading: viewModel.isLoadingSelection,
isSending: viewModel.isSendingVisibleChat,
topContentInset: showsCustomWorkspaceNavigation ? customWorkspaceNavigationContentInset : 0,
bottomContentInset: viewModel.showsComposer ? composerOverlayContentInset : 0,
tailSpacerHeight: visibleTranscriptTailSpacerHeight,
onViewportHeightChange: { height in
handleTranscriptViewportHeightChange(height)
},
onPendingAssistantHeightChange: { height in
handlePendingAssistantHeightChange(height)
}
bottomContentInset: viewModel.showsComposer ? composerOverlayContentInset : 0
)
.id(transcriptScrollContextID)
}
@@ -258,13 +232,7 @@ struct SybilWorkspaceView: View {
HStack(spacing: 14) {
workspaceNavigationLeadingControl
Text(viewModel.selectedTitle)
.font(.sybil(size: 16, weight: .semibold))
.foregroundStyle(SybilTheme.text)
.lineLimit(1)
.minimumScaleFactor(0.78)
.frame(maxWidth: .infinity, alignment: .leading)
.multilineTextAlignment(.leading)
customWorkspaceNavigationTitle
workspaceNavigationTrailingControl
}
@@ -277,6 +245,32 @@ struct SybilWorkspaceView: View {
}
}
private var selectedProviderModelSubtitle: String {
let selectedModel = viewModel.model.trimmingCharacters(in: .whitespacesAndNewlines)
guard !selectedModel.isEmpty else {
return viewModel.provider.displayName
}
return "\(viewModel.provider.displayName)\(selectedModel)"
}
private var customWorkspaceNavigationTitle: some View {
VStack(alignment: .leading, spacing: 2) {
Text(viewModel.selectedTitle)
.font(.sybil(size: 16, weight: .semibold))
.foregroundStyle(SybilTheme.text)
.lineLimit(1)
.minimumScaleFactor(0.78)
Text(selectedProviderModelSubtitle)
.font(.sybil(size: 10, weight: .medium))
.foregroundStyle(SybilTheme.textMuted)
.lineLimit(1)
.minimumScaleFactor(0.82)
}
.frame(maxWidth: .infinity, alignment: .leading)
.multilineTextAlignment(.leading)
}
@ViewBuilder
private var workspaceNavigationLeadingControl: some View {
switch navigationLeadingControl {
@@ -311,86 +305,6 @@ struct SybilWorkspaceView: View {
}
}
private func handleTranscriptContextChange() {
resetTranscriptTailSpacer(animated: false)
}
private func handleVisibleChatSendingChange(wasSending: Bool, isSending: Bool) {
guard !viewModel.isSearchMode else {
resetTranscriptTailSpacer(animated: true)
return
}
if isSending {
prepareTranscriptTailSpacerForReply(animated: false)
return
}
if wasSending {
if viewModel.errorMessage != nil {
resetTranscriptTailSpacer(animated: true)
}
pendingAssistantBaselineHeight = nil
}
}
private func handleTranscriptViewportHeightChange(_ height: CGFloat) {
transcriptViewportHeight = height
if viewModel.isSendingVisibleChat,
transcriptTailSpacerTargetHeight <= SybilTranscriptTailSpacer.minimumHeight {
prepareTranscriptTailSpacerForReply(animated: false)
}
}
private func handlePendingAssistantHeightChange(_ height: CGFloat) {
guard viewModel.isSendingVisibleChat, !viewModel.isSearchMode, height > 0 else {
return
}
if pendingAssistantBaselineHeight == nil {
pendingAssistantBaselineHeight = height
}
let measuredHeight = SybilTranscriptTailSpacer.placeholderHeight(
targetHeight: transcriptTailSpacerTargetHeight,
baselineAssistantHeight: pendingAssistantBaselineHeight ?? height,
currentAssistantHeight: height
)
let nextHeight = min(transcriptTailSpacerHeight, measuredHeight)
setTranscriptTailSpacer(nextHeight, animated: false)
}
private func prepareTranscriptTailSpacerForReply(animated: Bool) {
let targetHeight = SybilTranscriptTailSpacer.replyBufferHeight(for: transcriptViewportHeight)
transcriptTailSpacerTargetHeight = targetHeight
pendingAssistantBaselineHeight = nil
setTranscriptTailSpacer(targetHeight, animated: animated)
}
private func resetTranscriptTailSpacer(animated: Bool) {
transcriptTailSpacerTargetHeight = SybilTranscriptTailSpacer.minimumHeight
pendingAssistantBaselineHeight = nil
setTranscriptTailSpacer(SybilTranscriptTailSpacer.minimumHeight, animated: animated)
}
private func setTranscriptTailSpacer(_ height: CGFloat, animated: Bool) {
let nextHeight = SybilTranscriptTailSpacer.clampedHeight(height)
guard abs(nextHeight - transcriptTailSpacerHeight) >= 0.5 else {
return
}
let update = {
transcriptTailSpacerHeight = nextHeight
}
if animated {
withAnimation(.easeOut(duration: 0.22), update)
} else {
update()
}
}
private func beginNewChatSwipe(containerWidth: CGFloat) {
let update = {
newChatSwipeContainerWidth = max(containerWidth, 1)
@@ -808,14 +722,8 @@ struct SybilWorkspaceView: View {
return
}
if !viewModel.isSearchMode {
prepareTranscriptTailSpacerForReply(animated: false)
}
#if !targetEnvironment(macCatalyst)
if !viewModel.isSearchMode {
composerFocused = false
}
#endif
Task {
@@ -881,37 +789,6 @@ struct SybilWorkspaceView: View {
}
}
enum SybilTranscriptTailSpacer {
static let minimumHeight: CGFloat = 20
static let replyBufferMin: CGFloat = 288
static let replyBufferMax: CGFloat = 576
static let replyBufferViewportRatio: CGFloat = 0.52
static func replyBufferHeight(for viewportHeight: CGFloat) -> CGFloat {
guard viewportHeight > 0 else {
return replyBufferMin
}
return min(
replyBufferMax,
max(replyBufferMin, (viewportHeight * replyBufferViewportRatio).rounded())
)
}
static func clampedHeight(_ height: CGFloat) -> CGFloat {
max(minimumHeight, height.rounded(.up))
}
static func placeholderHeight(
targetHeight: CGFloat,
baselineAssistantHeight: CGFloat,
currentAssistantHeight: CGFloat
) -> CGFloat {
let consumedHeight = max(currentAssistantHeight - baselineAssistantHeight, 0)
return clampedHeight(targetHeight - consumedHeight)
}
}
enum NewChatSwipeMetrics {
static let referenceWidth: CGFloat = 390
static let horizontalActivationDistance: CGFloat = 18

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?
@@ -36,6 +38,8 @@ private actor MockSybilClient: SybilAPIClienting {
private var lastCreateChatCall: ChatCreateCallSnapshot?
private var lastCompletionStreamBody: CompletionStreamRequest?
private var completionStreamEvents: [CompletionStreamEvent]?
private var listChatsDelayNanoseconds: UInt64 = 0
private var listSearchesDelayNanoseconds: UInt64 = 0
private var getChatDelayNanoseconds: UInt64 = 0
private var getSearchDelayNanoseconds: UInt64 = 0
private var completionStreamNetworkErrorMessage: String?
@@ -53,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
}
@@ -85,6 +95,11 @@ private actor MockSybilClient: SybilAPIClienting {
completionStreamDelayNanoseconds = delayNanoseconds
}
func setListDelays(chats: UInt64 = 0, searches: UInt64 = 0) {
listChatsDelayNanoseconds = chats
listSearchesDelayNanoseconds = searches
}
func setGetChatDelay(_ delayNanoseconds: UInt64) {
getChatDelayNanoseconds = delayNanoseconds
}
@@ -120,8 +135,20 @@ 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 {
try await Task.sleep(nanoseconds: listChatsDelayNanoseconds)
}
return chatsResponse
}
@@ -165,6 +192,9 @@ private actor MockSybilClient: SybilAPIClienting {
func listSearches() async throws -> [SearchSummary] {
snapshot.listSearches += 1
if listSearchesDelayNanoseconds > 0 {
try await Task.sleep(nanoseconds: listSearchesDelayNanoseconds)
}
return searchesResponse
}
@@ -376,13 +406,41 @@ 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"))
}
@MainActor
@Test func pullToRefreshCompletesWhenRefreshableTaskIsCancelled() async throws {
let date = Date(timeIntervalSince1970: 1_700_000_050)
let chat = makeChatSummary(id: "chat-cancelled", date: date)
let search = makeSearchSummary(id: "search-cancelled", date: date)
let client = MockSybilClient(
chatsResponse: [chat],
searchesResponse: [search]
)
await client.setListDelays(chats: 50_000_000, searches: 50_000_000)
let viewModel = SybilViewModel(settings: testSettings(named: #function)) { _ in client }
viewModel.isAuthenticated = true
viewModel.isCheckingSession = false
let refreshTask = Task {
await viewModel.refreshSidebarCollectionsFromPullToRefresh()
}
try await Task.sleep(nanoseconds: 10_000_000)
refreshTask.cancel()
await refreshTask.value
#expect(viewModel.errorMessage == nil)
#expect(!viewModel.isLoadingCollections)
#expect(viewModel.chats.map(\.id) == ["chat-cancelled"])
#expect(viewModel.searches.map(\.id) == ["search-cancelled"])
}
@MainActor
@Test func foregroundChatRefreshReloadsSelectedTranscript() async throws {
let date = Date(timeIntervalSince1970: 1_700_000_100)
@@ -396,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)
@@ -415,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)
@@ -784,23 +844,3 @@ private func makeSearchDetail(id: String, date: Date, answer: String) -> SearchD
#expect(BackSwipeMetrics.shouldComplete(offset: 24, velocityX: 800, width: width, isLatched: false))
#expect(!BackSwipeMetrics.shouldComplete(offset: latchDistance + 1, velocityX: -800, width: width, isLatched: true))
}
@Test func transcriptTailSpacerContractsAsContentGrows() async throws {
let targetHeight: CGFloat = 320
let baselineAssistantHeight: CGFloat = 28
#expect(
SybilTranscriptTailSpacer.placeholderHeight(
targetHeight: targetHeight,
baselineAssistantHeight: baselineAssistantHeight,
currentAssistantHeight: baselineAssistantHeight + 120
) == 200
)
#expect(
SybilTranscriptTailSpacer.placeholderHeight(
targetHeight: targetHeight,
baselineAssistantHeight: baselineAssistantHeight,
currentAssistantHeight: baselineAssistantHeight + 500
) == SybilTranscriptTailSpacer.minimumHeight
)
}

View File

@@ -5,7 +5,7 @@ import swaggerUI from "@fastify/swagger-ui";
import sensible from "@fastify/sensible";
import { env } from "./env.js";
import { ensureDatabaseReady } from "./db-init.js";
import { warmModelCatalog } from "./llm/model-catalog.js";
import { startModelCatalogRefreshLoop, warmModelCatalog } from "./llm/model-catalog.js";
import { registerRoutes } from "./routes.js";
const app = Fastify({
@@ -21,6 +21,7 @@ const app = Fastify({
await ensureDatabaseReady(app.log);
await warmModelCatalog(app.log);
const stopModelCatalogRefreshLoop = startModelCatalogRefreshLoop(app.log);
await app.register(cors, {
origin: true,
@@ -80,6 +81,10 @@ app.setErrorHandler((err, req, reply) => {
});
});
app.addHook("onClose", async () => {
stopModelCatalogRefreshLoop();
});
await registerRoutes(app);
await app.listen({ port: env.PORT, host: env.HOST });

View File

@@ -13,6 +13,7 @@ export type ModelCatalogSnapshot = Partial<Record<Provider, ProviderModelSnapsho
const baseProviders: Provider[] = ["openai", "anthropic", "xai"];
const MODEL_FETCH_TIMEOUT_MS = 15000;
const MODEL_CATALOG_REFRESH_INTERVAL_MS = 24 * 60 * 60 * 1000;
const modelCatalog: ModelCatalogSnapshot = {
openai: { models: [], loadedAt: null, error: null },
@@ -20,6 +21,8 @@ const modelCatalog: ModelCatalogSnapshot = {
xai: { models: [], loadedAt: null, error: null },
};
let catalogRefreshPromise: Promise<void> | null = null;
function getCatalogProviders(): Provider[] {
return isHermesAgentConfigured() ? [...baseProviders, "hermes-agent"] : baseProviders;
}
@@ -86,17 +89,42 @@ async function refreshProviderModels(provider: Provider, logger?: FastifyBaseLog
logger?.info({ provider, modelCount: models.length }, "model catalog loaded");
} catch (err: any) {
const message = err?.message ?? String(err);
const previous = modelCatalog[provider];
const fallbackModels = provider === "hermes-agent" && env.HERMES_AGENT_MODEL ? [env.HERMES_AGENT_MODEL] : [];
modelCatalog[provider] = {
models: provider === "hermes-agent" && env.HERMES_AGENT_MODEL ? [env.HERMES_AGENT_MODEL] : [],
loadedAt: new Date().toISOString(),
models: previous?.models.length ? previous.models : fallbackModels,
loadedAt: previous?.loadedAt ?? null,
error: message,
};
logger?.warn({ provider, err: message }, "failed to load provider model catalog");
}
}
export async function refreshModelCatalog(logger?: FastifyBaseLogger) {
if (catalogRefreshPromise) return catalogRefreshPromise;
catalogRefreshPromise = Promise.all(getCatalogProviders().map((provider) => refreshProviderModels(provider, logger)))
.then(() => undefined)
.finally(() => {
catalogRefreshPromise = null;
});
return catalogRefreshPromise;
}
export async function warmModelCatalog(logger?: FastifyBaseLogger) {
await Promise.all(getCatalogProviders().map((provider) => refreshProviderModels(provider, logger)));
await refreshModelCatalog(logger);
}
export function startModelCatalogRefreshLoop(logger?: FastifyBaseLogger) {
const timer = setInterval(() => {
void refreshModelCatalog(logger);
}, MODEL_CATALOG_REFRESH_INTERVAL_MS);
timer.unref?.();
return () => {
clearInterval(timer);
};
}
export function getModelCatalogSnapshot(): ModelCatalogSnapshot {

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,
@@ -2510,7 +2561,7 @@ export default function App() {
onContextMenu={(event) => openContextMenu(event, { kind: item.kind, id: item.id })}
type="button"
>
<div className="flex items-center gap-2">
<div className="grid grid-cols-[auto_minmax(0,1fr)] gap-x-2 gap-y-1">
<span
className={cn(
"flex h-5 w-5 shrink-0 items-center justify-center rounded-md border",
@@ -2528,11 +2579,15 @@ export default function App() {
/>
) : null}
</span>
<span className={cn("ml-auto shrink-0 text-xs", active ? "text-violet-100/86" : "text-violet-200/50")}>{formatDate(item.updatedAt)}</span>
</div>
<span className="col-start-2 flex min-w-0 items-center gap-2">
<span className="shrink-0 text-xs text-secondary-foreground/70">{formatDate(item.updatedAt)}</span>
{initiatedLabel ? (
<p className={cn("mt-1 truncate text-right text-xs", active ? "text-violet-100/62" : "text-violet-200/42")}>{initiatedLabel}</p>
<span className="ml-auto min-w-0 truncate text-right text-xs text-secondary-foreground/70">
{initiatedLabel}
</span>
) : null}
</span>
</div>
</button>
);
})}

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