ios: better backgrounding/resume

This commit is contained in:
2026-05-02 22:18:33 -07:00
parent cf9832ca3b
commit 4ad36d9bf6
7 changed files with 421 additions and 10 deletions

View File

@@ -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
)