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 var starred: Bool var starredAt: Date? var initiatedLabel: String? var isRunning: Bool } private struct PendingChatState { var chatID: String? var messages: [Message] } 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 } } } 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] = [] var selectedItem: SidebarSelection? var selectedChat: ChatDetail? var selectedSearch: SearchDetail? var draftKind: DraftKind? var isLoadingCollections = false var isLoadingSelection = false var isCreatingSearchChat = false var chatBottomPinRequestID = 0 var errorMessage: String? var composer = "" var composerAttachments: [ChatAttachment] = [] var provider: Provider var modelCatalog: [Provider: ProviderModelInfo] = [:] var model: String 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 @ObservationIgnored private var hasBootstrapped = false private var pendingDraftChatState: PendingChatState? private var pendingChatStates: [String: PendingChatState] = [:] private var activeSearchDetails: [String: SearchDetail] = [:] private var activeDraftSendContexts: Set = [] private var localActiveChatIDs: Set = [] private var localActiveSearchIDs: Set = [] private var serverActiveChatIDs: Set = [] private var serverActiveSearchIDs: Set = [] private var draftIdentity = UUID() @ObservationIgnored private var selectionTask: Task? @ObservationIgnored private var activeRunPollingTask: Task? @ObservationIgnored private var activeChatAttachTasks: [String: Task] = [:] @ObservationIgnored private var activeSearchAttachTasks: [String: Task] = [:] @ObservationIgnored private var quickQuestionTask: Task? @ObservationIgnored private var quickQuestionRunID: UUID? @ObservationIgnored private var isAppActive = true @ObservationIgnored private var appLifecycleGeneration = 0 @ObservationIgnored private let clientFactory: (APIConfiguration) -> any SybilAPIClienting private let fallbackModels: [Provider: [String]] = [ .openai: ["gpt-4.1-mini"], .anthropic: ["claude-3-5-sonnet-latest"], .xai: ["grok-3-mini"], .hermesAgent: ["hermes-agent"] ] init( settings: SybilSettingsStore = SybilSettingsStore(), clientFactory: @escaping (APIConfiguration) -> any SybilAPIClienting = { configuration in SybilAPIClient(configuration: configuration) } ) { self.settings = settings self.clientFactory = clientFactory 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 } var providerModelOptions: [String] { modelOptions(for: provider) } var providerOptions: [Provider] { Provider.allCases.filter { candidate in candidate != .hermesAgent || modelCatalog[candidate] != nil } } 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) } func modelOptions(for candidate: Provider) -> [String] { let serverModels = modelCatalog[candidate]?.models ?? [] if !serverModels.isEmpty { return serverModels } return fallbackModels[candidate] ?? [] } 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 { 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 { 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 } private var activeChatIDs: Set { localActiveChatIDs.union(serverActiveChatIDs) } private var activeSearchIDs: Set { 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) } var canSendComposer: Bool { if isActiveSelectionSending { return false } let content = composer.trimmingCharacters(in: .whitespacesAndNewlines) if isSearchMode { return !content.isEmpty } return !content.isEmpty || !composerAttachments.isEmpty } var displayedMessages: [Message] { let canonical = displayableMessages(currentSelectedChat?.messages ?? []) if case let .chat(chatID) = selectedItem, let pending = pendingChatStates[chatID] { return displayableMessages(pending.messages) } if draftKind == .chat, let pending = pendingDraftChatState { return displayableMessages(pending.messages) } return canonical } var displayedSearch: SearchDetail? { if case let .search(searchID) = selectedItem, let activeSearch = activeSearchDetails[searchID] { return activeSearch } return currentSelectedSearch } var isSendingVisibleChat: Bool { if draftKind == .chat { return activeDraftSendContexts.contains(.draftChat(draftIdentity)) } if case let .chat(chatID) = selectedItem { return isChatRowRunning(chatID) } return false } var isRunningVisibleSearch: Bool { if draftKind == .search { return activeDraftSendContexts.contains(.draftSearch(draftIdentity)) } if case let .search(searchID) = selectedItem { return isSearchRowRunning(searchID) } return false } 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 } } else { initiatedLabel = nil } return SidebarItem( selection: .chat(item.id), kind: .chat, title: chatTitle(title: item.title, messages: nil), updatedAt: item.updatedAt, starred: item.starred, starredAt: item.starredAt, initiatedLabel: initiatedLabel, isRunning: isChatRowRunning(item.id) ) case .search: return SidebarItem( selection: .search(item.id), kind: .search, title: searchTitle(title: item.title, query: item.query), updatedAt: item.updatedAt, starred: item.starred, starredAt: item.starredAt, initiatedLabel: "exa", isRunning: isSearchRowRunning(item.id) ) } } } 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 }) } var hasRefreshableSelection: Bool { guard draftKind == nil, let selectedItem else { return false } switch selectedItem { case .chat, .search: return true case .settings: return false } } 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() stopActiveRunPolling() cancelActiveStreamAttachTasks() pendingDraftChatState = nil pendingChatStates = [:] activeSearchDetails = [:] activeDraftSendContexts = [] localActiveChatIDs = [] localActiveSearchIDs = [] serverActiveChatIDs = [] serverActiveSearchIDs = [] resetQuickQuestion() draftIdentity = UUID() composerAttachments = [] settings.persist() SybilLog.info( SybilLog.app, "Reconnecting with API base URL \(settings.normalizedAPIBaseURL?.absoluteString ?? "")" ) 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) startActiveRunPolling() } catch { stopActiveRunPolling() isAuthenticated = false authMode = nil chats = [] searches = [] workspaceItems = [] 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)") } 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)") } 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? { 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) draftKind = nil selectedItem = .chat(chat.id) selectedChat = ChatDetail( id: chat.id, title: chat.title, createdAt: chat.createdAt, updatedAt: chat.updatedAt, starred: chat.starred, starredAt: chat.starredAt, 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 } } func startNewChat() { SybilLog.debug(SybilLog.ui, "Starting draft chat") resetSelectionLoading() draftIdentity = UUID() draftKind = .chat selectedItem = nil selectedChat = nil selectedSearch = nil pendingDraftChatState = nil errorMessage = nil composer = "" composerAttachments = [] } func startNewSearch() { SybilLog.debug(SybilLog.ui, "Starting draft search") resetSelectionLoading() draftIdentity = UUID() draftKind = .search selectedItem = nil selectedChat = nil selectedSearch = nil pendingDraftChatState = nil errorMessage = nil composer = "" composerAttachments = [] } func openSettings() { SybilLog.debug(SybilLog.ui, "Opening settings") resetSelectionLoading() draftKind = nil selectedItem = .settings selectedChat = nil selectedSearch = nil pendingDraftChatState = nil errorMessage = nil composerAttachments = [] } 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? { 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() 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 composerAttachments = [] case .settings: selectedChat = nil selectedSearch = nil return nil } return startSelectionRefreshTask() } 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) } 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 } } 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) applyChatSummary(updated, moveToFront: true) } catch { errorMessage = normalizeAPIError(error) SybilLog.error(SybilLog.ui, "Rename failed", error: error) } } 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 } } func refreshAfterSettingsChange() async { SybilLog.info(SybilLog.ui, "Settings changed, reconnecting") settings.persist() await reconnect() } 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 ) } 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))" ) if shouldRefreshCollections { await refreshCollections(preferredSelection: selectedItem, refreshSelection: shouldRefreshSelection) return } if shouldRefreshSelection { await refreshActiveRunsFromServer() await refreshSelectionIfNeeded() } } 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 let sendContext = currentSendContext guard !isSendContextActive(sendContext) else { return } if sendContext.isSearch { guard !content.isEmpty else { return } } else if content.isEmpty && attachments.isEmpty { return } composer = "" composerAttachments = [] errorMessage = nil markSendContextActive(sendContext) defer { markSendContextInactive(sendContext) } do { if sendContext.isSearch { SybilLog.info(SybilLog.ui, "Sending search query") try await sendSearch(query: content, sendContext: sendContext) } else { SybilLog.info(SybilLog.ui, "Sending chat prompt") try await sendChat(content: content, attachments: attachments, sendContext: sendContext) } } catch { let shouldSurfaceError = isSendContextVisible(sendContext) if shouldSurfaceError { errorMessage = normalizeAPIError(error) } SybilLog.error(SybilLog.ui, "Send failed", error: error) if shouldSurfaceError, case let .chat(chatID) = selectedItem { do { let chat = try await client().getChat(chatID: chatID) if selectedItem == .chat(chatID), draftKind == nil { selectedChat = chat } } catch { SybilLog.error(SybilLog.ui, "Fallback chat refresh after failure failed", error: error) } } if shouldSurfaceError, case let .search(searchID) = selectedItem { do { let search = try await client().getSearch(searchID: searchID) if selectedItem == .search(searchID), draftKind == nil { selectedSearch = search } } catch { SybilLog.error(SybilLog.ui, "Fallback search refresh after failure failed", error: error) } } if !sendContext.isSearch, shouldSurfaceError { composer = content composerAttachments = attachments } clearPendingChatState(for: sendContext) } } 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 } } func startChatFromSelectedSearch() async { guard let search = currentSelectedSearch, !isCreatingSearchChat, !isActiveSelectionSending else { return } let sourceSelection = SidebarSelection.search(search.id) isCreatingSearchChat = true errorMessage = nil do { let client = try client() 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 } draftKind = nil pendingDraftChatState = nil composer = "" composerAttachments = [] chats.removeAll(where: { $0.id == chat.id }) chats.insert(chat, at: 0) upsertWorkspaceChat(chat) 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 } 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): upsertQuickQuestionToolCallMessage(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 } } private func loadInitialData(using client: any SybilAPIClienting) async { isLoadingCollections = true errorMessage = nil do { async let workspaceItemsValue = client.listWorkspaceItems() async let activeRunsValue = client.getActiveRuns() let (nextWorkspaceItems, nextActiveRuns) = try await (workspaceItemsValue, activeRunsValue) applyWorkspaceItems(nextWorkspaceItems) applyActiveRuns(nextActiveRuns) SybilLog.info( SybilLog.app, "Loaded collections: \(chats.count) chats, \(searches.count) searches" ) 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) { 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() } } attachToVisibleActiveRunIfNeeded() } catch { errorMessage = normalizeAPIError(error) SybilLog.error(SybilLog.app, "Initial data load failed", error: error) } isLoadingCollections = false } private func syncModelSelectionWithServerCatalog() { if !providerOptions.contains(provider), let firstProvider = providerOptions.first { provider = firstProvider settings.preferredProvider = firstProvider } 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 } 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 } settings.persist() } private func refreshCollections( preferredSelection: SidebarSelection?, refreshSelection: Bool = true, attachVisibleActiveRun: Bool = true ) async { isLoadingCollections = true do { let client = try client() async let workspaceItemsValue = client.listWorkspaceItems() async let activeRunsValue = client.getActiveRuns() let (nextWorkspaceItems, nextActiveRuns) = try await (workspaceItemsValue, activeRunsValue) applyWorkspaceItems(nextWorkspaceItems) applyActiveRuns(nextActiveRuns) SybilLog.info( SybilLog.app, "Refreshed collections: \(chats.count) chats, \(searches.count) searches" ) errorMessage = nil if draftKind != nil { if attachVisibleActiveRun { attachToVisibleActiveRunIfNeeded() } isLoadingCollections = false return } if case .settings = selectedItem { isLoadingCollections = false return } if let preferredSelection, hasSelection(preferredSelection, chats: chats, searches: searches) { selectedItem = preferredSelection } else if let existing = selectedItem, hasSelection(existing, chats: chats, searches: searches) { selectedItem = existing } else { selectedItem = sidebarItems.first?.selection } if refreshSelection, selectedItem != nil { await refreshSelectionIfNeeded() } if attachVisibleActiveRun { attachToVisibleActiveRunIfNeeded() } } catch { 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) SybilLog.error(SybilLog.app, "Refresh collections failed", error: error) } } isLoadingCollections = false } 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) } 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) } 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 requestChatBottomPin() { chatBottomPinRequestID += 1 } private func startSelectionRefreshTask() -> Task { 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 } } private func refreshSelectionIfNeeded() async { guard let target = selectedItem else { selectedChat = nil selectedSearch = nil isLoadingSelection = false return } guard case .settings = target else { isLoadingSelection = true do { let client = try client() switch target { 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 selectedSearch = nil requestChatBottomPin() if let provider = chat.lastUsedProvider, let model = chat.lastUsedModel, !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 selectedChat = nil case .settings: break } errorMessage = nil } catch { if isCancellation(error) { SybilLog.debug(SybilLog.app, "Selection refresh cancelled for \(target.id)") } else if shouldSuppressInactiveTransportError(error) { SybilLog.info(SybilLog.app, "Suppressing selection refresh transport interruption while app is inactive") } else if selectedItem == target, draftKind == nil { errorMessage = normalizeAPIError(error) SybilLog.error(SybilLog.app, "Selection refresh failed", error: error) } } if selectedItem == target, draftKind == nil { isLoadingSelection = false selectionTask = nil attachToVisibleActiveRunIfNeeded() } return } selectedChat = nil selectedSearch = nil isLoadingSelection = false } private func sendChat(content: String, attachments: [ChatAttachment], sendContext: ActiveSendContext) async throws { let optimisticUser = Message( id: "temp-user-\(UUID().uuidString)", createdAt: Date(), role: .user, content: content, name: nil, metadata: SybilChatAttachmentSupport.metadataValue(for: attachments) ) let optimisticAssistant = Message( id: "temp-assistant-\(UUID().uuidString)", createdAt: Date(), role: .assistant, content: "", name: nil ) let optimisticMessages = (currentSelectedChat?.messages ?? []) + [optimisticUser, optimisticAssistant] let client = try client() var chatID = currentChatID if let chatID { pendingChatStates[chatID] = PendingChatState(chatID: chatID, messages: optimisticMessages) } else { pendingDraftChatState = PendingChatState(chatID: nil, messages: optimisticMessages) } requestChatBottomPin() if chatID == nil { let created = try await client.createChat(title: nil) chatID = created.id let shouldShowCreatedChat = isSendContextVisible(sendContext) markSendContextInactive(sendContext) localActiveChatIDs.insert(created.id) chats.removeAll(where: { $0.id == created.id }) chats.insert(created, at: 0) upsertWorkspaceChat(created) if shouldShowCreatedChat { draftKind = nil selectedItem = .chat(created.id) selectedChat = ChatDetail( id: created.id, title: created.title, createdAt: created.createdAt, updatedAt: created.updatedAt, starred: created.starred, starredAt: created.starredAt, initiatedProvider: created.initiatedProvider, initiatedModel: created.initiatedModel, lastUsedProvider: created.lastUsedProvider, lastUsedModel: created.lastUsedModel, messages: [] ) selectedSearch = nil } SybilLog.info(SybilLog.app, "Created chat \(created.id)") } guard let chatID else { throw APIError.invalidResponse } localActiveChatIDs.insert(chatID) defer { localActiveChatIDs.remove(chatID) } if let draftPending = pendingDraftChatState { pendingDraftChatState = nil pendingChatStates[chatID] = PendingChatState(chatID: chatID, messages: draftPending.messages) requestChatBottomPin() } else if pendingChatStates[chatID] == nil { pendingChatStates[chatID] = PendingChatState(chatID: chatID, messages: optimisticMessages) } else { pendingChatStates[chatID]?.chatID = chatID } let baseChat: ChatDetail if let selectedChat = currentSelectedChat, selectedChat.id == chatID { 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 { 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)] let streamStatus = CompletionStreamStatus() let streamLifecycleGeneration = appLifecycleGeneration let streamStartedWhileInactive = !isAppActive if isUntitledChat(chatID: chatID, detail: currentSelectedChat) { Task { [weak self] in guard let self else { return } do { let titleSeed = !content.isEmpty ? content : SybilChatAttachmentSupport.attachmentSummary(attachments) let updated = try await client.suggestChatTitle(chatID: chatID, content: titleSeed.isEmpty ? "Uploaded files" : titleSeed) await MainActor.run { self.applyChatSummary(updated, moveToFront: false) } } catch { SybilLog.warning(SybilLog.app, "Chat title suggestion failed: \(SybilLog.describe(error))") } } } let chatBackgroundTask = SybilBackgroundTaskAssertion(name: "Sybil Chat Response") { SybilLog.warning(SybilLog.app, "Chat response background time expired") } defer { chatBackgroundTask?.end() } do { try await client.runCompletionStream( body: CompletionStreamRequest( chatId: chatID, provider: provider, model: selectedModel, messages: requestMessages ) ) { [weak self] event in guard let self else { return } await self.applyCompletionEvent(event, chatID: chatID, streamStatus: streamStatus) } } catch { if shouldSuppressLifecycleTransportError( error, startedAt: streamLifecycleGeneration, startedWhileInactive: streamStartedWhileInactive ) { SybilLog.info(SybilLog.app, "Suppressing chat stream transport interruption after app lifecycle change") pendingChatStates[chatID] = nil if isAppActive { await refreshInterruptedStream(preferredSelection: .chat(chatID)) } return } throw error } if let streamError = await streamStatus.error() { throw APIError.httpError(statusCode: 502, message: streamError) } guard isAppActive else { pendingChatStates[chatID] = nil 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 { pendingChatStates[chatID] = nil return } do { let refreshedChat = try await client.getChat(chatID: chatID) guard selectedItem == sentChatSelection, draftKind == nil else { pendingChatStates[chatID] = nil return } selectedChat = refreshedChat } catch { if shouldSuppressLifecycleTransportError( error, startedAt: streamLifecycleGeneration, startedWhileInactive: streamStartedWhileInactive ) { SybilLog.info(SybilLog.app, "Suppressing chat refresh transport interruption after app lifecycle change") pendingChatStates[chatID] = nil if isAppActive { await refreshInterruptedStream(preferredSelection: .chat(chatID)) } return } throw error } pendingChatStates[chatID] = nil } private func applyCompletionEvent(_ event: CompletionStreamEvent, chatID: String, streamStatus: CompletionStreamStatus) async { switch event { case let .meta(payload): if payload.chatId == chatID { pendingChatStates[chatID]?.chatID = payload.chatId } case let .toolCall(payload): upsertPendingToolCallMessage(payload, chatID: chatID) case let .delta(payload): guard !payload.text.isEmpty else { return } mutatePendingAssistantMessage(chatID: chatID) { existing in existing + payload.text } case let .done(payload): mutatePendingAssistantMessage(chatID: chatID) { _ in payload.text } case let .error(payload): await streamStatus.setError(payload.message) case .ignored: break } } private func sendSearch(query: String, sendContext: ActiveSendContext) async throws { 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 let shouldShowCreatedSearch = isSendContextVisible(sendContext) markSendContextInactive(sendContext) localActiveSearchIDs.insert(created.id) searches.removeAll(where: { $0.id == created.id }) searches.insert(created, at: 0) upsertWorkspaceSearch(created) if shouldShowCreatedSearch { draftKind = nil selectedItem = .search(created.id) } SybilLog.info(SybilLog.app, "Created search \(created.id)") } guard let searchID else { throw APIError.invalidResponse } localActiveSearchIDs.insert(searchID) defer { localActiveSearchIDs.remove(searchID) } let now = Date() let optimisticSearch = SearchDetail( id: searchID, title: String(query.prefix(80)), query: query, createdAt: currentSelectedSearch?.createdAt ?? now, updatedAt: now, starred: currentSelectedSearch?.starred ?? false, starredAt: currentSelectedSearch?.starredAt, requestId: nil, latencyMs: nil, error: nil, answerText: nil, answerRequestId: nil, answerCitations: nil, answerError: nil, results: [] ) activeSearchDetails[searchID] = optimisticSearch if selectedItem == .search(searchID), draftKind == nil { selectedSearch = optimisticSearch } let streamStatus = SearchStreamStatus() 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") activeSearchDetails[searchID] = nil if isAppActive { await refreshInterruptedStream(preferredSelection: .search(searchID)) } return } activeSearchDetails[searchID] = nil throw error } if let streamError = await streamStatus.error() { activeSearchDetails[searchID] = nil throw APIError.httpError(statusCode: 502, message: streamError) } guard isAppActive else { activeSearchDetails[searchID] = nil return } let sentSearchSelection = SidebarSelection.search(searchID) let shouldKeepSentSearchSelected = selectedItem == sentSearchSelection && draftKind == nil await refreshCollections( preferredSelection: shouldKeepSentSearchSelected ? sentSearchSelection : selectedItem, refreshSelection: false ) activeSearchDetails[searchID] = nil } private func applySearchEvent( _ event: SearchStreamEvent, searchID: String, streamStatus: SearchStreamStatus ) async { switch event { case let .searchResults(payload): 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) case let .searchError(payload): guard var search = activeSearchDetails[searchID] ?? matchingSelectedSearch(searchID) else { return } search.error = payload.error setActiveSearch(search, searchID: searchID) case let .answer(payload): 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) case let .answerError(payload): guard var search = activeSearchDetails[searchID] ?? matchingSelectedSearch(searchID) else { return } search.answerError = payload.error setActiveSearch(search, searchID: searchID) case let .done(payload): setActiveSearch(payload.search, searchID: searchID) case let .error(payload): await streamStatus.setError(payload.message) case .ignored: break } } 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 { 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 pendingChatStates[chatID] = pending } 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) } private func upsertPendingToolCallMessage(_ payload: CompletionStreamToolCall, chatID: String) { guard var pending = pendingChatStates[chatID] else { return } if let existingIndex = pending.messages.firstIndex(where: { $0.toolCallMetadata?.toolCallId == payload.toolCallId || $0.id == "temp-tool-\(payload.toolCallId)" }) { pending.messages[existingIndex] = toolCallMessage(for: payload, id: pending.messages[existingIndex].id) pendingChatStates[chatID] = pending return } 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 upsertQuickQuestionToolCallMessage(_ payload: CompletionStreamToolCall) { if let existingIndex = quickQuestionMessages.firstIndex(where: { $0.toolCallMetadata?.toolCallId == payload.toolCallId || $0.id == "temp-tool-\(payload.toolCallId)" }) { quickQuestionMessages[existingIndex] = toolCallMessage(for: payload, id: quickQuestionMessages[existingIndex].id) 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, id: String? = nil) -> Message { var metadataObject: [String: JSONValue] = [ "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), "error": payload.error.map { .string($0) } ?? .null, "resultPreview": payload.resultPreview.map { .string($0) } ?? .null ] if let completedAt = payload.completedAt { metadataObject["completedAt"] = .string(completedAt) } if let durationMs = payload.durationMs { metadataObject["durationMs"] = .number(Double(durationMs)) } let metadata: JSONValue = .object(metadataObject) let summary = payload.summary.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty ? "Ran tool '\(payload.name)'." : payload.summary return Message( id: id ?? "temp-tool-\(payload.toolCallId)", createdAt: toolCallDate(from: payload.completedAt) ?? toolCallDate(from: payload.startedAt) ?? Date(), role: .tool, content: summary, name: payload.name, metadata: metadata ) } private func toolCallDate(from value: String?) -> Date? { guard let value else { return nil } let fractionalFormatter = ISO8601DateFormatter() fractionalFormatter.formatOptions = [.withInternetDateTime, .withFractionalSeconds] if let date = fractionalFormatter.date(from: value) { return date } let formatter = ISO8601DateFormatter() formatter.formatOptions = [.withInternetDateTime] return formatter.date(from: value) } 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 } 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) } 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) } } 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 } } private func displayableMessages(_ messages: [Message]) -> [Message] { messages.filter { $0.role != .system } } 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)) } if let firstUserMessage = messages?.first(where: { $0.role == .user }) { let attachmentSummary = SybilChatAttachmentSupport.attachmentSummary(firstUserMessage.attachments) if !attachmentSummary.isEmpty { return String(attachmentSummary.prefix(48)) } } 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 } 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 } private func isActiveStreamNotFound(_ error: Error) -> Bool { if let apiError = error as? APIError, case let .httpError(statusCode, _) = apiError { return statusCode == 404 } return false } 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 } private func client() throws -> any SybilAPIClienting { 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"))" ) return clientFactory( APIConfiguration( 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 } }