diff --git a/ios/Packages/Sybil/Sources/Sybil/SplitView.swift b/ios/Packages/Sybil/Sources/Sybil/SplitView.swift index ab909f7..f52ac5e 100644 --- a/ios/Packages/Sybil/Sources/Sybil/SplitView.swift +++ b/ios/Packages/Sybil/Sources/Sybil/SplitView.swift @@ -3,6 +3,8 @@ import SwiftUI public struct SplitView: View { @State private var viewModel = SybilViewModel() @Environment(\.horizontalSizeClass) private var horizontalSizeClass + @Environment(\.scenePhase) private var scenePhase + @State private var shouldRefreshOnForeground = false @MainActor public init() { SybilFontRegistry.registerIfNeeded() @@ -38,5 +40,26 @@ public struct SplitView: View { .task { await viewModel.bootstrap() } + .onChange(of: scenePhase) { _, nextPhase in + switch nextPhase { + case .background: + shouldRefreshOnForeground = true + case .active: + guard shouldRefreshOnForeground, horizontalSizeClass != .compact else { + return + } + shouldRefreshOnForeground = false + Task { + await viewModel.refreshVisibleContent( + refreshCollections: true, + refreshSelection: viewModel.hasRefreshableSelection + ) + } + case .inactive: + break + @unknown default: + break + } + } } } diff --git a/ios/Packages/Sybil/Sources/Sybil/SybilAPIClient.swift b/ios/Packages/Sybil/Sources/Sybil/SybilAPIClient.swift index 72eccf9..02dac01 100644 --- a/ios/Packages/Sybil/Sources/Sybil/SybilAPIClient.swift +++ b/ios/Packages/Sybil/Sources/Sybil/SybilAPIClient.swift @@ -17,7 +17,7 @@ struct AnyEncodable: Encodable { } } -actor SybilAPIClient { +actor SybilAPIClient: SybilAPIClienting { private let configuration: APIConfiguration private let session: URLSession diff --git a/ios/Packages/Sybil/Sources/Sybil/SybilAPIClienting.swift b/ios/Packages/Sybil/Sources/Sybil/SybilAPIClienting.swift new file mode 100644 index 0000000..4c14d9a --- /dev/null +++ b/ios/Packages/Sybil/Sources/Sybil/SybilAPIClienting.swift @@ -0,0 +1,25 @@ +import Foundation + +protocol SybilAPIClienting: Sendable { + func verifySession() async throws -> AuthSession + func listChats() async throws -> [ChatSummary] + func createChat(title: String?) async throws -> ChatSummary + func getChat(chatID: String) async throws -> ChatDetail + func deleteChat(chatID: String) async throws + func suggestChatTitle(chatID: String, content: String) async throws -> ChatSummary + func listSearches() async throws -> [SearchSummary] + func createSearch(title: String?, query: String?) async throws -> SearchSummary + func getSearch(searchID: String) async throws -> SearchDetail + func createChatFromSearch(searchID: String, title: String?) async throws -> ChatSummary + func deleteSearch(searchID: String) async throws + func listModels() async throws -> ModelCatalogResponse + func runCompletionStream( + body: CompletionStreamRequest, + onEvent: @escaping @Sendable (CompletionStreamEvent) async -> Void + ) async throws + func runSearchStream( + searchID: String, + body: SearchRunRequest, + onEvent: @escaping @Sendable (SearchStreamEvent) async -> Void + ) async throws +} diff --git a/ios/Packages/Sybil/Sources/Sybil/SybilBackgroundTaskAssertion.swift b/ios/Packages/Sybil/Sources/Sybil/SybilBackgroundTaskAssertion.swift new file mode 100644 index 0000000..3475034 --- /dev/null +++ b/ios/Packages/Sybil/Sources/Sybil/SybilBackgroundTaskAssertion.swift @@ -0,0 +1,36 @@ +import UIKit + +@MainActor +final class SybilBackgroundTaskAssertion { + private let name: String + private var identifier: UIBackgroundTaskIdentifier = .invalid + + init?(name: String, onExpiration: @escaping @MainActor () -> Void = {}) { + self.name = name + identifier = UIApplication.shared.beginBackgroundTask(withName: name) { [weak self] in + Task { @MainActor in + guard let self else { return } + SybilLog.warning(SybilLog.app, "Background task expired: \(self.name)") + onExpiration() + self.end() + } + } + + guard identifier != .invalid else { + SybilLog.warning(SybilLog.app, "Failed to acquire background task: \(name)") + return nil + } + + SybilLog.debug(SybilLog.app, "Acquired background task: \(name)") + } + + func end() { + guard identifier != .invalid else { + return + } + + UIApplication.shared.endBackgroundTask(identifier) + identifier = .invalid + SybilLog.debug(SybilLog.app, "Ended background task: \(name)") + } +} diff --git a/ios/Packages/Sybil/Sources/Sybil/SybilPhoneShellView.swift b/ios/Packages/Sybil/Sources/Sybil/SybilPhoneShellView.swift index 05bc963..76fc8b0 100644 --- a/ios/Packages/Sybil/Sources/Sybil/SybilPhoneShellView.swift +++ b/ios/Packages/Sybil/Sources/Sybil/SybilPhoneShellView.swift @@ -23,6 +23,8 @@ enum PhoneRoute: Hashable { struct SybilPhoneShellView: View { @Bindable var viewModel: SybilViewModel @State private var path: [PhoneRoute] = [] + @Environment(\.scenePhase) private var scenePhase + @State private var shouldRefreshOnForeground = false var body: some View { NavigationStack(path: $path) { @@ -39,6 +41,27 @@ struct SybilPhoneShellView: View { } } .tint(SybilTheme.primary) + .onChange(of: scenePhase) { _, nextPhase in + switch nextPhase { + case .background: + shouldRefreshOnForeground = true + case .active: + guard shouldRefreshOnForeground else { + return + } + shouldRefreshOnForeground = false + Task { + await viewModel.refreshVisibleContent( + refreshCollections: path.isEmpty, + refreshSelection: !path.isEmpty && viewModel.hasRefreshableSelection + ) + } + case .inactive: + break + @unknown default: + break + } + } } } diff --git a/ios/Packages/Sybil/Sources/Sybil/SybilViewModel.swift b/ios/Packages/Sybil/Sources/Sybil/SybilViewModel.swift index e1bf687..d19f699 100644 --- a/ios/Packages/Sybil/Sources/Sybil/SybilViewModel.swift +++ b/ios/Packages/Sybil/Sources/Sybil/SybilViewModel.swift @@ -96,9 +96,15 @@ final class SybilViewModel { var modelCatalog: [Provider: ProviderModelInfo] = [:] var model: String + @ObservationIgnored private var hasBootstrapped = false private var pendingChatState: PendingChatState? + @ObservationIgnored private var selectionTask: Task? + @ObservationIgnored + private var chatBackgroundTask: SybilBackgroundTaskAssertion? + @ObservationIgnored + private let clientFactory: (APIConfiguration) -> any SybilAPIClienting private let fallbackModels: [Provider: [String]] = [ .openai: ["gpt-4.1-mini"], @@ -106,8 +112,14 @@ final class SybilViewModel { .xai: ["grok-3-mini"] ] - init(settings: SybilSettingsStore = SybilSettingsStore()) { + init( + settings: SybilSettingsStore = SybilSettingsStore(), + clientFactory: @escaping (APIConfiguration) -> any SybilAPIClienting = { configuration in + SybilAPIClient(configuration: configuration) + } + ) { self.settings = settings + self.clientFactory = clientFactory self.provider = settings.preferredProvider self.model = settings.preferredModelByProvider[settings.preferredProvider] ?? "gpt-4.1-mini" } @@ -281,6 +293,19 @@ final class SybilViewModel { 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 @@ -449,6 +474,30 @@ final class SybilViewModel { await reconnect() } + func refreshVisibleContent(refreshCollections shouldRefreshCollections: Bool, refreshSelection shouldRefreshSelection: Bool) async { + guard isAuthenticated, !isCheckingSession else { + return + } + + guard shouldRefreshCollections || shouldRefreshSelection else { + return + } + + SybilLog.info( + SybilLog.ui, + "Foreground refresh requested (collections=\(shouldRefreshCollections), selection=\(shouldRefreshSelection))" + ) + + if shouldRefreshCollections { + await refreshCollections(preferredSelection: selectedItem, refreshSelection: shouldRefreshSelection) + return + } + + if shouldRefreshSelection { + await refreshSelectionIfNeeded() + } + } + func sendComposer() async { let content = composer.trimmingCharacters(in: .whitespacesAndNewlines) let attachments = composerAttachments @@ -540,7 +589,7 @@ final class SybilViewModel { do { let client = try client() - let chat = try await client.createChatFromSearch(searchID: search.id) + let chat = try await client.createChatFromSearch(searchID: search.id, title: nil) draftKind = nil pendingChatState = nil composer = "" @@ -561,7 +610,7 @@ final class SybilViewModel { isCreatingSearchChat = false } - private func loadInitialData(using client: SybilAPIClient) async { + private func loadInitialData(using client: any SybilAPIClienting) async { isLoadingCollections = true errorMessage = nil @@ -633,7 +682,10 @@ final class SybilViewModel { settings.persist() } - private func refreshCollections(preferredSelection: SidebarSelection?) async { + private func refreshCollections( + preferredSelection: SidebarSelection?, + refreshSelection: Bool = true + ) async { isLoadingCollections = true do { @@ -665,7 +717,7 @@ final class SybilViewModel { selectedItem = sidebarItems.first?.selection } - if selectedItem != nil { + if refreshSelection, selectedItem != nil { await refreshSelectionIfNeeded() } } catch { @@ -752,7 +804,7 @@ final class SybilViewModel { var chatID = currentChatID if chatID == nil { - let created = try await client.createChat() + let created = try await client.createChat(title: nil) chatID = created.id draftKind = nil selectedItem = .chat(created.id) @@ -828,6 +880,15 @@ final class SybilViewModel { } } + chatBackgroundTask?.end() + chatBackgroundTask = SybilBackgroundTaskAssertion(name: "Sybil Chat Response") { + SybilLog.warning(SybilLog.app, "Chat response background time expired") + } + defer { + chatBackgroundTask?.end() + chatBackgroundTask = nil + } + try await client.runCompletionStream( body: CompletionStreamRequest( chatId: chatID, @@ -1171,7 +1232,7 @@ final class SybilViewModel { return false } - private func client() throws -> SybilAPIClient { + private func client() throws -> any SybilAPIClienting { guard let baseURL = settings.normalizedAPIBaseURL else { throw APIError.invalidBaseURL } @@ -1181,8 +1242,8 @@ final class SybilViewModel { "Creating API client for \(baseURL.absoluteString) (token: \(settings.trimmedTokenOrNil == nil ? "none" : "set"))" ) - return SybilAPIClient( - configuration: APIConfiguration( + return clientFactory( + APIConfiguration( baseURL: baseURL, authToken: settings.trimmedTokenOrNil ) diff --git a/ios/Packages/Sybil/Tests/SybilTests/SybilTests.swift b/ios/Packages/Sybil/Tests/SybilTests/SybilTests.swift index 7fe63f6..941e16b 100644 --- a/ios/Packages/Sybil/Tests/SybilTests/SybilTests.swift +++ b/ios/Packages/Sybil/Tests/SybilTests/SybilTests.swift @@ -1,6 +1,186 @@ +import Foundation import Testing @testable import Sybil +private struct MockClientCallSnapshot: Sendable { + var listChats = 0 + var listSearches = 0 + var getChat = 0 + var getSearch = 0 +} + +private struct UnexpectedClientCall: Error {} + +private actor MockSybilClient: SybilAPIClienting { + private let chatsResponse: [ChatSummary] + private let searchesResponse: [SearchSummary] + private let chatDetails: [String: ChatDetail] + private let searchDetails: [String: SearchDetail] + + private var snapshot = MockClientCallSnapshot() + + init( + chatsResponse: [ChatSummary] = [], + searchesResponse: [SearchSummary] = [], + chatDetails: [String: ChatDetail] = [:], + searchDetails: [String: SearchDetail] = [:] + ) { + self.chatsResponse = chatsResponse + self.searchesResponse = searchesResponse + self.chatDetails = chatDetails + self.searchDetails = searchDetails + } + + func currentSnapshot() -> MockClientCallSnapshot { + snapshot + } + + func verifySession() async throws -> AuthSession { + AuthSession(authenticated: true, mode: "open") + } + + func listChats() async throws -> [ChatSummary] { + snapshot.listChats += 1 + return chatsResponse + } + + func createChat(title: String?) async throws -> ChatSummary { + throw UnexpectedClientCall() + } + + func getChat(chatID: String) async throws -> ChatDetail { + snapshot.getChat += 1 + guard let detail = chatDetails[chatID] else { + throw UnexpectedClientCall() + } + return detail + } + + func deleteChat(chatID: String) async throws { + throw UnexpectedClientCall() + } + + func suggestChatTitle(chatID: String, content: String) async throws -> ChatSummary { + throw UnexpectedClientCall() + } + + func listSearches() async throws -> [SearchSummary] { + snapshot.listSearches += 1 + return searchesResponse + } + + func createSearch(title: String?, query: String?) async throws -> SearchSummary { + throw UnexpectedClientCall() + } + + func getSearch(searchID: String) async throws -> SearchDetail { + snapshot.getSearch += 1 + guard let detail = searchDetails[searchID] else { + throw UnexpectedClientCall() + } + return detail + } + + func createChatFromSearch(searchID: String, title: String?) async throws -> ChatSummary { + throw UnexpectedClientCall() + } + + func deleteSearch(searchID: String) async throws { + throw UnexpectedClientCall() + } + + func listModels() async throws -> ModelCatalogResponse { + ModelCatalogResponse(providers: [:]) + } + + func runCompletionStream( + body: CompletionStreamRequest, + onEvent: @escaping @Sendable (CompletionStreamEvent) async -> Void + ) async throws { + throw UnexpectedClientCall() + } + + func runSearchStream( + searchID: String, + body: SearchRunRequest, + onEvent: @escaping @Sendable (SearchStreamEvent) async -> Void + ) async throws { + throw UnexpectedClientCall() + } +} + +@MainActor +private func testSettings(named name: String) -> SybilSettingsStore { + let defaults = UserDefaults(suiteName: name)! + defaults.removePersistentDomain(forName: name) + let settings = SybilSettingsStore(defaults: defaults) + settings.apiBaseURL = "http://127.0.0.1:8787" + return settings +} + +private func makeChatSummary(id: String, date: Date) -> ChatSummary { + ChatSummary( + id: id, + title: "Chat \(id)", + createdAt: date, + updatedAt: date, + initiatedProvider: .openai, + initiatedModel: "gpt-4.1-mini", + lastUsedProvider: .openai, + lastUsedModel: "gpt-4.1-mini" + ) +} + +private func makeChatDetail(id: String, date: Date, body: String) -> ChatDetail { + ChatDetail( + id: id, + title: "Chat \(id)", + createdAt: date, + updatedAt: date, + initiatedProvider: .openai, + initiatedModel: "gpt-4.1-mini", + lastUsedProvider: .openai, + lastUsedModel: "gpt-4.1-mini", + messages: [ + Message( + id: "message-\(id)", + createdAt: date, + role: .assistant, + content: body, + name: nil + ) + ] + ) +} + +private func makeSearchSummary(id: String, date: Date) -> SearchSummary { + SearchSummary( + id: id, + title: "Search \(id)", + query: "query-\(id)", + createdAt: date, + updatedAt: date + ) +} + +private func makeSearchDetail(id: String, date: Date, answer: String) -> SearchDetail { + SearchDetail( + id: id, + title: "Search \(id)", + query: "query-\(id)", + createdAt: date, + updatedAt: date, + requestId: "request-\(id)", + latencyMs: 42, + error: nil, + answerText: answer, + answerRequestId: "answer-\(id)", + answerCitations: [], + answerError: nil, + results: [] + ) +} + @MainActor @Test func normalizedAPIBaseURLPreservesExplicitAPIPath() async throws { let defaults = UserDefaults(suiteName: #function)! @@ -22,3 +202,66 @@ import Testing #expect(settings.normalizedAPIBaseURL?.absoluteString == "http://127.0.0.1:8787") } + +@MainActor +@Test func foregroundListRefreshDoesNotReloadHiddenSelection() async throws { + let date = Date(timeIntervalSince1970: 1_700_000_000) + let chat = makeChatSummary(id: "chat-1", date: date) + let search = makeSearchSummary(id: "search-1", date: date) + let client = MockSybilClient( + chatsResponse: [chat], + searchesResponse: [search], + chatDetails: ["chat-1": makeChatDetail(id: "chat-1", date: date, body: "fresh chat body")] + ) + let viewModel = SybilViewModel(settings: testSettings(named: #function)) { _ in client } + viewModel.isAuthenticated = true + viewModel.isCheckingSession = false + viewModel.selectedItem = .chat("chat-1") + + await viewModel.refreshVisibleContent(refreshCollections: true, refreshSelection: false) + + let snapshot = await client.currentSnapshot() + #expect(snapshot.listChats == 1) + #expect(snapshot.listSearches == 1) + #expect(snapshot.getChat == 0) + #expect(snapshot.getSearch == 0) + #expect(viewModel.selectedItem == .chat("chat-1")) +} + +@MainActor +@Test func foregroundChatRefreshReloadsSelectedTranscript() async throws { + let date = Date(timeIntervalSince1970: 1_700_000_100) + let detail = makeChatDetail(id: "chat-2", date: date, body: "refreshed transcript") + let client = MockSybilClient(chatDetails: ["chat-2": detail]) + let viewModel = SybilViewModel(settings: testSettings(named: #function)) { _ in client } + viewModel.isAuthenticated = true + viewModel.isCheckingSession = false + viewModel.selectedItem = .chat("chat-2") + + await viewModel.refreshVisibleContent(refreshCollections: false, refreshSelection: true) + + let snapshot = await client.currentSnapshot() + #expect(snapshot.listChats == 0) + #expect(snapshot.listSearches == 0) + #expect(snapshot.getChat == 1) + #expect(viewModel.selectedChat?.messages.first?.content == "refreshed transcript") +} + +@MainActor +@Test func foregroundSearchRefreshReloadsSelectedSearch() async throws { + let date = Date(timeIntervalSince1970: 1_700_000_200) + let detail = makeSearchDetail(id: "search-2", date: date, answer: "fresh answer") + let client = MockSybilClient(searchDetails: ["search-2": detail]) + let viewModel = SybilViewModel(settings: testSettings(named: #function)) { _ in client } + viewModel.isAuthenticated = true + viewModel.isCheckingSession = false + viewModel.selectedItem = .search("search-2") + + await viewModel.refreshVisibleContent(refreshCollections: false, refreshSelection: true) + + let snapshot = await client.currentSnapshot() + #expect(snapshot.listChats == 0) + #expect(snapshot.listSearches == 0) + #expect(snapshot.getSearch == 1) + #expect(viewModel.selectedSearch?.answerText == "fresh answer") +}