ios: better backgrounding/resume
This commit is contained in:
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -17,7 +17,7 @@ struct AnyEncodable: Encodable {
|
||||
}
|
||||
}
|
||||
|
||||
actor SybilAPIClient {
|
||||
actor SybilAPIClient: SybilAPIClienting {
|
||||
private let configuration: APIConfiguration
|
||||
private let session: URLSession
|
||||
|
||||
|
||||
25
ios/Packages/Sybil/Sources/Sybil/SybilAPIClienting.swift
Normal file
25
ios/Packages/Sybil/Sources/Sybil/SybilAPIClienting.swift
Normal file
@@ -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
|
||||
}
|
||||
@@ -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)")
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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<Void, Never>?
|
||||
@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
|
||||
)
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user