Files
Sybil-2/ios/Packages/Sybil/Sources/Sybil/SybilViewModel.swift

2608 lines
88 KiB
Swift
Raw Normal View History

2026-02-20 00:09:02 -08:00
import Foundation
import Observation
enum DraftKind {
case chat
case search
}
enum SidebarSelection: Hashable {
case chat(String)
case search(String)
case settings
var id: String {
switch self {
case let .chat(chatID):
return "chat:\(chatID)"
case let .search(searchID):
return "search:\(searchID)"
case .settings:
return "settings"
}
}
}
struct SidebarItem: Identifiable, Hashable {
enum Kind: Hashable {
case chat
case search
}
var id: String { selection.id }
var selection: SidebarSelection
var kind: Kind
var title: String
var updatedAt: Date
2026-05-28 22:47:45 -07:00
var starred: Bool
var starredAt: Date?
2026-02-20 00:09:02 -08:00
var initiatedLabel: String?
2026-05-04 20:14:16 -07:00
var isRunning: Bool
2026-02-20 00:09:02 -08:00
}
private struct PendingChatState {
var chatID: String?
var messages: [Message]
}
2026-05-04 20:14:16 -07:00
private enum ActiveSendContext: Hashable {
case draftChat(UUID)
case chat(String)
case draftSearch(UUID)
case search(String)
var isSearch: Bool {
switch self {
case .draftSearch, .search:
return true
case .draftChat, .chat:
return false
}
}
}
2026-02-20 00:09:02 -08:00
private actor CompletionStreamStatus {
private var streamError: String?
func setError(_ value: String) {
streamError = value
}
func error() -> String? {
streamError
}
}
private actor SearchStreamStatus {
private var streamError: String?
func setError(_ value: String) {
streamError = value
}
func error() -> String? {
streamError
}
}
@MainActor
@Observable
final class SybilViewModel {
let settings: SybilSettingsStore
var isCheckingSession = true
var isAuthenticated = false
var authMode: String?
var authError: String?
var chats: [ChatSummary] = []
var searches: [SearchSummary] = []
var workspaceItems: [WorkspaceItem] = []
2026-02-20 00:09:02 -08:00
var selectedItem: SidebarSelection?
var selectedChat: ChatDetail?
var selectedSearch: SearchDetail?
var draftKind: DraftKind?
var isLoadingCollections = false
var isLoadingSelection = false
2026-05-02 16:48:01 -07:00
var isCreatingSearchChat = false
2026-02-20 00:09:02 -08:00
var errorMessage: String?
var composer = ""
2026-05-02 19:47:38 -07:00
var composerAttachments: [ChatAttachment] = []
2026-02-20 00:09:02 -08:00
var provider: Provider
var modelCatalog: [Provider: ProviderModelInfo] = [:]
var model: String
2026-05-06 21:53:51 -07:00
var quickQuestionPrompt = ""
var quickQuestionMessages: [Message] = []
var quickQuestionError: String?
var quickQuestionProvider: Provider
var quickQuestionModel: String
var quickQuestionSubmittedPrompt: String?
var quickQuestionSubmittedProvider: Provider?
var quickQuestionSubmittedModel: String?
var isQuickQuestionSending = false
var isConvertingQuickQuestion = false
2026-02-20 00:09:02 -08:00
2026-05-02 22:18:33 -07:00
@ObservationIgnored
2026-02-20 00:09:02 -08:00
private var hasBootstrapped = false
2026-05-04 20:14:16 -07:00
private var pendingDraftChatState: PendingChatState?
private var pendingChatStates: [String: PendingChatState] = [:]
private var activeSearchDetails: [String: SearchDetail] = [:]
private var activeDraftSendContexts: Set<ActiveSendContext> = []
private var localActiveChatIDs: Set<String> = []
private var localActiveSearchIDs: Set<String> = []
private var serverActiveChatIDs: Set<String> = []
private var serverActiveSearchIDs: Set<String> = []
private var draftIdentity = UUID()
2026-05-02 22:18:33 -07:00
@ObservationIgnored
2026-02-20 00:09:02 -08:00
private var selectionTask: Task<Void, Never>?
2026-05-02 22:18:33 -07:00
@ObservationIgnored
2026-05-04 20:14:16 -07:00
private var activeRunPollingTask: Task<Void, Never>?
@ObservationIgnored
private var activeChatAttachTasks: [String: Task<Void, Never>] = [:]
@ObservationIgnored
private var activeSearchAttachTasks: [String: Task<Void, Never>] = [:]
2026-05-02 22:18:33 -07:00
@ObservationIgnored
2026-05-06 21:53:51 -07:00
private var quickQuestionTask: Task<Void, Never>?
@ObservationIgnored
private var quickQuestionRunID: UUID?
@ObservationIgnored
2026-05-03 16:42:49 -07:00
private var isAppActive = true
@ObservationIgnored
private var appLifecycleGeneration = 0
@ObservationIgnored
2026-05-02 22:18:33 -07:00
private let clientFactory: (APIConfiguration) -> any SybilAPIClienting
2026-02-20 00:09:02 -08:00
private let fallbackModels: [Provider: [String]] = [
.openai: ["gpt-4.1-mini"],
.anthropic: ["claude-3-5-sonnet-latest"],
2026-05-04 21:52:39 -07:00
.xai: ["grok-3-mini"],
.hermesAgent: ["hermes-agent"]
2026-02-20 00:09:02 -08:00
]
2026-05-02 22:18:33 -07:00
init(
settings: SybilSettingsStore = SybilSettingsStore(),
clientFactory: @escaping (APIConfiguration) -> any SybilAPIClienting = { configuration in
SybilAPIClient(configuration: configuration)
}
) {
2026-02-20 00:09:02 -08:00
self.settings = settings
2026-05-02 22:18:33 -07:00
self.clientFactory = clientFactory
2026-05-06 21:53:51 -07:00
let initialProvider = settings.preferredProvider
let initialModel = settings.preferredModelByProvider[initialProvider] ?? "gpt-4.1-mini"
self.provider = initialProvider
self.model = initialModel
let initialQuickQuestionProvider = settings.quickQuestionPreferredProvider
let initialQuickQuestionModel = settings.quickQuestionPreferredModelByProvider[initialQuickQuestionProvider] ?? initialModel
self.quickQuestionProvider = initialQuickQuestionProvider
self.quickQuestionModel = initialQuickQuestionModel
2026-02-20 00:09:02 -08:00
}
var providerModelOptions: [String] {
2026-05-02 16:23:00 -07:00
modelOptions(for: provider)
}
2026-05-04 21:52:39 -07:00
var providerOptions: [Provider] {
Provider.allCases.filter { candidate in
candidate != .hermesAgent || modelCatalog[candidate] != nil
}
}
2026-05-06 21:53:51 -07:00
var quickQuestionProviderModelOptions: [String] {
modelOptions(for: quickQuestionProvider)
}
var canSendQuickQuestion: Bool {
!isQuickQuestionSending &&
!isConvertingQuickQuestion &&
!quickQuestionPrompt.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty &&
!quickQuestionModel.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
}
var quickQuestionAnswerText: String {
for message in quickQuestionMessages.reversed() where message.role == .assistant {
let content = message.content.trimmingCharacters(in: .whitespacesAndNewlines)
if !content.isEmpty {
return content
}
}
return ""
}
var canConvertQuickQuestion: Bool {
!isQuickQuestionSending &&
!isConvertingQuickQuestion &&
!(quickQuestionSubmittedPrompt?.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty ?? true) &&
!quickQuestionAnswerText.isEmpty &&
quickQuestionSubmittedProvider != nil &&
!(quickQuestionSubmittedModel?.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty ?? true)
}
2026-05-02 16:23:00 -07:00
func modelOptions(for candidate: Provider) -> [String] {
let serverModels = modelCatalog[candidate]?.models ?? []
2026-02-20 00:09:02 -08:00
if !serverModels.isEmpty {
return serverModels
}
2026-05-02 16:23:00 -07:00
return fallbackModels[candidate] ?? []
2026-02-20 00:09:02 -08:00
}
var selectedTitle: String {
if case .settings = selectedItem {
return "Settings"
}
if draftKind == .chat {
return "New chat"
}
if draftKind == .search {
return "New search"
}
guard let selectedItem else {
return "Sybil"
}
switch selectedItem {
case .chat:
if let selectedChat = currentSelectedChat {
2026-02-20 00:09:02 -08:00
return chatTitle(title: selectedChat.title, messages: selectedChat.messages)
}
if let summary = selectedChatSummary {
return chatTitle(title: summary.title, messages: nil)
}
return "Chat"
case .search:
if let selectedSearch = currentSelectedSearch {
2026-02-20 00:09:02 -08:00
return searchTitle(title: selectedSearch.title, query: selectedSearch.query)
}
if let summary = selectedSearchSummary {
return searchTitle(title: summary.title, query: summary.query)
}
return "Search"
case .settings:
return "Settings"
}
}
var subtitle: String {
if case .settings = selectedItem {
return "Configure API connectivity"
}
let modeLabel: String
if authMode == "open" {
modeLabel = "open mode"
} else if authMode == "token" {
modeLabel = "token mode"
} else {
modeLabel = "offline"
}
if isSearchMode {
return "Sybil iOS (\(modeLabel)) • Exa Search"
}
return "Sybil iOS (\(modeLabel))"
}
var isSearchMode: Bool {
if draftKind == .search {
return true
}
if draftKind == .chat {
return false
}
if case .search = selectedItem {
return true
}
return false
}
var showsComposer: Bool {
if case .settings = selectedItem {
return false
}
return draftKind != nil || selectedItem != nil
}
2026-05-04 20:14:16 -07:00
private var activeChatIDs: Set<String> {
localActiveChatIDs.union(serverActiveChatIDs)
}
private var activeSearchIDs: Set<String> {
localActiveSearchIDs.union(serverActiveSearchIDs)
}
private func isChatRowRunning(_ chatID: String) -> Bool {
pendingChatStates[chatID] != nil || activeChatIDs.contains(chatID)
}
private func isSearchRowRunning(_ searchID: String) -> Bool {
activeSearchDetails[searchID] != nil || activeSearchIDs.contains(searchID)
}
var isSending: Bool {
!activeDraftSendContexts.isEmpty || !activeChatIDs.isEmpty || !activeSearchIDs.isEmpty
}
var isActiveSelectionSending: Bool {
isSendContextActive(currentSendContext)
}
2026-05-02 19:47:38 -07:00
var canSendComposer: Bool {
2026-05-04 20:14:16 -07:00
if isActiveSelectionSending {
2026-05-02 19:47:38 -07:00
return false
}
let content = composer.trimmingCharacters(in: .whitespacesAndNewlines)
if isSearchMode {
return !content.isEmpty
}
return !content.isEmpty || !composerAttachments.isEmpty
}
2026-02-20 00:09:02 -08:00
var displayedMessages: [Message] {
let canonical = displayableMessages(currentSelectedChat?.messages ?? [])
2026-05-04 20:14:16 -07:00
if case let .chat(chatID) = selectedItem,
let pending = pendingChatStates[chatID] {
return displayableMessages(pending.messages)
2026-02-20 00:09:02 -08:00
}
2026-05-04 20:14:16 -07:00
if draftKind == .chat, let pending = pendingDraftChatState {
2026-05-02 16:48:01 -07:00
return displayableMessages(pending.messages)
2026-02-20 00:09:02 -08:00
}
return canonical
}
var displayedSearch: SearchDetail? {
2026-05-04 20:14:16 -07:00
if case let .search(searchID) = selectedItem,
let activeSearch = activeSearchDetails[searchID] {
return activeSearch
}
return currentSelectedSearch
}
var isSendingVisibleChat: Bool {
2026-05-04 20:14:16 -07:00
if draftKind == .chat {
return activeDraftSendContexts.contains(.draftChat(draftIdentity))
}
2026-05-04 20:14:16 -07:00
if case let .chat(chatID) = selectedItem {
return isChatRowRunning(chatID)
}
2026-05-04 20:14:16 -07:00
return false
}
var isRunningVisibleSearch: Bool {
2026-05-04 20:14:16 -07:00
if draftKind == .search {
return activeDraftSendContexts.contains(.draftSearch(draftIdentity))
}
2026-05-04 20:14:16 -07:00
if case let .search(searchID) = selectedItem {
return isSearchRowRunning(searchID)
}
2026-05-04 20:14:16 -07:00
return false
}
2026-02-20 00:09:02 -08:00
var sidebarItems: [SidebarItem] {
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
}
2026-02-20 00:09:02 -08:00
} else {
initiatedLabel = nil
2026-02-20 00:09:02 -08:00
}
return SidebarItem(
selection: .chat(item.id),
kind: .chat,
title: chatTitle(title: item.title, messages: nil),
updatedAt: item.updatedAt,
2026-05-28 22:47:45 -07:00
starred: item.starred,
starredAt: item.starredAt,
initiatedLabel: initiatedLabel,
isRunning: isChatRowRunning(item.id)
)
2026-02-20 00:09:02 -08:00
case .search:
return SidebarItem(
selection: .search(item.id),
kind: .search,
title: searchTitle(title: item.title, query: item.query),
updatedAt: item.updatedAt,
2026-05-28 22:47:45 -07:00
starred: item.starred,
starredAt: item.starredAt,
initiatedLabel: "exa",
isRunning: isSearchRowRunning(item.id)
)
}
2026-02-20 00:09:02 -08:00
}
}
var selectedChatSummary: ChatSummary? {
guard case let .chat(chatID) = selectedItem else { return nil }
return chats.first(where: { $0.id == chatID })
}
var selectedSearchSummary: SearchSummary? {
guard case let .search(searchID) = selectedItem else { return nil }
return searches.first(where: { $0.id == searchID })
}
2026-05-02 22:18:33 -07:00
var hasRefreshableSelection: Bool {
guard draftKind == nil, let selectedItem else {
return false
}
switch selectedItem {
case .chat, .search:
return true
case .settings:
return false
}
}
2026-02-20 00:09:02 -08:00
func bootstrap() async {
guard !hasBootstrapped else {
return
}
SybilLog.info(SybilLog.app, "Bootstrapping Sybil iOS session")
hasBootstrapped = true
await reconnect()
}
func reconnect() async {
isCheckingSession = true
authError = nil
errorMessage = nil
resetSelectionLoading()
2026-05-04 20:14:16 -07:00
stopActiveRunPolling()
cancelActiveStreamAttachTasks()
pendingDraftChatState = nil
pendingChatStates = [:]
activeSearchDetails = [:]
activeDraftSendContexts = []
localActiveChatIDs = []
localActiveSearchIDs = []
serverActiveChatIDs = []
serverActiveSearchIDs = []
2026-05-06 21:53:51 -07:00
resetQuickQuestion()
draftIdentity = UUID()
2026-05-02 19:47:38 -07:00
composerAttachments = []
2026-02-20 00:09:02 -08:00
settings.persist()
SybilLog.info(
SybilLog.app,
"Reconnecting with API base URL \(settings.normalizedAPIBaseURL?.absoluteString ?? "<invalid>")"
)
do {
let client = try client()
let session = try await client.verifySession()
isAuthenticated = session.authenticated
authMode = session.mode
authError = nil
SybilLog.info(
SybilLog.app,
"Session verified (authenticated=\(session.authenticated), mode=\(session.mode))"
)
await loadInitialData(using: client)
2026-05-04 20:14:16 -07:00
startActiveRunPolling()
2026-02-20 00:09:02 -08:00
} catch {
2026-05-04 20:14:16 -07:00
stopActiveRunPolling()
2026-02-20 00:09:02 -08:00
isAuthenticated = false
authMode = nil
chats = []
searches = []
workspaceItems = []
2026-02-20 00:09:02 -08:00
selectedItem = .settings
selectedChat = nil
selectedSearch = nil
draftKind = nil
authError = normalizeAuthError(error)
SybilLog.error(SybilLog.app, "Reconnect failed", error: error)
}
isCheckingSession = false
}
func setProvider(_ nextProvider: Provider) {
provider = nextProvider
let preferred = settings.preferredModelByProvider[nextProvider]
let options = providerModelOptions
if let preferred, options.contains(preferred) {
model = preferred
} else if let first = options.first {
model = first
}
settings.preferredProvider = nextProvider
settings.persist()
SybilLog.info(SybilLog.ui, "Provider changed to \(nextProvider.rawValue), model=\(model)")
}
func setModel(_ nextModel: String) {
model = nextModel
settings.preferredModelByProvider[provider] = nextModel
settings.persist()
SybilLog.info(SybilLog.ui, "Model changed to \(nextModel)")
}
2026-05-02 16:23:00 -07:00
func setProvider(_ nextProvider: Provider, model nextModel: String) {
provider = nextProvider
model = nextModel
settings.preferredProvider = nextProvider
settings.preferredModelByProvider[nextProvider] = nextModel
settings.persist()
SybilLog.info(SybilLog.ui, "Provider changed to \(nextProvider.rawValue), model=\(nextModel)")
}
2026-05-06 21:53:51 -07:00
func setQuickQuestionProvider(_ nextProvider: Provider) {
quickQuestionProvider = nextProvider
let options = modelOptions(for: nextProvider)
if let preferred = settings.quickQuestionPreferredModelByProvider[nextProvider], options.contains(preferred) {
quickQuestionModel = preferred
} else if let first = options.first {
quickQuestionModel = first
} else {
quickQuestionModel = ""
}
persistQuickQuestionModelSelection()
}
func setQuickQuestionModel(_ nextModel: String) {
quickQuestionModel = nextModel
persistQuickQuestionModelSelection()
}
private func persistQuickQuestionModelSelection() {
settings.quickQuestionPreferredProvider = quickQuestionProvider
let trimmedModel = quickQuestionModel.trimmingCharacters(in: .whitespacesAndNewlines)
if !trimmedModel.isEmpty {
settings.quickQuestionPreferredModelByProvider[quickQuestionProvider] = trimmedModel
}
settings.persist()
}
func updateQuickQuestionPrompt(_ nextPrompt: String) {
guard nextPrompt != quickQuestionPrompt else {
return
}
if isQuickQuestionSending || quickQuestionSubmittedPrompt != nil || !quickQuestionMessages.isEmpty {
cancelQuickQuestion()
quickQuestionSubmittedPrompt = nil
quickQuestionSubmittedProvider = nil
quickQuestionSubmittedModel = nil
quickQuestionMessages = []
quickQuestionError = nil
}
quickQuestionPrompt = nextPrompt
}
func resetQuickQuestion() {
cancelQuickQuestion()
quickQuestionPrompt = ""
quickQuestionMessages = []
quickQuestionError = nil
quickQuestionSubmittedPrompt = nil
quickQuestionSubmittedProvider = nil
quickQuestionSubmittedModel = nil
isConvertingQuickQuestion = false
}
func cancelQuickQuestion() {
quickQuestionTask?.cancel()
quickQuestionTask = nil
quickQuestionRunID = nil
isQuickQuestionSending = false
}
@discardableResult
func sendQuickQuestion() -> Task<Void, Never>? {
let content = quickQuestionPrompt.trimmingCharacters(in: .whitespacesAndNewlines)
guard !content.isEmpty, !isQuickQuestionSending, !isConvertingQuickQuestion else {
return nil
}
let selectedModel = quickQuestionModel.trimmingCharacters(in: .whitespacesAndNewlines)
guard !selectedModel.isEmpty else {
quickQuestionError = "No model available for selected provider."
return nil
}
cancelQuickQuestion()
let selectedProvider = quickQuestionProvider
let task = Task { [weak self] in
guard let self else {
return
}
await self.runQuickQuestion(prompt: content, provider: selectedProvider, model: selectedModel)
}
quickQuestionTask = task
return task
}
@discardableResult
func convertQuickQuestionToChat() async -> Bool {
let question = quickQuestionSubmittedPrompt?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
let answer = quickQuestionAnswerText
guard !question.isEmpty,
!answer.isEmpty,
let submittedProvider = quickQuestionSubmittedProvider,
let submittedModel = quickQuestionSubmittedModel?.trimmingCharacters(in: .whitespacesAndNewlines),
!submittedModel.isEmpty,
!isQuickQuestionSending,
!isConvertingQuickQuestion
else {
return false
}
isConvertingQuickQuestion = true
quickQuestionError = nil
defer {
isConvertingQuickQuestion = false
}
do {
let titleSeed = question.split(whereSeparator: \.isNewline).first.map(String.init) ?? question
let title = String(titleSeed.trimmingCharacters(in: .whitespacesAndNewlines).prefix(48))
let chat = try await client().createChat(
title: title.isEmpty ? "Quick question" : title,
provider: submittedProvider,
model: submittedModel,
messages: [
CompletionRequestMessage(role: .user, content: question),
CompletionRequestMessage(role: .assistant, content: answer)
]
)
setProvider(submittedProvider, model: submittedModel)
chats.removeAll(where: { $0.id == chat.id })
chats.insert(chat, at: 0)
upsertWorkspaceChat(chat)
2026-05-06 21:53:51 -07:00
draftKind = nil
selectedItem = .chat(chat.id)
selectedChat = ChatDetail(
id: chat.id,
title: chat.title,
createdAt: chat.createdAt,
updatedAt: chat.updatedAt,
2026-05-28 22:47:45 -07:00
starred: chat.starred,
starredAt: chat.starredAt,
2026-05-06 21:53:51 -07:00
initiatedProvider: chat.initiatedProvider,
initiatedModel: chat.initiatedModel,
lastUsedProvider: chat.lastUsedProvider,
lastUsedModel: chat.lastUsedModel,
messages: []
)
selectedSearch = nil
composer = ""
composerAttachments = []
await refreshCollections(preferredSelection: .chat(chat.id))
resetQuickQuestion()
return true
} catch {
quickQuestionError = normalizeAPIError(error)
SybilLog.error(SybilLog.ui, "Convert quick question to chat failed", error: error)
return false
}
}
2026-02-20 00:09:02 -08:00
func startNewChat() {
SybilLog.debug(SybilLog.ui, "Starting draft chat")
resetSelectionLoading()
draftIdentity = UUID()
2026-02-20 00:09:02 -08:00
draftKind = .chat
selectedItem = nil
selectedChat = nil
selectedSearch = nil
2026-05-04 20:14:16 -07:00
pendingDraftChatState = nil
2026-02-20 00:09:02 -08:00
errorMessage = nil
composer = ""
2026-05-02 19:47:38 -07:00
composerAttachments = []
2026-02-20 00:09:02 -08:00
}
func startNewSearch() {
SybilLog.debug(SybilLog.ui, "Starting draft search")
resetSelectionLoading()
draftIdentity = UUID()
2026-02-20 00:09:02 -08:00
draftKind = .search
selectedItem = nil
selectedChat = nil
selectedSearch = nil
2026-05-04 20:14:16 -07:00
pendingDraftChatState = nil
2026-02-20 00:09:02 -08:00
errorMessage = nil
composer = ""
2026-05-02 19:47:38 -07:00
composerAttachments = []
2026-02-20 00:09:02 -08:00
}
func openSettings() {
SybilLog.debug(SybilLog.ui, "Opening settings")
resetSelectionLoading()
2026-02-20 00:09:02 -08:00
draftKind = nil
selectedItem = .settings
selectedChat = nil
selectedSearch = nil
2026-05-04 20:14:16 -07:00
pendingDraftChatState = nil
2026-02-20 00:09:02 -08:00
errorMessage = nil
2026-05-02 19:47:38 -07:00
composerAttachments = []
2026-02-20 00:09:02 -08:00
}
func select(_ selection: SidebarSelection) {
_ = beginSelecting(selection)
}
func selectForNavigation(_ selection: SidebarSelection, preloadTimeout: Duration = .seconds(3)) async {
guard beginSelecting(selection) != nil else {
return
}
await waitForSelectionLoad(timeout: preloadTimeout)
}
@discardableResult
private func beginSelecting(_ selection: SidebarSelection) -> Task<Void, Never>? {
2026-02-20 00:09:02 -08:00
SybilLog.debug(SybilLog.ui, "Selecting \(selection.id)")
if draftKind == nil, selectedItem == selection {
errorMessage = nil
if case .search = selection {
composerAttachments = []
}
if needsSelectionLoad(selection) {
return startSelectionRefreshTask()
}
return selectionTask
}
resetSelectionLoading()
2026-02-20 00:09:02 -08:00
draftKind = nil
selectedItem = selection
errorMessage = nil
switch selection {
case let .chat(chatID):
if selectedChat?.id != chatID {
selectedChat = nil
}
selectedSearch = nil
case let .search(searchID):
if selectedSearch?.id != searchID {
selectedSearch = nil
}
selectedChat = nil
2026-05-02 19:47:38 -07:00
composerAttachments = []
2026-02-20 00:09:02 -08:00
case .settings:
2026-02-20 00:09:02 -08:00
selectedChat = nil
selectedSearch = nil
return nil
2026-02-20 00:09:02 -08:00
}
return startSelectionRefreshTask()
2026-02-20 00:09:02 -08:00
}
2026-05-02 23:50:51 -07:00
func selectPreviousSidebarItem() {
selectAdjacentSidebarItem(offset: -1)
}
func selectNextSidebarItem() {
selectAdjacentSidebarItem(offset: 1)
}
private func selectAdjacentSidebarItem(offset: Int) {
let items = sidebarItems
guard !items.isEmpty else {
return
}
let currentIndex = selectedItem.flatMap { selection in
items.firstIndex { $0.selection == selection }
}
let startingIndex = currentIndex ?? (offset < 0 ? items.count : -1)
let nextIndex = (startingIndex + offset + items.count) % items.count
let nextSelection = items[nextIndex].selection
guard draftKind != nil || selectedItem != nextSelection else {
return
}
select(nextSelection)
}
2026-02-20 00:09:02 -08:00
func deleteItem(_ selection: SidebarSelection) async {
guard isAuthenticated else {
return
}
guard case .settings = selection else {
SybilLog.info(SybilLog.ui, "Deleting item \(selection.id)")
do {
let client = try client()
switch selection {
case let .chat(chatID):
try await client.deleteChat(chatID: chatID)
case let .search(searchID):
try await client.deleteSearch(searchID: searchID)
case .settings:
break
}
await refreshCollections(preferredSelection: nil)
} catch {
errorMessage = normalizeAPIError(error)
SybilLog.error(SybilLog.ui, "Delete failed", error: error)
}
return
}
}
2026-05-28 22:22:55 -07:00
func renameChat(chatID: String, title: String) async {
guard isAuthenticated else {
return
}
let trimmedTitle = title.trimmingCharacters(in: .whitespacesAndNewlines)
guard !trimmedTitle.isEmpty else {
errorMessage = "Enter a chat title."
return
}
SybilLog.info(SybilLog.ui, "Renaming chat \(chatID)")
errorMessage = nil
do {
let updated = try await client().updateChatTitle(chatID: chatID, title: trimmedTitle)
2026-05-28 22:47:45 -07:00
applyChatSummary(updated, moveToFront: true)
2026-05-28 22:22:55 -07:00
} catch {
errorMessage = normalizeAPIError(error)
SybilLog.error(SybilLog.ui, "Rename failed", error: error)
}
}
2026-05-28 22:47:45 -07:00
func setItemStarred(_ selection: SidebarSelection, starred: Bool) async {
guard isAuthenticated else {
return
}
guard case .settings = selection else {
errorMessage = nil
do {
let client = try client()
switch selection {
case let .chat(chatID):
let updated = try await client.updateChatStar(chatID: chatID, starred: starred)
applyChatSummary(updated, moveToFront: false)
case let .search(searchID):
let updated = try await client.updateSearchStar(searchID: searchID, starred: starred)
applySearchSummary(updated, moveToFront: false)
case .settings:
break
}
} catch {
errorMessage = normalizeAPIError(error)
SybilLog.error(SybilLog.ui, "Star update failed", error: error)
}
return
}
}
2026-02-20 00:09:02 -08:00
func refreshAfterSettingsChange() async {
SybilLog.info(SybilLog.ui, "Settings changed, reconnecting")
settings.persist()
await reconnect()
}
2026-05-03 16:42:49 -07:00
func markAppInactiveForNetwork() {
guard isAppActive else {
return
}
isAppActive = false
appLifecycleGeneration += 1
SybilLog.debug(SybilLog.app, "App became inactive for network lifecycle generation \(appLifecycleGeneration)")
}
func markAppActiveForNetwork() {
isAppActive = true
}
func refreshAfterAppBecameActive(refreshCollections shouldRefreshCollections: Bool, refreshSelection shouldRefreshSelection: Bool) async {
markAppActiveForNetwork()
guard isAuthenticated, !isCheckingSession else {
return
}
guard shouldRefreshCollections || shouldRefreshSelection else {
return
}
try? await Task.sleep(for: .milliseconds(150))
await refreshVisibleContent(
refreshCollections: shouldRefreshCollections,
refreshSelection: shouldRefreshSelection
)
}
2026-05-02 22:18:33 -07:00
func refreshVisibleContent(refreshCollections shouldRefreshCollections: Bool, refreshSelection shouldRefreshSelection: Bool) async {
guard isAuthenticated, !isCheckingSession else {
return
}
guard shouldRefreshCollections || shouldRefreshSelection else {
return
}
SybilLog.info(
SybilLog.ui,
"Visible content refresh requested (collections=\(shouldRefreshCollections), selection=\(shouldRefreshSelection))"
2026-05-02 22:18:33 -07:00
)
if shouldRefreshCollections {
await refreshCollections(preferredSelection: selectedItem, refreshSelection: shouldRefreshSelection)
return
}
if shouldRefreshSelection {
2026-05-04 20:14:16 -07:00
await refreshActiveRunsFromServer()
2026-05-02 22:18:33 -07:00
await refreshSelectionIfNeeded()
}
}
2026-05-06 22:34:17 -07:00
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
}
2026-02-20 00:09:02 -08:00
func sendComposer() async {
let content = composer.trimmingCharacters(in: .whitespacesAndNewlines)
2026-05-02 19:47:38 -07:00
let attachments = composerAttachments
let sendContext = currentSendContext
2026-05-02 19:47:38 -07:00
2026-05-04 20:14:16 -07:00
guard !isSendContextActive(sendContext) else {
2026-05-02 19:47:38 -07:00
return
}
if sendContext.isSearch {
2026-05-02 19:47:38 -07:00
guard !content.isEmpty else { return }
} else if content.isEmpty && attachments.isEmpty {
2026-02-20 00:09:02 -08:00
return
}
composer = ""
2026-05-02 19:47:38 -07:00
composerAttachments = []
2026-02-20 00:09:02 -08:00
errorMessage = nil
2026-05-04 20:14:16 -07:00
markSendContextActive(sendContext)
defer {
markSendContextInactive(sendContext)
}
2026-02-20 00:09:02 -08:00
do {
if sendContext.isSearch {
2026-02-20 00:09:02 -08:00
SybilLog.info(SybilLog.ui, "Sending search query")
2026-05-04 20:14:16 -07:00
try await sendSearch(query: content, sendContext: sendContext)
2026-02-20 00:09:02 -08:00
} else {
SybilLog.info(SybilLog.ui, "Sending chat prompt")
2026-05-04 20:14:16 -07:00
try await sendChat(content: content, attachments: attachments, sendContext: sendContext)
2026-02-20 00:09:02 -08:00
}
} catch {
2026-05-04 20:14:16 -07:00
let shouldSurfaceError = isSendContextVisible(sendContext)
if shouldSurfaceError {
errorMessage = normalizeAPIError(error)
}
2026-02-20 00:09:02 -08:00
SybilLog.error(SybilLog.ui, "Send failed", error: error)
if shouldSurfaceError, case let .chat(chatID) = selectedItem {
2026-02-20 00:09:02 -08:00
do {
let chat = try await client().getChat(chatID: chatID)
if selectedItem == .chat(chatID), draftKind == nil {
selectedChat = chat
}
2026-02-20 00:09:02 -08:00
} catch {
SybilLog.error(SybilLog.ui, "Fallback chat refresh after failure failed", error: error)
}
}
if shouldSurfaceError, case let .search(searchID) = selectedItem {
2026-02-20 00:09:02 -08:00
do {
let search = try await client().getSearch(searchID: searchID)
if selectedItem == .search(searchID), draftKind == nil {
selectedSearch = search
}
2026-02-20 00:09:02 -08:00
} catch {
SybilLog.error(SybilLog.ui, "Fallback search refresh after failure failed", error: error)
}
}
if !sendContext.isSearch, shouldSurfaceError {
2026-05-02 19:47:38 -07:00
composer = content
composerAttachments = attachments
}
2026-05-04 20:14:16 -07:00
clearPendingChatState(for: sendContext)
2026-02-20 00:09:02 -08:00
}
}
2026-05-02 19:47:38 -07:00
func appendComposerAttachments(_ attachments: [ChatAttachment]) throws {
guard !attachments.isEmpty else {
return
}
guard !isSearchMode else {
errorMessage = "Attachments are only available in chat mode."
return
}
if composerAttachments.count + attachments.count > SybilChatAttachmentSupport.maxAttachmentsPerMessage {
throw ChatAttachmentError.tooManyAttachments(SybilChatAttachmentSupport.maxAttachmentsPerMessage)
}
composerAttachments += attachments
errorMessage = nil
}
func removeComposerAttachment(id: String) {
composerAttachments.removeAll { $0.id == id }
}
2026-05-02 16:48:01 -07:00
func startChatFromSelectedSearch() async {
2026-05-04 20:14:16 -07:00
guard let search = currentSelectedSearch, !isCreatingSearchChat, !isActiveSelectionSending else {
2026-05-02 16:48:01 -07:00
return
}
let sourceSelection = SidebarSelection.search(search.id)
2026-05-02 16:48:01 -07:00
isCreatingSearchChat = true
errorMessage = nil
do {
let client = try client()
2026-05-02 22:18:33 -07:00
let chat = try await client.createChatFromSearch(searchID: search.id, title: nil)
guard selectedItem == sourceSelection, draftKind == nil else {
chats.removeAll(where: { $0.id == chat.id })
chats.insert(chat, at: 0)
upsertWorkspaceChat(chat)
isCreatingSearchChat = false
return
}
2026-05-02 16:48:01 -07:00
draftKind = nil
2026-05-04 20:14:16 -07:00
pendingDraftChatState = nil
2026-05-02 16:48:01 -07:00
composer = ""
2026-05-02 19:47:38 -07:00
composerAttachments = []
2026-05-02 16:48:01 -07:00
chats.removeAll(where: { $0.id == chat.id })
chats.insert(chat, at: 0)
upsertWorkspaceChat(chat)
2026-05-02 16:48:01 -07:00
selectedItem = .chat(chat.id)
selectedSearch = nil
await refreshCollections(preferredSelection: .chat(chat.id))
} catch {
errorMessage = normalizeAPIError(error)
SybilLog.error(SybilLog.ui, "Create chat from search failed", error: error)
}
isCreatingSearchChat = false
}
2026-05-06 21:53:51 -07:00
private func runQuickQuestion(prompt: String, provider: Provider, model: String) async {
let runID = UUID()
quickQuestionRunID = runID
quickQuestionError = nil
quickQuestionSubmittedPrompt = prompt
quickQuestionSubmittedProvider = provider
quickQuestionSubmittedModel = model
quickQuestionMessages = [
Message(
id: "temp-assistant-quick-\(UUID().uuidString)",
createdAt: Date(),
role: .assistant,
content: "",
name: nil
)
]
isQuickQuestionSending = true
defer {
if quickQuestionRunID == runID {
quickQuestionTask = nil
quickQuestionRunID = nil
isQuickQuestionSending = false
}
}
let streamStatus = CompletionStreamStatus()
do {
try await client().runCompletionStream(
body: CompletionStreamRequest(
chatId: nil,
persist: false,
provider: provider,
model: model,
messages: [CompletionRequestMessage(role: .user, content: prompt)]
)
) { [weak self] event in
guard let self else { return }
await self.applyQuickQuestionCompletionEvent(event, streamStatus: streamStatus)
}
if let streamError = await streamStatus.error() {
throw APIError.httpError(statusCode: 502, message: streamError)
}
} catch {
guard quickQuestionRunID == runID else {
return
}
if isCancellation(error) {
return
}
quickQuestionError = normalizeAPIError(error)
SybilLog.error(SybilLog.ui, "Quick question failed", error: error)
}
}
private func applyQuickQuestionCompletionEvent(_ event: CompletionStreamEvent, streamStatus: CompletionStreamStatus) async {
switch event {
case .meta:
break
case let .toolCall(payload):
insertQuickQuestionToolCallMessage(payload)
case let .delta(payload):
guard !payload.text.isEmpty else { return }
mutateQuickQuestionAssistantMessage { existing in
existing + payload.text
}
case let .done(payload):
mutateQuickQuestionAssistantMessage { _ in
payload.text
}
case let .error(payload):
await streamStatus.setError(payload.message)
case .ignored:
break
}
}
2026-05-02 22:18:33 -07:00
private func loadInitialData(using client: any SybilAPIClienting) async {
2026-02-20 00:09:02 -08:00
isLoadingCollections = true
errorMessage = nil
do {
async let workspaceItemsValue = client.listWorkspaceItems()
2026-05-04 20:14:16 -07:00
async let activeRunsValue = client.getActiveRuns()
let (nextWorkspaceItems, nextActiveRuns) = try await (workspaceItemsValue, activeRunsValue)
2026-02-20 00:09:02 -08:00
applyWorkspaceItems(nextWorkspaceItems)
2026-05-04 20:14:16 -07:00
applyActiveRuns(nextActiveRuns)
2026-02-20 00:09:02 -08:00
SybilLog.info(
SybilLog.app,
"Loaded collections: \(chats.count) chats, \(searches.count) searches"
2026-02-20 00:09:02 -08:00
)
do {
let nextCatalog = try await client.listModels()
self.modelCatalog = nextCatalog.providers
syncModelSelectionWithServerCatalog()
SybilLog.info(SybilLog.app, "Loaded model catalog for \(nextCatalog.providers.count) providers")
} catch {
SybilLog.error(SybilLog.app, "Model catalog load failed, using provider fallback models", error: error)
errorMessage = "Loaded chats/searches, but failed to load models: \(normalizeAPIError(error))"
}
let nextSelection: SidebarSelection?
if case .settings = selectedItem {
nextSelection = .settings
} else if let currentSelection = selectedItem,
hasSelection(currentSelection, chats: chats, searches: searches) {
2026-02-20 00:09:02 -08:00
nextSelection = currentSelection
} else {
nextSelection = sidebarItems.first?.selection
}
selectedItem = nextSelection
if nextSelection == nil {
draftKind = .chat
}
if let nextSelection {
switch nextSelection {
case .settings:
selectedChat = nil
selectedSearch = nil
case .chat, .search:
await refreshSelectionIfNeeded()
}
}
2026-05-04 20:14:16 -07:00
attachToVisibleActiveRunIfNeeded()
2026-02-20 00:09:02 -08:00
} catch {
errorMessage = normalizeAPIError(error)
SybilLog.error(SybilLog.app, "Initial data load failed", error: error)
}
isLoadingCollections = false
}
private func syncModelSelectionWithServerCatalog() {
2026-05-04 21:52:39 -07:00
if !providerOptions.contains(provider), let firstProvider = providerOptions.first {
provider = firstProvider
settings.preferredProvider = firstProvider
}
2026-02-20 00:09:02 -08:00
if !providerModelOptions.contains(model), let first = providerModelOptions.first {
model = first
settings.preferredModelByProvider[provider] = first
}
if let preferred = settings.preferredModelByProvider[provider], providerModelOptions.contains(preferred) {
model = preferred
}
2026-05-06 21:53:51 -07:00
if !providerOptions.contains(quickQuestionProvider), let firstProvider = providerOptions.first {
quickQuestionProvider = firstProvider
settings.quickQuestionPreferredProvider = firstProvider
}
if !quickQuestionProviderModelOptions.contains(quickQuestionModel), let first = quickQuestionProviderModelOptions.first {
quickQuestionModel = first
settings.quickQuestionPreferredModelByProvider[quickQuestionProvider] = first
}
if let preferred = settings.quickQuestionPreferredModelByProvider[quickQuestionProvider],
quickQuestionProviderModelOptions.contains(preferred)
{
quickQuestionModel = preferred
}
2026-02-20 00:09:02 -08:00
settings.persist()
}
2026-05-02 22:18:33 -07:00
private func refreshCollections(
preferredSelection: SidebarSelection?,
2026-05-04 20:14:16 -07:00
refreshSelection: Bool = true,
attachVisibleActiveRun: Bool = true
2026-05-02 22:18:33 -07:00
) async {
2026-02-20 00:09:02 -08:00
isLoadingCollections = true
do {
let client = try client()
async let workspaceItemsValue = client.listWorkspaceItems()
2026-05-04 20:14:16 -07:00
async let activeRunsValue = client.getActiveRuns()
let (nextWorkspaceItems, nextActiveRuns) = try await (workspaceItemsValue, activeRunsValue)
2026-02-20 00:09:02 -08:00
applyWorkspaceItems(nextWorkspaceItems)
2026-05-04 20:14:16 -07:00
applyActiveRuns(nextActiveRuns)
2026-02-20 00:09:02 -08:00
SybilLog.info(
SybilLog.app,
"Refreshed collections: \(chats.count) chats, \(searches.count) searches"
2026-02-20 00:09:02 -08:00
)
2026-05-03 16:42:49 -07:00
errorMessage = nil
2026-02-20 00:09:02 -08:00
if draftKind != nil {
2026-05-04 20:14:16 -07:00
if attachVisibleActiveRun {
attachToVisibleActiveRunIfNeeded()
}
isLoadingCollections = false
return
}
2026-02-20 00:09:02 -08:00
if case .settings = selectedItem {
isLoadingCollections = false
return
}
if let preferredSelection,
hasSelection(preferredSelection, chats: chats, searches: searches) {
2026-02-20 00:09:02 -08:00
selectedItem = preferredSelection
} else if let existing = selectedItem,
hasSelection(existing, chats: chats, searches: searches) {
2026-02-20 00:09:02 -08:00
selectedItem = existing
} else {
selectedItem = sidebarItems.first?.selection
}
2026-05-02 22:18:33 -07:00
if refreshSelection, selectedItem != nil {
2026-02-20 00:09:02 -08:00
await refreshSelectionIfNeeded()
}
2026-05-04 20:14:16 -07:00
if attachVisibleActiveRun {
attachToVisibleActiveRunIfNeeded()
}
2026-02-20 00:09:02 -08:00
} catch {
2026-05-06 22:34:17 -07:00
if isCancellation(error) {
SybilLog.debug(SybilLog.app, "Collection refresh cancelled")
} else if shouldSuppressInactiveTransportError(error) {
2026-05-03 16:42:49 -07:00
SybilLog.info(SybilLog.app, "Suppressing collection refresh transport interruption while app is inactive")
} else {
errorMessage = normalizeAPIError(error)
SybilLog.error(SybilLog.app, "Refresh collections failed", error: error)
}
2026-02-20 00:09:02 -08:00
}
isLoadingCollections = false
}
2026-05-04 20:14:16 -07:00
private func startActiveRunPolling() {
activeRunPollingTask?.cancel()
activeRunPollingTask = Task { [weak self] in
while !Task.isCancelled {
try? await Task.sleep(for: .seconds(3))
guard !Task.isCancelled else {
return
}
guard let self else {
return
}
await self.refreshActiveRunsFromServer()
}
}
}
private func stopActiveRunPolling() {
activeRunPollingTask?.cancel()
activeRunPollingTask = nil
}
private func cancelActiveStreamAttachTasks() {
for task in activeChatAttachTasks.values {
task.cancel()
}
for task in activeSearchAttachTasks.values {
task.cancel()
}
activeChatAttachTasks = [:]
activeSearchAttachTasks = [:]
}
private func refreshActiveRunsFromServer(
using providedClient: (any SybilAPIClienting)? = nil,
attachVisibleActiveRun: Bool = true
) async {
guard isAuthenticated, !isCheckingSession else {
return
}
do {
let apiClient: any SybilAPIClienting
if let providedClient {
apiClient = providedClient
} else {
apiClient = try client()
}
let activeRuns = try await apiClient.getActiveRuns()
applyActiveRuns(activeRuns)
if attachVisibleActiveRun {
attachToVisibleActiveRunIfNeeded()
}
} catch {
if shouldSuppressInactiveTransportError(error) {
SybilLog.info(SybilLog.app, "Suppressing active-run refresh transport interruption while app is inactive")
} else {
SybilLog.warning(SybilLog.app, "Active-run refresh failed: \(SybilLog.describe(error))")
}
}
}
private func applyActiveRuns(_ activeRuns: ActiveRunsResponse) {
serverActiveChatIDs = Set(activeRuns.chats)
serverActiveSearchIDs = Set(activeRuns.searches)
}
private func applyWorkspaceItems(_ items: [WorkspaceItem]) {
workspaceItems = items
chats = items.compactMap(\.chatSummary)
searches = items.compactMap(\.searchSummary)
}
2026-05-28 22:47:45 -07:00
private func applyChatSummary(_ chat: ChatSummary, moveToFront: Bool) {
if let existingIndex = chats.firstIndex(where: { $0.id == chat.id }) {
chats.remove(at: existingIndex)
chats.insert(chat, at: moveToFront ? 0 : existingIndex)
} else {
chats.insert(chat, at: 0)
}
upsertWorkspaceChat(chat, moveToFront: moveToFront)
if selectedChat?.id == chat.id {
selectedChat?.title = chat.title
selectedChat?.updatedAt = chat.updatedAt
selectedChat?.starred = chat.starred
selectedChat?.starredAt = chat.starredAt
selectedChat?.initiatedProvider = chat.initiatedProvider
selectedChat?.initiatedModel = chat.initiatedModel
selectedChat?.lastUsedProvider = chat.lastUsedProvider
selectedChat?.lastUsedModel = chat.lastUsedModel
}
}
private func applySearchSummary(_ search: SearchSummary, moveToFront: Bool) {
if let existingIndex = searches.firstIndex(where: { $0.id == search.id }) {
searches.remove(at: existingIndex)
searches.insert(search, at: moveToFront ? 0 : existingIndex)
} else {
searches.insert(search, at: 0)
}
upsertWorkspaceSearch(search, moveToFront: moveToFront)
if selectedSearch?.id == search.id {
selectedSearch?.title = search.title
selectedSearch?.query = search.query
selectedSearch?.updatedAt = search.updatedAt
selectedSearch?.starred = search.starred
selectedSearch?.starredAt = search.starredAt
}
}
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)
}
2026-05-04 20:14:16 -07:00
private func attachToVisibleActiveRunIfNeeded() {
guard draftKind == nil else {
return
}
switch selectedItem {
case let .chat(chatID):
guard serverActiveChatIDs.contains(chatID),
!localActiveChatIDs.contains(chatID),
activeChatAttachTasks[chatID] == nil else {
return
}
activeChatAttachTasks[chatID] = Task { [weak self] in
await self?.attachToActiveChatStream(chatID: chatID)
}
case let .search(searchID):
guard serverActiveSearchIDs.contains(searchID),
!localActiveSearchIDs.contains(searchID),
activeSearchAttachTasks[searchID] == nil else {
return
}
activeSearchAttachTasks[searchID] = Task { [weak self] in
await self?.attachToActiveSearchStream(searchID: searchID)
}
case .settings, nil:
return
}
}
private func attachToActiveChatStream(chatID: String) async {
defer {
activeChatAttachTasks[chatID] = nil
}
let selection = SidebarSelection.chat(chatID)
do {
let client = try client()
if pendingChatStates[chatID] == nil {
let baseChat: ChatDetail
if let currentChat = currentSelectedChat, currentChat.id == chatID {
baseChat = currentChat
} else {
baseChat = try await client.getChat(chatID: chatID)
if selectedItem == selection, draftKind == nil {
selectedChat = baseChat
selectedSearch = nil
}
}
pendingChatStates[chatID] = PendingChatState(
chatID: chatID,
messages: baseChat.messages + [
Message(
id: "temp-assistant-attach-\(UUID().uuidString)",
createdAt: Date(),
role: .assistant,
content: "",
name: nil
)
]
)
}
let streamStatus = CompletionStreamStatus()
try await client.attachCompletionStream(chatID: chatID) { [weak self] event in
guard let self else { return }
await self.applyCompletionEvent(event, chatID: chatID, streamStatus: streamStatus)
}
if let streamError = await streamStatus.error() {
throw APIError.httpError(statusCode: 502, message: streamError)
}
serverActiveChatIDs.remove(chatID)
pendingChatStates[chatID] = nil
await refreshCollections(preferredSelection: selectedItem, refreshSelection: false, attachVisibleActiveRun: false)
if selectedItem == selection, draftKind == nil {
selectedChat = try await client.getChat(chatID: chatID)
selectedSearch = nil
}
} catch {
serverActiveChatIDs.remove(chatID)
pendingChatStates[chatID] = nil
if isCancellation(error) {
return
}
if isActiveStreamNotFound(error) {
SybilLog.info(SybilLog.app, "Active chat stream \(chatID) no longer exists")
} else if shouldSuppressInactiveTransportError(error) {
SybilLog.info(SybilLog.app, "Suppressing active chat stream transport interruption while app is inactive")
} else {
if selectedItem == selection, draftKind == nil {
errorMessage = normalizeAPIError(error)
}
SybilLog.error(SybilLog.app, "Active chat stream attach failed", error: error)
}
if selectedItem == selection, draftKind == nil {
do {
selectedChat = try await client().getChat(chatID: chatID)
selectedSearch = nil
} catch {
SybilLog.warning(SybilLog.app, "Chat refresh after attach failure failed: \(SybilLog.describe(error))")
}
}
await refreshActiveRunsFromServer(attachVisibleActiveRun: false)
}
}
private func attachToActiveSearchStream(searchID: String) async {
defer {
activeSearchAttachTasks[searchID] = nil
}
let selection = SidebarSelection.search(searchID)
do {
let client = try client()
if currentSelectedSearch?.id != searchID {
let search = try await client.getSearch(searchID: searchID)
activeSearchDetails[searchID] = search
if selectedItem == selection, draftKind == nil {
selectedSearch = search
selectedChat = nil
}
} else if let currentSearch = currentSelectedSearch {
activeSearchDetails[searchID] = currentSearch
}
let streamStatus = SearchStreamStatus()
try await client.attachSearchStream(searchID: searchID) { [weak self] event in
guard let self else { return }
await self.applySearchEvent(event, searchID: searchID, streamStatus: streamStatus)
}
if let streamError = await streamStatus.error() {
throw APIError.httpError(statusCode: 502, message: streamError)
}
serverActiveSearchIDs.remove(searchID)
activeSearchDetails[searchID] = nil
await refreshCollections(preferredSelection: selectedItem, refreshSelection: false, attachVisibleActiveRun: false)
if selectedItem == selection, draftKind == nil {
selectedSearch = try await client.getSearch(searchID: searchID)
selectedChat = nil
}
} catch {
serverActiveSearchIDs.remove(searchID)
activeSearchDetails[searchID] = nil
if isCancellation(error) {
return
}
if isActiveStreamNotFound(error) {
SybilLog.info(SybilLog.app, "Active search stream \(searchID) no longer exists")
} else if shouldSuppressInactiveTransportError(error) {
SybilLog.info(SybilLog.app, "Suppressing active search stream transport interruption while app is inactive")
} else {
if selectedItem == selection, draftKind == nil {
errorMessage = normalizeAPIError(error)
}
SybilLog.error(SybilLog.app, "Active search stream attach failed", error: error)
}
if selectedItem == selection, draftKind == nil {
do {
selectedSearch = try await client().getSearch(searchID: searchID)
selectedChat = nil
} catch {
SybilLog.warning(SybilLog.app, "Search refresh after attach failure failed: \(SybilLog.describe(error))")
}
}
await refreshActiveRunsFromServer(attachVisibleActiveRun: false)
}
}
private func resetSelectionLoading() {
selectionTask?.cancel()
selectionTask = nil
isLoadingSelection = false
}
private func startSelectionRefreshTask() -> Task<Void, Never> {
isLoadingSelection = true
let task = Task { [weak self] in
guard let self else {
return
}
await self.refreshSelectionIfNeeded()
}
selectionTask = task
return task
}
private func waitForSelectionLoad(timeout: Duration) async {
let clock = ContinuousClock()
let deadline = clock.now.advanced(by: timeout)
while isLoadingSelection, clock.now < deadline {
try? await Task.sleep(for: .milliseconds(10))
}
}
private func needsSelectionLoad(_ selection: SidebarSelection) -> Bool {
switch selection {
case let .chat(chatID):
return selectedChat?.id != chatID
case let .search(searchID):
return selectedSearch?.id != searchID
case .settings:
return false
}
}
2026-02-20 00:09:02 -08:00
private func refreshSelectionIfNeeded() async {
guard let target = selectedItem else {
2026-02-20 00:09:02 -08:00
selectedChat = nil
selectedSearch = nil
isLoadingSelection = false
2026-02-20 00:09:02 -08:00
return
}
guard case .settings = target else {
2026-02-20 00:09:02 -08:00
isLoadingSelection = true
do {
let client = try client()
switch target {
2026-02-20 00:09:02 -08:00
case let .chat(chatID):
SybilLog.debug(SybilLog.app, "Refreshing chat \(chatID)")
let chat = try await client.getChat(chatID: chatID)
guard selectedItem == target, draftKind == nil else {
return
}
selectedChat = chat
2026-02-20 00:09:02 -08:00
selectedSearch = nil
if let provider = chat.lastUsedProvider,
let model = chat.lastUsedModel,
2026-02-20 00:09:02 -08:00
!model.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty {
self.provider = provider
self.model = model
}
case let .search(searchID):
SybilLog.debug(SybilLog.app, "Refreshing search \(searchID)")
let search = try await client.getSearch(searchID: searchID)
guard selectedItem == target, draftKind == nil else {
return
}
selectedSearch = search
2026-02-20 00:09:02 -08:00
selectedChat = nil
case .settings:
break
}
2026-05-03 16:42:49 -07:00
errorMessage = nil
2026-02-20 00:09:02 -08:00
} catch {
if isCancellation(error) {
SybilLog.debug(SybilLog.app, "Selection refresh cancelled for \(target.id)")
2026-05-03 16:42:49 -07:00
} else if shouldSuppressInactiveTransportError(error) {
SybilLog.info(SybilLog.app, "Suppressing selection refresh transport interruption while app is inactive")
} else if selectedItem == target, draftKind == nil {
2026-02-20 00:09:02 -08:00
errorMessage = normalizeAPIError(error)
SybilLog.error(SybilLog.app, "Selection refresh failed", error: error)
}
}
if selectedItem == target, draftKind == nil {
isLoadingSelection = false
selectionTask = nil
2026-05-04 20:14:16 -07:00
attachToVisibleActiveRunIfNeeded()
}
2026-02-20 00:09:02 -08:00
return
}
selectedChat = nil
selectedSearch = nil
isLoadingSelection = false
2026-02-20 00:09:02 -08:00
}
2026-05-04 20:14:16 -07:00
private func sendChat(content: String, attachments: [ChatAttachment], sendContext: ActiveSendContext) async throws {
2026-02-20 00:09:02 -08:00
let optimisticUser = Message(
id: "temp-user-\(UUID().uuidString)",
createdAt: Date(),
role: .user,
content: content,
2026-05-02 19:47:38 -07:00
name: nil,
metadata: SybilChatAttachmentSupport.metadataValue(for: attachments)
2026-02-20 00:09:02 -08:00
)
let optimisticAssistant = Message(
id: "temp-assistant-\(UUID().uuidString)",
createdAt: Date(),
role: .assistant,
content: "",
name: nil
)
2026-05-04 20:14:16 -07:00
let optimisticMessages = (currentSelectedChat?.messages ?? []) + [optimisticUser, optimisticAssistant]
2026-02-20 00:09:02 -08:00
let client = try client()
var chatID = currentChatID
2026-05-04 20:14:16 -07:00
if let chatID {
pendingChatStates[chatID] = PendingChatState(chatID: chatID, messages: optimisticMessages)
} else {
pendingDraftChatState = PendingChatState(chatID: nil, messages: optimisticMessages)
}
2026-02-20 00:09:02 -08:00
if chatID == nil {
2026-05-02 22:18:33 -07:00
let created = try await client.createChat(title: nil)
2026-02-20 00:09:02 -08:00
chatID = created.id
2026-05-04 20:14:16 -07:00
let shouldShowCreatedChat = isSendContextVisible(sendContext)
markSendContextInactive(sendContext)
localActiveChatIDs.insert(created.id)
2026-02-20 00:09:02 -08:00
chats.removeAll(where: { $0.id == created.id })
chats.insert(created, at: 0)
upsertWorkspaceChat(created)
2026-02-20 00:09:02 -08:00
if shouldShowCreatedChat {
draftKind = nil
selectedItem = .chat(created.id)
selectedChat = ChatDetail(
id: created.id,
title: created.title,
createdAt: created.createdAt,
updatedAt: created.updatedAt,
2026-05-28 22:47:45 -07:00
starred: created.starred,
starredAt: created.starredAt,
initiatedProvider: created.initiatedProvider,
initiatedModel: created.initiatedModel,
lastUsedProvider: created.lastUsedProvider,
lastUsedModel: created.lastUsedModel,
messages: []
)
selectedSearch = nil
}
2026-02-20 00:09:02 -08:00
SybilLog.info(SybilLog.app, "Created chat \(created.id)")
}
guard let chatID else {
throw APIError.invalidResponse
}
2026-05-04 20:14:16 -07:00
localActiveChatIDs.insert(chatID)
defer {
localActiveChatIDs.remove(chatID)
}
if let draftPending = pendingDraftChatState {
pendingDraftChatState = nil
pendingChatStates[chatID] = PendingChatState(chatID: chatID, messages: draftPending.messages)
} else if pendingChatStates[chatID] == nil {
pendingChatStates[chatID] = PendingChatState(chatID: chatID, messages: optimisticMessages)
} else {
pendingChatStates[chatID]?.chatID = chatID
}
2026-02-20 00:09:02 -08:00
let baseChat: ChatDetail
if let selectedChat = currentSelectedChat, selectedChat.id == chatID {
2026-02-20 00:09:02 -08:00
baseChat = selectedChat
} else {
baseChat = try await client.getChat(chatID: chatID)
}
let selectedModel = model.trimmingCharacters(in: .whitespacesAndNewlines)
guard !selectedModel.isEmpty else {
throw APIError.invalidResponse
}
let requestMessages: [CompletionRequestMessage] =
baseChat.messages
.filter { !$0.isToolCallLog }
.map {
2026-05-02 19:47:38 -07:00
CompletionRequestMessage(role: $0.role, content: $0.content, name: $0.name, attachments: $0.attachments.isEmpty ? nil : $0.attachments)
} + [CompletionRequestMessage(role: .user, content: content, attachments: attachments.isEmpty ? nil : attachments)]
2026-02-20 00:09:02 -08:00
let streamStatus = CompletionStreamStatus()
2026-05-03 16:42:49 -07:00
let streamLifecycleGeneration = appLifecycleGeneration
let streamStartedWhileInactive = !isAppActive
2026-02-20 00:09:02 -08:00
if isUntitledChat(chatID: chatID, detail: currentSelectedChat) {
2026-02-20 00:09:02 -08:00
Task { [weak self] in
guard let self else { return }
do {
2026-05-02 19:47:38 -07:00
let titleSeed = !content.isEmpty ? content : SybilChatAttachmentSupport.attachmentSummary(attachments)
let updated = try await client.suggestChatTitle(chatID: chatID, content: titleSeed.isEmpty ? "Uploaded files" : titleSeed)
2026-02-20 00:09:02 -08:00
await MainActor.run {
2026-05-28 22:47:45 -07:00
self.applyChatSummary(updated, moveToFront: false)
2026-02-20 00:09:02 -08:00
}
} catch {
SybilLog.warning(SybilLog.app, "Chat title suggestion failed: \(SybilLog.describe(error))")
}
}
}
2026-05-04 20:14:16 -07:00
let chatBackgroundTask = SybilBackgroundTaskAssertion(name: "Sybil Chat Response") {
2026-05-02 22:18:33 -07:00
SybilLog.warning(SybilLog.app, "Chat response background time expired")
}
defer {
chatBackgroundTask?.end()
}
2026-05-03 16:42:49 -07:00
do {
try await client.runCompletionStream(
body: CompletionStreamRequest(
chatId: chatID,
provider: provider,
model: selectedModel,
messages: requestMessages
)
) { [weak self] event in
guard let self else { return }
2026-05-04 20:14:16 -07:00
await self.applyCompletionEvent(event, chatID: chatID, streamStatus: streamStatus)
2026-05-03 16:42:49 -07:00
}
} catch {
if shouldSuppressLifecycleTransportError(
error,
startedAt: streamLifecycleGeneration,
startedWhileInactive: streamStartedWhileInactive
) {
SybilLog.info(SybilLog.app, "Suppressing chat stream transport interruption after app lifecycle change")
2026-05-04 20:14:16 -07:00
pendingChatStates[chatID] = nil
2026-05-03 16:42:49 -07:00
if isAppActive {
await refreshInterruptedStream(preferredSelection: .chat(chatID))
}
return
}
throw error
2026-02-20 00:09:02 -08:00
}
if let streamError = await streamStatus.error() {
throw APIError.httpError(statusCode: 502, message: streamError)
}
2026-05-03 16:42:49 -07:00
guard isAppActive else {
2026-05-04 20:14:16 -07:00
pendingChatStates[chatID] = nil
2026-05-03 16:42:49 -07:00
return
}
let sentChatSelection = SidebarSelection.chat(chatID)
let shouldKeepSentChatSelected = selectedItem == sentChatSelection && draftKind == nil
await refreshCollections(
preferredSelection: shouldKeepSentChatSelected ? sentChatSelection : selectedItem,
refreshSelection: false
)
guard selectedItem == sentChatSelection, draftKind == nil else {
2026-05-04 20:14:16 -07:00
pendingChatStates[chatID] = nil
return
}
2026-05-03 16:42:49 -07:00
do {
let refreshedChat = try await client.getChat(chatID: chatID)
guard selectedItem == sentChatSelection, draftKind == nil else {
2026-05-04 20:14:16 -07:00
pendingChatStates[chatID] = nil
return
}
selectedChat = refreshedChat
2026-05-03 16:42:49 -07:00
} catch {
if shouldSuppressLifecycleTransportError(
error,
startedAt: streamLifecycleGeneration,
startedWhileInactive: streamStartedWhileInactive
) {
SybilLog.info(SybilLog.app, "Suppressing chat refresh transport interruption after app lifecycle change")
2026-05-04 20:14:16 -07:00
pendingChatStates[chatID] = nil
2026-05-03 16:42:49 -07:00
if isAppActive {
await refreshInterruptedStream(preferredSelection: .chat(chatID))
}
return
}
throw error
}
2026-05-04 20:14:16 -07:00
pendingChatStates[chatID] = nil
2026-02-20 00:09:02 -08:00
}
2026-05-04 20:14:16 -07:00
private func applyCompletionEvent(_ event: CompletionStreamEvent, chatID: String, streamStatus: CompletionStreamStatus) async {
2026-02-20 00:09:02 -08:00
switch event {
case let .meta(payload):
2026-05-04 20:14:16 -07:00
if payload.chatId == chatID {
pendingChatStates[chatID]?.chatID = payload.chatId
}
2026-02-20 00:09:02 -08:00
case let .toolCall(payload):
2026-05-04 20:14:16 -07:00
insertPendingToolCallMessage(payload, chatID: chatID)
2026-02-20 00:09:02 -08:00
case let .delta(payload):
guard !payload.text.isEmpty else { return }
2026-05-04 20:14:16 -07:00
mutatePendingAssistantMessage(chatID: chatID) { existing in
2026-02-20 00:09:02 -08:00
existing + payload.text
}
case let .done(payload):
2026-05-04 20:14:16 -07:00
mutatePendingAssistantMessage(chatID: chatID) { _ in
2026-02-20 00:09:02 -08:00
payload.text
}
case let .error(payload):
await streamStatus.setError(payload.message)
case .ignored:
break
}
}
2026-05-04 20:14:16 -07:00
private func sendSearch(query: String, sendContext: ActiveSendContext) async throws {
2026-02-20 00:09:02 -08:00
let client = try client()
var searchID = currentSearchID
if searchID == nil {
let created = try await client.createSearch(title: String(query.prefix(80)), query: query)
searchID = created.id
2026-05-04 20:14:16 -07:00
let shouldShowCreatedSearch = isSendContextVisible(sendContext)
markSendContextInactive(sendContext)
localActiveSearchIDs.insert(created.id)
2026-02-20 00:09:02 -08:00
searches.removeAll(where: { $0.id == created.id })
searches.insert(created, at: 0)
upsertWorkspaceSearch(created)
2026-02-20 00:09:02 -08:00
if shouldShowCreatedSearch {
draftKind = nil
selectedItem = .search(created.id)
}
2026-02-20 00:09:02 -08:00
SybilLog.info(SybilLog.app, "Created search \(created.id)")
}
guard let searchID else {
throw APIError.invalidResponse
}
2026-05-04 20:14:16 -07:00
localActiveSearchIDs.insert(searchID)
defer {
localActiveSearchIDs.remove(searchID)
}
2026-02-20 00:09:02 -08:00
let now = Date()
2026-05-04 20:14:16 -07:00
let optimisticSearch = SearchDetail(
id: searchID,
title: String(query.prefix(80)),
query: query,
createdAt: currentSelectedSearch?.createdAt ?? now,
updatedAt: now,
2026-05-28 22:47:45 -07:00
starred: currentSelectedSearch?.starred ?? false,
starredAt: currentSelectedSearch?.starredAt,
2026-05-04 20:14:16 -07:00
requestId: nil,
latencyMs: nil,
error: nil,
answerText: nil,
answerRequestId: nil,
answerCitations: nil,
answerError: nil,
results: []
)
activeSearchDetails[searchID] = optimisticSearch
if selectedItem == .search(searchID), draftKind == nil {
2026-05-04 20:14:16 -07:00
selectedSearch = optimisticSearch
}
2026-02-20 00:09:02 -08:00
let streamStatus = SearchStreamStatus()
2026-05-03 16:42:49 -07:00
let streamLifecycleGeneration = appLifecycleGeneration
let streamStartedWhileInactive = !isAppActive
do {
try await client.runSearchStream(
searchID: searchID,
body: SearchRunRequest(query: query, title: String(query.prefix(80)), type: "auto", numResults: 10)
) { [weak self] event in
guard let self else { return }
await self.applySearchEvent(event, searchID: searchID, streamStatus: streamStatus)
}
} catch {
if shouldSuppressLifecycleTransportError(
error,
startedAt: streamLifecycleGeneration,
startedWhileInactive: streamStartedWhileInactive
) {
SybilLog.info(SybilLog.app, "Suppressing search stream transport interruption after app lifecycle change")
2026-05-04 20:14:16 -07:00
activeSearchDetails[searchID] = nil
2026-05-03 16:42:49 -07:00
if isAppActive {
await refreshInterruptedStream(preferredSelection: .search(searchID))
}
return
}
2026-02-20 00:09:02 -08:00
2026-05-04 20:14:16 -07:00
activeSearchDetails[searchID] = nil
2026-05-03 16:42:49 -07:00
throw error
2026-02-20 00:09:02 -08:00
}
if let streamError = await streamStatus.error() {
2026-05-04 20:14:16 -07:00
activeSearchDetails[searchID] = nil
2026-02-20 00:09:02 -08:00
throw APIError.httpError(statusCode: 502, message: streamError)
}
2026-05-03 16:42:49 -07:00
guard isAppActive else {
2026-05-04 20:14:16 -07:00
activeSearchDetails[searchID] = nil
2026-05-03 16:42:49 -07:00
return
}
let sentSearchSelection = SidebarSelection.search(searchID)
let shouldKeepSentSearchSelected = selectedItem == sentSearchSelection && draftKind == nil
await refreshCollections(
preferredSelection: shouldKeepSentSearchSelected ? sentSearchSelection : selectedItem,
refreshSelection: false
)
2026-05-04 20:14:16 -07:00
activeSearchDetails[searchID] = nil
2026-02-20 00:09:02 -08:00
}
private func applySearchEvent(
_ event: SearchStreamEvent,
searchID: String,
streamStatus: SearchStreamStatus
) async {
switch event {
case let .searchResults(payload):
2026-05-04 20:14:16 -07:00
guard var search = activeSearchDetails[searchID] ?? matchingSelectedSearch(searchID) else {
return
}
search.requestId = payload.requestId ?? search.requestId
search.error = nil
search.results = payload.results
setActiveSearch(search, searchID: searchID)
2026-02-20 00:09:02 -08:00
case let .searchError(payload):
2026-05-04 20:14:16 -07:00
guard var search = activeSearchDetails[searchID] ?? matchingSelectedSearch(searchID) else {
return
}
search.error = payload.error
setActiveSearch(search, searchID: searchID)
2026-02-20 00:09:02 -08:00
case let .answer(payload):
2026-05-04 20:14:16 -07:00
guard var search = activeSearchDetails[searchID] ?? matchingSelectedSearch(searchID) else {
return
}
search.answerText = payload.answerText
search.answerRequestId = payload.answerRequestId
search.answerCitations = payload.answerCitations
search.answerError = nil
setActiveSearch(search, searchID: searchID)
2026-02-20 00:09:02 -08:00
case let .answerError(payload):
2026-05-04 20:14:16 -07:00
guard var search = activeSearchDetails[searchID] ?? matchingSelectedSearch(searchID) else {
return
}
search.answerError = payload.error
setActiveSearch(search, searchID: searchID)
2026-02-20 00:09:02 -08:00
case let .done(payload):
2026-05-04 20:14:16 -07:00
setActiveSearch(payload.search, searchID: searchID)
2026-02-20 00:09:02 -08:00
case let .error(payload):
await streamStatus.setError(payload.message)
case .ignored:
break
}
}
2026-05-04 20:14:16 -07:00
private func matchingSelectedSearch(_ searchID: String) -> SearchDetail? {
guard let current = currentSelectedSearch, current.id == searchID else {
return nil
}
return current
}
private func setActiveSearch(_ search: SearchDetail, searchID: String) {
activeSearchDetails[searchID] = search
if selectedItem == .search(searchID), draftKind == nil {
selectedSearch = search
selectedChat = nil
}
}
private func mutatePendingAssistantMessage(chatID: String, _ transform: (String) -> String) {
guard var pending = pendingChatStates[chatID], !pending.messages.isEmpty else {
2026-02-20 00:09:02 -08:00
return
}
let index = pending.messages.indices.last { pending.messages[$0].id.hasPrefix("temp-assistant-") }
guard let index else {
return
}
var message = pending.messages[index]
message.content = transform(message.content)
pending.messages[index] = message
2026-05-04 20:14:16 -07:00
pendingChatStates[chatID] = pending
2026-02-20 00:09:02 -08:00
}
2026-05-06 21:53:51 -07:00
private func mutateQuickQuestionAssistantMessage(_ transform: (String) -> String) {
let index = quickQuestionMessages.indices.last { quickQuestionMessages[$0].id.hasPrefix("temp-assistant-quick-") }
guard let index else {
return
}
quickQuestionMessages[index].content = transform(quickQuestionMessages[index].content)
}
2026-05-04 20:14:16 -07:00
private func insertPendingToolCallMessage(_ payload: CompletionStreamToolCall, chatID: String) {
guard var pending = pendingChatStates[chatID] else {
return
}
if pending.messages.contains(where: { $0.toolCallMetadata?.toolCallId == payload.toolCallId }) {
return
}
2026-05-06 21:53:51 -07:00
let message = toolCallMessage(for: payload)
if let assistantIndex = pending.messages.indices.last(where: { pending.messages[$0].id.hasPrefix("temp-assistant-") }) {
pending.messages.insert(message, at: assistantIndex)
} else {
pending.messages.append(message)
}
pendingChatStates[chatID] = pending
}
private func insertQuickQuestionToolCallMessage(_ payload: CompletionStreamToolCall) {
if quickQuestionMessages.contains(where: { $0.toolCallMetadata?.toolCallId == payload.toolCallId }) {
return
}
let message = toolCallMessage(for: payload)
if let assistantIndex = quickQuestionMessages.indices.last(where: { quickQuestionMessages[$0].id.hasPrefix("temp-assistant-quick-") }) {
quickQuestionMessages.insert(message, at: assistantIndex)
} else {
quickQuestionMessages.append(message)
}
}
private func toolCallMessage(for payload: CompletionStreamToolCall) -> Message {
let metadata: JSONValue = .object([
"kind": .string("tool_call"),
"toolCallId": .string(payload.toolCallId),
"toolName": .string(payload.name),
"status": .string(payload.status),
"summary": .string(payload.summary),
"args": .object(payload.args),
"startedAt": .string(payload.startedAt),
"completedAt": .string(payload.completedAt),
"durationMs": .number(Double(payload.durationMs)),
"error": payload.error.map { .string($0) } ?? .null,
"resultPreview": payload.resultPreview.map { .string($0) } ?? .null
])
let summary = payload.summary.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
? "Ran tool '\(payload.name)'."
: payload.summary
2026-05-06 21:53:51 -07:00
return Message(
id: "temp-tool-\(payload.toolCallId)",
createdAt: Date(),
role: .tool,
content: summary,
name: payload.name,
metadata: metadata
)
}
2026-02-20 00:09:02 -08:00
private var currentChatID: String? {
if draftKind == .chat {
return nil
}
if case let .chat(chatID) = selectedItem {
return chatID
}
return nil
}
private var currentSelectedChat: ChatDetail? {
guard case let .chat(chatID) = selectedItem,
selectedChat?.id == chatID else {
return nil
}
return selectedChat
}
private var currentSelectedSearch: SearchDetail? {
guard case let .search(searchID) = selectedItem,
selectedSearch?.id == searchID else {
return nil
}
return selectedSearch
}
2026-02-20 00:09:02 -08:00
private var currentSearchID: String? {
if draftKind == .search {
return nil
}
if case let .search(searchID) = selectedItem {
return searchID
}
return nil
}
private var currentSendContext: ActiveSendContext {
if isSearchMode {
if let searchID = currentSearchID {
return .search(searchID)
}
return .draftSearch(draftIdentity)
}
if let chatID = currentChatID {
return .chat(chatID)
}
return .draftChat(draftIdentity)
}
2026-05-04 20:14:16 -07:00
private func isSendContextActive(_ context: ActiveSendContext) -> Bool {
switch context {
case let .draftChat(identity):
return activeDraftSendContexts.contains(.draftChat(identity))
case let .chat(chatID):
return activeChatIDs.contains(chatID)
case let .draftSearch(identity):
return activeDraftSendContexts.contains(.draftSearch(identity))
case let .search(searchID):
return activeSearchIDs.contains(searchID)
}
}
private func markSendContextActive(_ context: ActiveSendContext) {
switch context {
case .draftChat, .draftSearch:
activeDraftSendContexts.insert(context)
case let .chat(chatID):
localActiveChatIDs.insert(chatID)
case let .search(searchID):
localActiveSearchIDs.insert(searchID)
}
}
private func markSendContextInactive(_ context: ActiveSendContext) {
switch context {
case .draftChat, .draftSearch:
activeDraftSendContexts.remove(context)
case let .chat(chatID):
localActiveChatIDs.remove(chatID)
case let .search(searchID):
localActiveSearchIDs.remove(searchID)
}
}
private func clearPendingChatState(for context: ActiveSendContext) {
switch context {
case .draftChat:
pendingDraftChatState = nil
case let .chat(chatID):
pendingChatStates[chatID] = nil
case .draftSearch, .search:
break
}
}
private func isSendContextVisible(_ context: ActiveSendContext) -> Bool {
switch context {
case let .draftChat(identity):
return draftKind == .chat && draftIdentity == identity
case let .chat(chatID):
return selectedItem == .chat(chatID)
case let .draftSearch(identity):
return draftKind == .search && draftIdentity == identity
case let .search(searchID):
return selectedItem == .search(searchID)
}
}
2026-02-20 00:09:02 -08:00
private func hasSelection(_ selection: SidebarSelection, chats: [ChatSummary], searches: [SearchSummary]) -> Bool {
switch selection {
case let .chat(chatID):
return chats.contains(where: { $0.id == chatID })
case let .search(searchID):
return searches.contains(where: { $0.id == searchID })
case .settings:
return true
}
}
2026-05-02 16:48:01 -07:00
private func displayableMessages(_ messages: [Message]) -> [Message] {
messages.filter { $0.role != .system }
}
2026-02-20 00:09:02 -08:00
private func chatTitle(title: String?, messages: [Message]?) -> String {
if let title = title?.trimmingCharacters(in: .whitespacesAndNewlines), !title.isEmpty {
return title
}
if let firstUserMessage = messages?.first(where: { $0.role == .user })?.content.trimmingCharacters(in: .whitespacesAndNewlines),
!firstUserMessage.isEmpty {
return String(firstUserMessage.prefix(48))
}
2026-05-02 19:47:38 -07:00
if let firstUserMessage = messages?.first(where: { $0.role == .user }) {
let attachmentSummary = SybilChatAttachmentSupport.attachmentSummary(firstUserMessage.attachments)
if !attachmentSummary.isEmpty {
return String(attachmentSummary.prefix(48))
}
}
2026-02-20 00:09:02 -08:00
return "New chat"
}
private func searchTitle(title: String?, query: String?) -> String {
if let title = title?.trimmingCharacters(in: .whitespacesAndNewlines), !title.isEmpty {
return title
}
if let query = query?.trimmingCharacters(in: .whitespacesAndNewlines), !query.isEmpty {
return String(query.prefix(64))
}
return "New search"
}
private func normalizeAuthError(_ error: Error) -> String {
let normalized = normalizeAPIError(error)
if normalized.contains("missing bearer token") || normalized.contains("invalid bearer token") {
return "Authentication failed. Enter the ADMIN_TOKEN configured in server/.env."
}
return normalized
}
private func normalizeAPIError(_ error: Error) -> String {
if let apiError = error as? APIError {
switch apiError {
case .invalidBaseURL:
return "Set a valid API URL in Settings."
case let .httpError(_, message):
return message
case let .networkError(message):
return appendLoopbackHintIfNeeded(to: message)
case let .decodingError(message):
return message
case .invalidResponse:
return "Unexpected server response."
case .noResponseStream:
return "No response stream from server."
}
}
if let decodingError = error as? DecodingError {
return "Failed to decode server response: \(SybilLog.describe(decodingError))"
}
if let urlError = error as? URLError {
let base = "Network error \(urlError.code.rawValue): \(urlError.localizedDescription)"
return appendLoopbackHintIfNeeded(to: base)
}
if error is CancellationError {
return "Request was cancelled."
}
return (error as NSError).localizedDescription
}
private func appendLoopbackHintIfNeeded(to message: String) -> String {
guard let baseURL = settings.normalizedAPIBaseURL,
let host = baseURL.host?.lowercased(),
host == "127.0.0.1" || host == "localhost" else {
return message
}
#if targetEnvironment(simulator)
return message
#else
return message + " On physical devices, localhost/127.0.0.1 points to the phone. Use your Mac's LAN IP in Settings."
#endif
}
2026-05-03 16:42:49 -07:00
private func refreshInterruptedStream(preferredSelection: SidebarSelection) async {
try? await Task.sleep(for: .milliseconds(150))
await refreshCollections(preferredSelection: preferredSelection)
}
private func shouldSuppressLifecycleTransportError(
_ error: Error,
startedAt generation: Int,
startedWhileInactive: Bool
) -> Bool {
guard generation != appLifecycleGeneration || startedWhileInactive || !isAppActive else {
return false
}
return isTransientTransportInterruption(error)
}
private func shouldSuppressInactiveTransportError(_ error: Error) -> Bool {
!isAppActive && isTransientTransportInterruption(error)
}
private func isTransientTransportInterruption(_ error: Error) -> Bool {
if isCancellation(error) {
return true
}
if let apiError = error as? APIError,
case let .networkError(message) = apiError {
let lowercased = message.lowercased()
return lowercased.contains("network error -999")
|| lowercased.contains("network error -1005")
|| lowercased.contains("network connection was lost")
|| lowercased.contains("software caused connection abort")
|| lowercased.contains("socket is not connected")
}
let nsError = error as NSError
if nsError.domain == NSURLErrorDomain {
return nsError.code == URLError.cancelled.rawValue
|| nsError.code == URLError.networkConnectionLost.rawValue
|| nsError.code == URLError.notConnectedToInternet.rawValue
|| nsError.code == URLError.timedOut.rawValue
}
if nsError.domain == NSPOSIXErrorDomain {
return nsError.code == 53 || nsError.code == 57
}
return false
}
2026-05-04 20:14:16 -07:00
private func isActiveStreamNotFound(_ error: Error) -> Bool {
if let apiError = error as? APIError,
case let .httpError(statusCode, _) = apiError {
return statusCode == 404
}
return false
}
2026-02-20 00:09:02 -08:00
private func isCancellation(_ error: Error) -> Bool {
if error is CancellationError {
return true
}
if let urlError = error as? URLError, urlError.code == .cancelled {
return true
}
return false
}
2026-05-02 22:18:33 -07:00
private func client() throws -> any SybilAPIClienting {
2026-02-20 00:09:02 -08:00
guard let baseURL = settings.normalizedAPIBaseURL else {
throw APIError.invalidBaseURL
}
SybilLog.debug(
SybilLog.app,
"Creating API client for \(baseURL.absoluteString) (token: \(settings.trimmedTokenOrNil == nil ? "none" : "set"))"
)
2026-05-02 22:18:33 -07:00
return clientFactory(
APIConfiguration(
2026-02-20 00:09:02 -08:00
baseURL: baseURL,
authToken: settings.trimmedTokenOrNil
)
)
}
private func isUntitledChat(chatID: String, detail: ChatDetail?) -> Bool {
if let detail, detail.id == chatID {
if let title = detail.title?.trimmingCharacters(in: .whitespacesAndNewlines), !title.isEmpty {
return false
}
return true
}
if let summary = chats.first(where: { $0.id == chatID }) {
if let title = summary.title?.trimmingCharacters(in: .whitespacesAndNewlines), !title.isEmpty {
return false
}
}
return true
}
}