From be072fd46d1a37ac2ad1d05ce45550cbdba361e4 Mon Sep 17 00:00:00 2001 From: James Magahern Date: Mon, 4 May 2026 20:14:16 -0700 Subject: [PATCH] ios: add multi-polling support --- .../Sybil/Sources/Sybil/SybilAPIClient.swift | 176 +++-- .../Sources/Sybil/SybilAPIClienting.swift | 9 + .../Sybil/Sources/Sybil/SybilModels.swift | 10 + .../Sources/Sybil/SybilPhoneShellView.swift | 7 + .../Sources/Sybil/SybilSidebarView.swift | 19 + .../Sybil/Sources/Sybil/SybilViewModel.swift | 663 ++++++++++++++---- .../Sources/Sybil/SybilWorkspaceView.swift | 10 +- .../Sybil/Tests/SybilTests/SybilTests.swift | 116 ++- 8 files changed, 824 insertions(+), 186 deletions(-) diff --git a/ios/Packages/Sybil/Sources/Sybil/SybilAPIClient.swift b/ios/Packages/Sybil/Sources/Sybil/SybilAPIClient.swift index 02dac01..c272e62 100644 --- a/ios/Packages/Sybil/Sources/Sybil/SybilAPIClient.swift +++ b/ios/Packages/Sybil/Sources/Sybil/SybilAPIClient.swift @@ -116,6 +116,10 @@ actor SybilAPIClient: SybilAPIClienting { try await request("/v1/models", method: "GET", responseType: ModelCatalogResponse.self) } + func getActiveRuns() async throws -> ActiveRunsResponse { + try await request("/v1/active-runs", method: "GET", responseType: ActiveRunsResponse.self) + } + func runCompletionStream( body: CompletionStreamRequest, onEvent: @escaping @Sendable (CompletionStreamEvent) async -> Void @@ -133,43 +137,35 @@ actor SybilAPIClient: SybilAPIClienting { ) try await stream(request: request) { eventName, dataText in - switch eventName { - case "meta": - let payload: CompletionStreamMeta = try Self.decodeEvent(dataText, as: CompletionStreamMeta.self, eventName: eventName) - await onEvent(.meta(payload)) - case "tool_call": - let payload: CompletionStreamToolCall = try Self.decodeEvent(dataText, as: CompletionStreamToolCall.self, eventName: eventName) - await onEvent(.toolCall(payload)) - case "delta": - let payload: CompletionStreamDelta = try Self.decodeEvent(dataText, as: CompletionStreamDelta.self, eventName: eventName) - await onEvent(.delta(payload)) - case "done": - do { - let payload: CompletionStreamDone = try Self.decodeEvent(dataText, as: CompletionStreamDone.self, eventName: eventName) - await onEvent(.done(payload)) - } catch { - if let recovered = Self.decodeLastJSONLine(dataText, as: CompletionStreamDone.self) { - SybilLog.warning( - SybilLog.network, - "Recovered chat stream done payload from concatenated SSE data" - ) - await onEvent(.done(recovered)) - } else { - throw error - } - } - case "error": - let payload: StreamErrorPayload = try Self.decodeEvent(dataText, as: StreamErrorPayload.self, eventName: eventName) - await onEvent(.error(payload)) - default: - SybilLog.warning(SybilLog.network, "Ignoring unknown chat stream event '\(eventName)'") - await onEvent(.ignored) - } + try await Self.handleCompletionStreamEvent(eventName: eventName, dataText: dataText, onEvent: onEvent) } SybilLog.info(SybilLog.network, "Chat stream completed") } + func attachCompletionStream( + chatID: String, + onEvent: @escaping @Sendable (CompletionStreamEvent) async -> Void + ) async throws { + let request = try makeRequest( + path: "/v1/chats/\(chatID)/stream/attach", + method: "POST", + body: nil, + acceptsSSE: true + ) + + SybilLog.info( + SybilLog.network, + "Attaching chat stream POST \(request.url?.absoluteString ?? "")" + ) + + try await stream(request: request) { eventName, dataText in + try await Self.handleCompletionStreamEvent(eventName: eventName, dataText: dataText, onEvent: onEvent) + } + + SybilLog.info(SybilLog.network, "Attached chat stream completed") + } + func runSearchStream( searchID: String, body: SearchRunRequest, @@ -188,34 +184,35 @@ actor SybilAPIClient: SybilAPIClienting { ) try await stream(request: request) { eventName, dataText in - switch eventName { - case "search_results": - let payload: SearchResultsPayload = try Self.decodeEvent(dataText, as: SearchResultsPayload.self, eventName: eventName) - await onEvent(.searchResults(payload)) - case "search_error": - let payload: SearchErrorPayload = try Self.decodeEvent(dataText, as: SearchErrorPayload.self, eventName: eventName) - await onEvent(.searchError(payload)) - case "answer": - let payload: SearchAnswerPayload = try Self.decodeEvent(dataText, as: SearchAnswerPayload.self, eventName: eventName) - await onEvent(.answer(payload)) - case "answer_error": - let payload: SearchErrorPayload = try Self.decodeEvent(dataText, as: SearchErrorPayload.self, eventName: eventName) - await onEvent(.answerError(payload)) - case "done": - let payload: SearchDonePayload = try Self.decodeEvent(dataText, as: SearchDonePayload.self, eventName: eventName) - await onEvent(.done(payload)) - case "error": - let payload: StreamErrorPayload = try Self.decodeEvent(dataText, as: StreamErrorPayload.self, eventName: eventName) - await onEvent(.error(payload)) - default: - SybilLog.warning(SybilLog.network, "Ignoring unknown search stream event '\(eventName)'") - await onEvent(.ignored) - } + try await Self.handleSearchStreamEvent(eventName: eventName, dataText: dataText, onEvent: onEvent) } SybilLog.info(SybilLog.network, "Search stream completed") } + func attachSearchStream( + searchID: String, + onEvent: @escaping @Sendable (SearchStreamEvent) async -> Void + ) async throws { + let request = try makeRequest( + path: "/v1/searches/\(searchID)/run/stream/attach", + method: "POST", + body: nil, + acceptsSSE: true + ) + + SybilLog.info( + SybilLog.network, + "Attaching search stream POST \(request.url?.absoluteString ?? "")" + ) + + try await stream(request: request) { eventName, dataText in + try await Self.handleSearchStreamEvent(eventName: eventName, dataText: dataText, onEvent: onEvent) + } + + SybilLog.info(SybilLog.network, "Attached search stream completed") + } + private func request( _ path: String, method: String, @@ -498,6 +495,75 @@ actor SybilAPIClient: SybilAPIClienting { return try? Self.decodeJSON(type, from: data) } + private static func handleCompletionStreamEvent( + eventName: String, + dataText: String, + onEvent: @escaping @Sendable (CompletionStreamEvent) async -> Void + ) async throws { + switch eventName { + case "meta": + let payload: CompletionStreamMeta = try Self.decodeEvent(dataText, as: CompletionStreamMeta.self, eventName: eventName) + await onEvent(.meta(payload)) + case "tool_call": + let payload: CompletionStreamToolCall = try Self.decodeEvent(dataText, as: CompletionStreamToolCall.self, eventName: eventName) + await onEvent(.toolCall(payload)) + case "delta": + let payload: CompletionStreamDelta = try Self.decodeEvent(dataText, as: CompletionStreamDelta.self, eventName: eventName) + await onEvent(.delta(payload)) + case "done": + do { + let payload: CompletionStreamDone = try Self.decodeEvent(dataText, as: CompletionStreamDone.self, eventName: eventName) + await onEvent(.done(payload)) + } catch { + if let recovered = Self.decodeLastJSONLine(dataText, as: CompletionStreamDone.self) { + SybilLog.warning( + SybilLog.network, + "Recovered chat stream done payload from concatenated SSE data" + ) + await onEvent(.done(recovered)) + } else { + throw error + } + } + case "error": + let payload: StreamErrorPayload = try Self.decodeEvent(dataText, as: StreamErrorPayload.self, eventName: eventName) + await onEvent(.error(payload)) + default: + SybilLog.warning(SybilLog.network, "Ignoring unknown chat stream event '\(eventName)'") + await onEvent(.ignored) + } + } + + private static func handleSearchStreamEvent( + eventName: String, + dataText: String, + onEvent: @escaping @Sendable (SearchStreamEvent) async -> Void + ) async throws { + switch eventName { + case "search_results": + let payload: SearchResultsPayload = try Self.decodeEvent(dataText, as: SearchResultsPayload.self, eventName: eventName) + await onEvent(.searchResults(payload)) + case "search_error": + let payload: SearchErrorPayload = try Self.decodeEvent(dataText, as: SearchErrorPayload.self, eventName: eventName) + await onEvent(.searchError(payload)) + case "answer": + let payload: SearchAnswerPayload = try Self.decodeEvent(dataText, as: SearchAnswerPayload.self, eventName: eventName) + await onEvent(.answer(payload)) + case "answer_error": + let payload: SearchErrorPayload = try Self.decodeEvent(dataText, as: SearchErrorPayload.self, eventName: eventName) + await onEvent(.answerError(payload)) + case "done": + let payload: SearchDonePayload = try Self.decodeEvent(dataText, as: SearchDonePayload.self, eventName: eventName) + await onEvent(.done(payload)) + case "error": + let payload: StreamErrorPayload = try Self.decodeEvent(dataText, as: StreamErrorPayload.self, eventName: eventName) + await onEvent(.error(payload)) + default: + SybilLog.warning(SybilLog.network, "Ignoring unknown search stream event '\(eventName)'") + await onEvent(.ignored) + } + } + private static func flushSSEEvent( eventName: inout String, dataLines: inout [String] diff --git a/ios/Packages/Sybil/Sources/Sybil/SybilAPIClienting.swift b/ios/Packages/Sybil/Sources/Sybil/SybilAPIClienting.swift index 4c14d9a..a6ac5d3 100644 --- a/ios/Packages/Sybil/Sources/Sybil/SybilAPIClienting.swift +++ b/ios/Packages/Sybil/Sources/Sybil/SybilAPIClienting.swift @@ -13,13 +13,22 @@ protocol SybilAPIClienting: Sendable { func createChatFromSearch(searchID: String, title: String?) async throws -> ChatSummary func deleteSearch(searchID: String) async throws func listModels() async throws -> ModelCatalogResponse + func getActiveRuns() async throws -> ActiveRunsResponse func runCompletionStream( body: CompletionStreamRequest, onEvent: @escaping @Sendable (CompletionStreamEvent) async -> Void ) async throws + func attachCompletionStream( + chatID: String, + onEvent: @escaping @Sendable (CompletionStreamEvent) async -> Void + ) async throws func runSearchStream( searchID: String, body: SearchRunRequest, onEvent: @escaping @Sendable (SearchStreamEvent) async -> Void ) async throws + func attachSearchStream( + searchID: String, + onEvent: @escaping @Sendable (SearchStreamEvent) async -> Void + ) async throws } diff --git a/ios/Packages/Sybil/Sources/Sybil/SybilModels.swift b/ios/Packages/Sybil/Sources/Sybil/SybilModels.swift index 05645f9..daf6421 100644 --- a/ios/Packages/Sybil/Sources/Sybil/SybilModels.swift +++ b/ios/Packages/Sybil/Sources/Sybil/SybilModels.swift @@ -354,6 +354,16 @@ public struct SearchDetail: Codable, Identifiable, Hashable, Sendable { public var results: [SearchResultItem] } +public struct ActiveRunsResponse: Codable, Hashable, Sendable { + public var chats: [String] + public var searches: [String] + + public init(chats: [String] = [], searches: [String] = []) { + self.chats = chats + self.searches = searches + } +} + public struct SearchRunRequest: Codable, Sendable { public var query: String? public var title: String? diff --git a/ios/Packages/Sybil/Sources/Sybil/SybilPhoneShellView.swift b/ios/Packages/Sybil/Sources/Sybil/SybilPhoneShellView.swift index 9fca46e..bf16d04 100644 --- a/ios/Packages/Sybil/Sources/Sybil/SybilPhoneShellView.swift +++ b/ios/Packages/Sybil/Sources/Sybil/SybilPhoneShellView.swift @@ -554,6 +554,13 @@ private struct SybilPhoneSidebarRow: View { Text(item.title) .font(.sybil(.subheadline, weight: .semibold)) .lineLimit(1) + .layoutPriority(1) + + Spacer(minLength: 8) + + if item.isRunning { + SybilSidebarActivityIndicator() + } } HStack(spacing: 8) { diff --git a/ios/Packages/Sybil/Sources/Sybil/SybilSidebarView.swift b/ios/Packages/Sybil/Sources/Sybil/SybilSidebarView.swift index 3d1dbd2..1b4adf0 100644 --- a/ios/Packages/Sybil/Sources/Sybil/SybilSidebarView.swift +++ b/ios/Packages/Sybil/Sources/Sybil/SybilSidebarView.swift @@ -104,6 +104,13 @@ struct SybilSidebarView: View { Text(item.title) .font(.sybil(.subheadline, weight: .semibold)) .lineLimit(1) + .layoutPriority(1) + + Spacer(minLength: 8) + + if item.isRunning { + SybilSidebarActivityIndicator() + } } HStack(spacing: 8) { @@ -205,3 +212,15 @@ struct SybilSidebarView: View { .buttonStyle(.plain) } } + +struct SybilSidebarActivityIndicator: View { + var body: some View { + ProgressView() + .progressViewStyle(.circular) + .controlSize(.small) + .tint(SybilTheme.accent) + .scaleEffect(0.82) + .frame(width: 16, height: 16) + .accessibilityLabel("Generating") + } +} diff --git a/ios/Packages/Sybil/Sources/Sybil/SybilViewModel.swift b/ios/Packages/Sybil/Sources/Sybil/SybilViewModel.swift index 3c78ac6..63da034 100644 --- a/ios/Packages/Sybil/Sources/Sybil/SybilViewModel.swift +++ b/ios/Packages/Sybil/Sources/Sybil/SybilViewModel.swift @@ -35,6 +35,7 @@ struct SidebarItem: Identifiable, Hashable { var title: String var updatedAt: Date var initiatedLabel: String? + var isRunning: Bool } private struct PendingChatState { @@ -42,7 +43,7 @@ private struct PendingChatState { var messages: [Message] } -private enum ActiveSendContext: Equatable { +private enum ActiveSendContext: Hashable { case draftChat(UUID) case chat(String) case draftSearch(UUID) @@ -102,7 +103,6 @@ final class SybilViewModel { var isLoadingCollections = false var isLoadingSelection = false - var isSending = false var isCreatingSearchChat = false var errorMessage: String? @@ -114,13 +114,23 @@ final class SybilViewModel { @ObservationIgnored private var hasBootstrapped = false - private var pendingChatState: PendingChatState? - private var activeSendContext: ActiveSendContext? + 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 chatBackgroundTask: SybilBackgroundTaskAssertion? + private var activeRunPollingTask: Task? + @ObservationIgnored + private var activeChatAttachTasks: [String: Task] = [:] + @ObservationIgnored + private var activeSearchAttachTasks: [String: Task] = [:] @ObservationIgnored private var isAppActive = true @ObservationIgnored @@ -237,8 +247,32 @@ final class SybilViewModel { 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 isSending { + if isActiveSelectionSending { return false } @@ -252,18 +286,12 @@ final class SybilViewModel { var displayedMessages: [Message] { let canonical = displayableMessages(currentSelectedChat?.messages ?? []) - guard let pending = pendingChatState else { - return canonical + if case let .chat(chatID) = selectedItem, + let pending = pendingChatStates[chatID] { + return displayableMessages(pending.messages) } - if let pendingID = pending.chatID { - if case let .chat(selectedID) = selectedItem, selectedID == pendingID { - return displayableMessages(pending.messages) - } - return canonical - } - - if draftKind == .chat { + if draftKind == .chat, let pending = pendingDraftChatState { return displayableMessages(pending.messages) } @@ -271,37 +299,35 @@ final class SybilViewModel { } var displayedSearch: SearchDetail? { - currentSelectedSearch + if case let .search(searchID) = selectedItem, + let activeSearch = activeSearchDetails[searchID] { + return activeSearch + } + return currentSelectedSearch } var isSendingVisibleChat: Bool { - guard isSending, pendingChatState != nil else { - return false + if draftKind == .chat { + return activeDraftSendContexts.contains(.draftChat(draftIdentity)) } - switch activeSendContext { - case let .draftChat(identity): - return draftKind == .chat && identity == draftIdentity - case let .chat(chatID): - return selectedItem == .chat(chatID) - case .draftSearch, .search, nil: - return false + if case let .chat(chatID) = selectedItem { + return isChatRowRunning(chatID) } + + return false } var isRunningVisibleSearch: Bool { - guard isSending else { - return false + if draftKind == .search { + return activeDraftSendContexts.contains(.draftSearch(draftIdentity)) } - switch activeSendContext { - case let .draftSearch(identity): - return draftKind == .search && identity == draftIdentity - case let .search(searchID): - return selectedItem == .search(searchID) - case .draftChat, .chat, nil: - return false + if case let .search(searchID) = selectedItem { + return isSearchRowRunning(searchID) } + + return false } var sidebarItems: [SidebarItem] { @@ -322,7 +348,8 @@ final class SybilViewModel { kind: .chat, title: chatTitle(title: chat.title, messages: nil), updatedAt: chat.updatedAt, - initiatedLabel: initiatedLabel + initiatedLabel: initiatedLabel, + isRunning: isChatRowRunning(chat.id) ) } @@ -332,7 +359,8 @@ final class SybilViewModel { kind: .search, title: searchTitle(title: search.title, query: search.query), updatedAt: search.updatedAt, - initiatedLabel: "exa" + initiatedLabel: "exa", + isRunning: isSearchRowRunning(search.id) ) } @@ -377,8 +405,16 @@ final class SybilViewModel { authError = nil errorMessage = nil resetSelectionLoading() - pendingChatState = nil - activeSendContext = nil + stopActiveRunPolling() + cancelActiveStreamAttachTasks() + pendingDraftChatState = nil + pendingChatStates = [:] + activeSearchDetails = [:] + activeDraftSendContexts = [] + localActiveChatIDs = [] + localActiveSearchIDs = [] + serverActiveChatIDs = [] + serverActiveSearchIDs = [] draftIdentity = UUID() composerAttachments = [] settings.persist() @@ -401,7 +437,9 @@ final class SybilViewModel { ) await loadInitialData(using: client) + startActiveRunPolling() } catch { + stopActiveRunPolling() isAuthenticated = false authMode = nil chats = [] @@ -457,7 +495,7 @@ final class SybilViewModel { selectedItem = nil selectedChat = nil selectedSearch = nil - pendingChatState = nil + pendingDraftChatState = nil errorMessage = nil composer = "" composerAttachments = [] @@ -471,7 +509,7 @@ final class SybilViewModel { selectedItem = nil selectedChat = nil selectedSearch = nil - pendingChatState = nil + pendingDraftChatState = nil errorMessage = nil composer = "" composerAttachments = [] @@ -484,7 +522,7 @@ final class SybilViewModel { selectedItem = .settings selectedChat = nil selectedSearch = nil - pendingChatState = nil + pendingDraftChatState = nil errorMessage = nil composerAttachments = [] } @@ -540,7 +578,6 @@ final class SybilViewModel { case .settings: selectedChat = nil selectedSearch = nil - pendingChatState = nil return nil } @@ -658,6 +695,7 @@ final class SybilViewModel { } if shouldRefreshSelection { + await refreshActiveRunsFromServer() await refreshSelectionIfNeeded() } } @@ -667,7 +705,7 @@ final class SybilViewModel { let attachments = composerAttachments let sendContext = currentSendContext - guard !isSending else { + guard !isSendContextActive(sendContext) else { return } @@ -680,19 +718,21 @@ final class SybilViewModel { composer = "" composerAttachments = [] errorMessage = nil - activeSendContext = sendContext - isSending = true + markSendContextActive(sendContext) + defer { + markSendContextInactive(sendContext) + } do { if sendContext.isSearch { SybilLog.info(SybilLog.ui, "Sending search query") - try await sendSearch(query: content) + try await sendSearch(query: content, sendContext: sendContext) } else { SybilLog.info(SybilLog.ui, "Sending chat prompt") - try await sendChat(content: content, attachments: attachments) + try await sendChat(content: content, attachments: attachments, sendContext: sendContext) } } catch { - let shouldSurfaceError = isSendContextVisible(sendContext) || (activeSendContext.map { isSendContextVisible($0) } ?? false) + let shouldSurfaceError = isSendContextVisible(sendContext) if shouldSurfaceError { errorMessage = normalizeAPIError(error) } @@ -724,11 +764,8 @@ final class SybilViewModel { composer = content composerAttachments = attachments } - pendingChatState = nil + clearPendingChatState(for: sendContext) } - - isSending = false - activeSendContext = nil } func appendComposerAttachments(_ attachments: [ChatAttachment]) throws { @@ -754,7 +791,7 @@ final class SybilViewModel { } func startChatFromSelectedSearch() async { - guard let search = currentSelectedSearch, !isCreatingSearchChat, !isSending else { + guard let search = currentSelectedSearch, !isCreatingSearchChat, !isActiveSelectionSending else { return } @@ -774,7 +811,7 @@ final class SybilViewModel { } draftKind = nil - pendingChatState = nil + pendingDraftChatState = nil composer = "" composerAttachments = [] @@ -800,10 +837,12 @@ final class SybilViewModel { do { async let chatsValue = client.listChats() async let searchesValue = client.listSearches() - let (nextChats, nextSearches) = try await (chatsValue, searchesValue) + async let activeRunsValue = client.getActiveRuns() + let (nextChats, nextSearches, nextActiveRuns) = try await (chatsValue, searchesValue, activeRunsValue) chats = nextChats searches = nextSearches + applyActiveRuns(nextActiveRuns) SybilLog.info( SybilLog.app, @@ -844,6 +883,7 @@ final class SybilViewModel { await refreshSelectionIfNeeded() } } + attachToVisibleActiveRunIfNeeded() } catch { errorMessage = normalizeAPIError(error) SybilLog.error(SybilLog.app, "Initial data load failed", error: error) @@ -867,7 +907,8 @@ final class SybilViewModel { private func refreshCollections( preferredSelection: SidebarSelection?, - refreshSelection: Bool = true + refreshSelection: Bool = true, + attachVisibleActiveRun: Bool = true ) async { isLoadingCollections = true @@ -875,10 +916,12 @@ final class SybilViewModel { let client = try client() async let chatsValue = client.listChats() async let searchesValue = client.listSearches() - let (nextChats, nextSearches) = try await (chatsValue, searchesValue) + async let activeRunsValue = client.getActiveRuns() + let (nextChats, nextSearches, nextActiveRuns) = try await (chatsValue, searchesValue, activeRunsValue) chats = nextChats searches = nextSearches + applyActiveRuns(nextActiveRuns) SybilLog.info( SybilLog.app, @@ -887,6 +930,9 @@ final class SybilViewModel { errorMessage = nil if draftKind != nil { + if attachVisibleActiveRun { + attachToVisibleActiveRunIfNeeded() + } isLoadingCollections = false return } @@ -909,6 +955,9 @@ final class SybilViewModel { if refreshSelection, selectedItem != nil { await refreshSelectionIfNeeded() } + if attachVisibleActiveRun { + attachToVisibleActiveRunIfNeeded() + } } catch { if shouldSuppressInactiveTransportError(error) { SybilLog.info(SybilLog.app, "Suppressing collection refresh transport interruption while app is inactive") @@ -921,6 +970,264 @@ final class SybilViewModel { 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 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 @@ -1014,6 +1321,7 @@ final class SybilViewModel { if selectedItem == target, draftKind == nil { isLoadingSelection = false selectionTask = nil + attachToVisibleActiveRunIfNeeded() } return } @@ -1023,7 +1331,7 @@ final class SybilViewModel { isLoadingSelection = false } - private func sendChat(content: String, attachments: [ChatAttachment]) async throws { + private func sendChat(content: String, attachments: [ChatAttachment], sendContext: ActiveSendContext) async throws { let optimisticUser = Message( id: "temp-user-\(UUID().uuidString)", createdAt: Date(), @@ -1041,19 +1349,23 @@ final class SybilViewModel { name: nil ) - pendingChatState = PendingChatState( - chatID: currentChatID, - messages: (currentSelectedChat?.messages ?? []) + [optimisticUser, optimisticAssistant] - ) + 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) + } + if chatID == nil { let created = try await client.createChat(title: nil) chatID = created.id - let shouldShowCreatedChat = activeSendContext.map { isSendContextVisible($0) } ?? true - activeSendContext = .chat(created.id) + let shouldShowCreatedChat = isSendContextVisible(sendContext) + markSendContextInactive(sendContext) + localActiveChatIDs.insert(created.id) chats.removeAll(where: { $0.id == created.id }) chats.insert(created, at: 0) @@ -1083,7 +1395,19 @@ final class SybilViewModel { throw APIError.invalidResponse } - pendingChatState?.chatID = chatID + 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 + } let baseChat: ChatDetail if let selectedChat = currentSelectedChat, selectedChat.id == chatID { @@ -1133,13 +1457,11 @@ final class SybilViewModel { } } - chatBackgroundTask?.end() - chatBackgroundTask = SybilBackgroundTaskAssertion(name: "Sybil Chat Response") { + let chatBackgroundTask = SybilBackgroundTaskAssertion(name: "Sybil Chat Response") { SybilLog.warning(SybilLog.app, "Chat response background time expired") } defer { chatBackgroundTask?.end() - chatBackgroundTask = nil } do { @@ -1152,7 +1474,7 @@ final class SybilViewModel { ) ) { [weak self] event in guard let self else { return } - await self.applyCompletionEvent(event, streamStatus: streamStatus) + await self.applyCompletionEvent(event, chatID: chatID, streamStatus: streamStatus) } } catch { if shouldSuppressLifecycleTransportError( @@ -1161,7 +1483,7 @@ final class SybilViewModel { startedWhileInactive: streamStartedWhileInactive ) { SybilLog.info(SybilLog.app, "Suppressing chat stream transport interruption after app lifecycle change") - pendingChatState = nil + pendingChatStates[chatID] = nil if isAppActive { await refreshInterruptedStream(preferredSelection: .chat(chatID)) } @@ -1176,7 +1498,7 @@ final class SybilViewModel { } guard isAppActive else { - pendingChatState = nil + pendingChatStates[chatID] = nil return } @@ -1188,14 +1510,14 @@ final class SybilViewModel { ) guard selectedItem == sentChatSelection, draftKind == nil else { - pendingChatState = nil + pendingChatStates[chatID] = nil return } do { let refreshedChat = try await client.getChat(chatID: chatID) guard selectedItem == sentChatSelection, draftKind == nil else { - pendingChatState = nil + pendingChatStates[chatID] = nil return } selectedChat = refreshedChat @@ -1206,7 +1528,7 @@ final class SybilViewModel { startedWhileInactive: streamStartedWhileInactive ) { SybilLog.info(SybilLog.app, "Suppressing chat refresh transport interruption after app lifecycle change") - pendingChatState = nil + pendingChatStates[chatID] = nil if isAppActive { await refreshInterruptedStream(preferredSelection: .chat(chatID)) } @@ -1215,25 +1537,27 @@ final class SybilViewModel { throw error } - pendingChatState = nil + pendingChatStates[chatID] = nil } - private func applyCompletionEvent(_ event: CompletionStreamEvent, streamStatus: CompletionStreamStatus) async { + private func applyCompletionEvent(_ event: CompletionStreamEvent, chatID: String, streamStatus: CompletionStreamStatus) async { switch event { case let .meta(payload): - pendingChatState?.chatID = payload.chatId + if payload.chatId == chatID { + pendingChatStates[chatID]?.chatID = payload.chatId + } case let .toolCall(payload): - insertPendingToolCallMessage(payload) + insertPendingToolCallMessage(payload, chatID: chatID) case let .delta(payload): guard !payload.text.isEmpty else { return } - mutatePendingAssistantMessage { existing in + mutatePendingAssistantMessage(chatID: chatID) { existing in existing + payload.text } case let .done(payload): - mutatePendingAssistantMessage { _ in + mutatePendingAssistantMessage(chatID: chatID) { _ in payload.text } @@ -1245,15 +1569,16 @@ final class SybilViewModel { } } - private func sendSearch(query: String) async throws { + 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 = activeSendContext.map { isSendContextVisible($0) } ?? true - activeSendContext = .search(created.id) + let shouldShowCreatedSearch = isSendContextVisible(sendContext) + markSendContextInactive(sendContext) + localActiveSearchIDs.insert(created.id) searches.removeAll(where: { $0.id == created.id }) searches.insert(created, at: 0) @@ -1270,23 +1595,31 @@ final class SybilViewModel { 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, + 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 = SearchDetail( - id: searchID, - title: String(query.prefix(80)), - query: query, - createdAt: currentSelectedSearch?.createdAt ?? now, - updatedAt: now, - requestId: nil, - latencyMs: nil, - error: nil, - answerText: nil, - answerRequestId: nil, - answerCitations: nil, - answerError: nil, - results: [] - ) + selectedSearch = optimisticSearch } let streamStatus = SearchStreamStatus() @@ -1308,20 +1641,24 @@ final class SybilViewModel { 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 } @@ -1331,6 +1668,7 @@ final class SybilViewModel { preferredSelection: shouldKeepSentSearchSelected ? sentSearchSelection : selectedItem, refreshSelection: false ) + activeSearchDetails[searchID] = nil } private func applySearchEvent( @@ -1338,36 +1676,42 @@ final class SybilViewModel { searchID: String, streamStatus: SearchStreamStatus ) async { - guard let current = currentSelectedSearch, current.id == searchID else { - if case let .done(payload) = event, - selectedItem == .search(searchID), - draftKind == nil { - selectedSearch = payload.search - } - return - } - switch event { case let .searchResults(payload): - selectedSearch?.requestId = payload.requestId ?? current.requestId - selectedSearch?.error = nil - selectedSearch?.results = payload.results + 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): - selectedSearch?.error = payload.error + guard var search = activeSearchDetails[searchID] ?? matchingSelectedSearch(searchID) else { + return + } + search.error = payload.error + setActiveSearch(search, searchID: searchID) case let .answer(payload): - selectedSearch?.answerText = payload.answerText - selectedSearch?.answerRequestId = payload.answerRequestId - selectedSearch?.answerCitations = payload.answerCitations - selectedSearch?.answerError = nil + 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): - selectedSearch?.answerError = payload.error + guard var search = activeSearchDetails[searchID] ?? matchingSelectedSearch(searchID) else { + return + } + search.answerError = payload.error + setActiveSearch(search, searchID: searchID) case let .done(payload): - selectedSearch = payload.search - selectedChat = nil + setActiveSearch(payload.search, searchID: searchID) case let .error(payload): await streamStatus.setError(payload.message) @@ -1377,8 +1721,23 @@ final class SybilViewModel { } } - private func mutatePendingAssistantMessage(_ transform: (String) -> String) { - guard var pending = pendingChatState, !pending.messages.isEmpty else { + 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 } @@ -1390,11 +1749,11 @@ final class SybilViewModel { var message = pending.messages[index] message.content = transform(message.content) pending.messages[index] = message - pendingChatState = pending + pendingChatStates[chatID] = pending } - private func insertPendingToolCallMessage(_ payload: CompletionStreamToolCall) { - guard var pending = pendingChatState else { + private func insertPendingToolCallMessage(_ payload: CompletionStreamToolCall, chatID: String) { + guard var pending = pendingChatStates[chatID] else { return } @@ -1435,7 +1794,7 @@ final class SybilViewModel { pending.messages.append(message) } - pendingChatState = pending + pendingChatStates[chatID] = pending } private var currentChatID: String? { @@ -1488,6 +1847,52 @@ final class SybilViewModel { 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): @@ -1660,6 +2065,14 @@ final class SybilViewModel { 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 diff --git a/ios/Packages/Sybil/Sources/Sybil/SybilWorkspaceView.swift b/ios/Packages/Sybil/Sources/Sybil/SybilWorkspaceView.swift index bc0955c..504d5ea 100644 --- a/ios/Packages/Sybil/Sources/Sybil/SybilWorkspaceView.swift +++ b/ios/Packages/Sybil/Sources/Sybil/SybilWorkspaceView.swift @@ -75,7 +75,7 @@ struct SybilWorkspaceView: View { guard onRequestNewChat != nil else { return false } - guard !viewModel.isSending, viewModel.draftKind == nil else { + guard !viewModel.isActiveSelectionSending, viewModel.draftKind == nil else { return false } guard case .chat = viewModel.selectedItem else { @@ -155,7 +155,7 @@ struct SybilWorkspaceView: View { workspaceContentStack if showsCustomWorkspaceNavigation { - SybilWorkspaceCharacterBackdrop(isBusy: viewModel.isSending) + SybilWorkspaceCharacterBackdrop(isBusy: viewModel.isActiveSelectionSending) .allowsHitTesting(false) customWorkspaceNavigationBar } @@ -560,10 +560,10 @@ struct SybilWorkspaceView: View { Circle() .stroke(SybilTheme.border.opacity(0.82), lineWidth: 1) ) - .foregroundStyle(viewModel.isSending ? SybilTheme.textMuted : SybilTheme.text) + .foregroundStyle(viewModel.isActiveSelectionSending ? SybilTheme.textMuted : SybilTheme.text) } .buttonStyle(.plain) - .disabled(viewModel.isSending) + .disabled(viewModel.isActiveSelectionSending) .accessibilityLabel("Attach file") } @@ -626,7 +626,7 @@ struct SybilWorkspaceView: View { } } .onDrop(of: [UTType.fileURL.identifier, UTType.image.identifier], isTargeted: $isComposerDropTargeted) { providers in - if viewModel.isSearchMode || viewModel.isSending { + if viewModel.isSearchMode || viewModel.isActiveSelectionSending { return false } diff --git a/ios/Packages/Sybil/Tests/SybilTests/SybilTests.swift b/ios/Packages/Sybil/Tests/SybilTests/SybilTests.swift index 3c48028..b73751e 100644 --- a/ios/Packages/Sybil/Tests/SybilTests/SybilTests.swift +++ b/ios/Packages/Sybil/Tests/SybilTests/SybilTests.swift @@ -8,6 +8,9 @@ private struct MockClientCallSnapshot: Sendable { var listSearches = 0 var getChat = 0 var getSearch = 0 + var getActiveRuns = 0 + var attachCompletionStream = 0 + var attachSearchStream = 0 } private struct UnexpectedClientCall: Error {} @@ -18,27 +21,34 @@ private actor MockSybilClient: SybilAPIClienting { private let chatDetails: [String: ChatDetail] private let searchDetails: [String: SearchDetail] private let createChatResponse: ChatSummary? + private let activeRunsResponse: ActiveRunsResponse private var snapshot = MockClientCallSnapshot() private var getChatDelayNanoseconds: UInt64 = 0 private var getSearchDelayNanoseconds: UInt64 = 0 private var completionStreamNetworkErrorMessage: String? private var completionStreamDelayNanoseconds: UInt64 = 0 + private var completionAttachEvents: [String: [CompletionStreamEvent]] = [:] + private var completionAttachDelayNanoseconds: UInt64 = 0 private var searchStreamNetworkErrorMessage: String? private var searchStreamDelayNanoseconds: UInt64 = 0 + private var searchAttachEvents: [String: [SearchStreamEvent]] = [:] + private var searchAttachDelayNanoseconds: UInt64 = 0 init( chatsResponse: [ChatSummary] = [], searchesResponse: [SearchSummary] = [], chatDetails: [String: ChatDetail] = [:], searchDetails: [String: SearchDetail] = [:], - createChatResponse: ChatSummary? = nil + createChatResponse: ChatSummary? = nil, + activeRunsResponse: ActiveRunsResponse = ActiveRunsResponse() ) { self.chatsResponse = chatsResponse self.searchesResponse = searchesResponse self.chatDetails = chatDetails self.searchDetails = searchDetails self.createChatResponse = createChatResponse + self.activeRunsResponse = activeRunsResponse } func currentSnapshot() -> MockClientCallSnapshot { @@ -63,6 +73,24 @@ private actor MockSybilClient: SybilAPIClienting { searchStreamDelayNanoseconds = delayNanoseconds } + func setCompletionAttachEvents( + chatID: String, + events: [CompletionStreamEvent], + delayNanoseconds: UInt64 = 0 + ) { + completionAttachEvents[chatID] = events + completionAttachDelayNanoseconds = delayNanoseconds + } + + func setSearchAttachEvents( + searchID: String, + events: [SearchStreamEvent], + delayNanoseconds: UInt64 = 0 + ) { + searchAttachEvents[searchID] = events + searchAttachDelayNanoseconds = delayNanoseconds + } + func verifySession() async throws -> AuthSession { AuthSession(authenticated: true, mode: "open") } @@ -130,6 +158,11 @@ private actor MockSybilClient: SybilAPIClienting { ModelCatalogResponse(providers: [:]) } + func getActiveRuns() async throws -> ActiveRunsResponse { + snapshot.getActiveRuns += 1 + return activeRunsResponse + } + func runCompletionStream( body: CompletionStreamRequest, onEvent: @escaping @Sendable (CompletionStreamEvent) async -> Void @@ -143,6 +176,20 @@ private actor MockSybilClient: SybilAPIClienting { throw UnexpectedClientCall() } + func attachCompletionStream( + chatID: String, + onEvent: @escaping @Sendable (CompletionStreamEvent) async -> Void + ) async throws { + snapshot.attachCompletionStream += 1 + let events = completionAttachEvents[chatID] ?? [] + for event in events { + await onEvent(event) + } + if completionAttachDelayNanoseconds > 0 { + try await Task.sleep(nanoseconds: completionAttachDelayNanoseconds) + } + } + func runSearchStream( searchID: String, body: SearchRunRequest, @@ -156,6 +203,20 @@ private actor MockSybilClient: SybilAPIClienting { } throw UnexpectedClientCall() } + + func attachSearchStream( + searchID: String, + onEvent: @escaping @Sendable (SearchStreamEvent) async -> Void + ) async throws { + snapshot.attachSearchStream += 1 + let events = searchAttachEvents[searchID] ?? [] + for event in events { + await onEvent(event) + } + if searchAttachDelayNanoseconds > 0 { + try await Task.sleep(nanoseconds: searchAttachDelayNanoseconds) + } + } } @MainActor @@ -409,6 +470,59 @@ private func makeSearchDetail(id: String, date: Date, answer: String) -> SearchD await sendTask.value } +@MainActor +@Test func reconnectAttachesSelectedActiveChatStream() async throws { + let date = Date(timeIntervalSince1970: 1_700_000_260) + let chat = makeChatSummary(id: "chat-active", date: date) + let detail = makeChatDetail(id: "chat-active", date: date, body: "existing transcript") + let client = MockSybilClient( + chatsResponse: [chat], + chatDetails: ["chat-active": detail], + activeRunsResponse: ActiveRunsResponse(chats: ["chat-active"]) + ) + await client.setCompletionAttachEvents( + chatID: "chat-active", + events: [.delta(CompletionStreamDelta(text: "streaming"))], + delayNanoseconds: 100_000_000 + ) + let viewModel = SybilViewModel(settings: testSettings(named: #function)) { _ in client } + + await viewModel.reconnect() + try await Task.sleep(nanoseconds: 20_000_000) + + let snapshot = await client.currentSnapshot() + #expect(snapshot.getActiveRuns >= 1) + #expect(snapshot.attachCompletionStream == 1) + #expect(viewModel.sidebarItems.first?.isRunning == true) + #expect(viewModel.isSendingVisibleChat) + #expect(viewModel.displayedMessages.last?.content == "streaming") +} + +@MainActor +@Test func activeRunOnDifferentChatDoesNotDisableComposer() async throws { + let date = Date(timeIntervalSince1970: 1_700_000_270) + let activeChat = makeChatSummary(id: "chat-active", date: date) + let idleChat = makeChatSummary(id: "chat-idle", date: date.addingTimeInterval(1)) + let client = MockSybilClient( + chatsResponse: [idleChat, activeChat], + chatDetails: [ + "chat-active": makeChatDetail(id: "chat-active", date: date, body: "active transcript"), + "chat-idle": makeChatDetail(id: "chat-idle", date: date, body: "idle transcript") + ], + activeRunsResponse: ActiveRunsResponse(chats: ["chat-active"]) + ) + let viewModel = SybilViewModel(settings: testSettings(named: #function)) { _ in client } + viewModel.selectedItem = .chat("chat-idle") + viewModel.composer = "new message" + + await viewModel.reconnect() + + #expect(viewModel.selectedItem == .chat("chat-idle")) + #expect(viewModel.sidebarItems.first(where: { $0.selection == .chat("chat-active") })?.isRunning == true) + #expect(!viewModel.isActiveSelectionSending) + #expect(viewModel.canSendComposer) +} + @MainActor @Test func backgroundChatStreamInterruptionIsSuppressedUntilForegroundRefresh() async throws { let date = Date(timeIntervalSince1970: 1_700_000_300)