From 1e045db7f405129722ba409cfe570c680ce9b9e0 Mon Sep 17 00:00:00 2001 From: James Magahern Date: Wed, 6 May 2026 22:34:17 -0700 Subject: [PATCH] ios: fix pull to refresh --- .../Sources/Sybil/SybilSidebarView.swift | 5 +-- .../Sybil/Sources/Sybil/SybilViewModel.swift | 21 +++++++++- .../Sybil/Tests/SybilTests/SybilTests.swift | 40 +++++++++++++++++++ 3 files changed, 61 insertions(+), 5 deletions(-) diff --git a/ios/Packages/Sybil/Sources/Sybil/SybilSidebarView.swift b/ios/Packages/Sybil/Sources/Sybil/SybilSidebarView.swift index 780b24f..698afc4 100644 --- a/ios/Packages/Sybil/Sources/Sybil/SybilSidebarView.swift +++ b/ios/Packages/Sybil/Sources/Sybil/SybilSidebarView.swift @@ -159,10 +159,7 @@ struct SybilSidebarItemList: View { .padding(10) } .refreshable { - await viewModel.refreshVisibleContent( - refreshCollections: true, - refreshSelection: false - ) + await viewModel.refreshSidebarCollectionsFromPullToRefresh() } } } diff --git a/ios/Packages/Sybil/Sources/Sybil/SybilViewModel.swift b/ios/Packages/Sybil/Sources/Sybil/SybilViewModel.swift index 862359c..d01abed 100644 --- a/ios/Packages/Sybil/Sources/Sybil/SybilViewModel.swift +++ b/ios/Packages/Sybil/Sources/Sybil/SybilViewModel.swift @@ -911,6 +911,23 @@ final class SybilViewModel { } } + func refreshSidebarCollectionsFromPullToRefresh() async { + guard isAuthenticated, !isCheckingSession else { + return + } + + SybilLog.info( + SybilLog.ui, + "Sidebar pull-to-refresh requested" + ) + + let preferredSelection = selectedItem + let refreshTask = Task { @MainActor in + await refreshCollections(preferredSelection: preferredSelection, refreshSelection: false) + } + await refreshTask.value + } + func sendComposer() async { let content = composer.trimmingCharacters(in: .whitespacesAndNewlines) let attachments = composerAttachments @@ -1276,7 +1293,9 @@ final class SybilViewModel { attachToVisibleActiveRunIfNeeded() } } catch { - if shouldSuppressInactiveTransportError(error) { + if isCancellation(error) { + SybilLog.debug(SybilLog.app, "Collection refresh cancelled") + } else if shouldSuppressInactiveTransportError(error) { SybilLog.info(SybilLog.app, "Suppressing collection refresh transport interruption while app is inactive") } else { errorMessage = normalizeAPIError(error) diff --git a/ios/Packages/Sybil/Tests/SybilTests/SybilTests.swift b/ios/Packages/Sybil/Tests/SybilTests/SybilTests.swift index 3835851..8a13dc2 100644 --- a/ios/Packages/Sybil/Tests/SybilTests/SybilTests.swift +++ b/ios/Packages/Sybil/Tests/SybilTests/SybilTests.swift @@ -36,6 +36,8 @@ private actor MockSybilClient: SybilAPIClienting { private var lastCreateChatCall: ChatCreateCallSnapshot? private var lastCompletionStreamBody: CompletionStreamRequest? private var completionStreamEvents: [CompletionStreamEvent]? + private var listChatsDelayNanoseconds: UInt64 = 0 + private var listSearchesDelayNanoseconds: UInt64 = 0 private var getChatDelayNanoseconds: UInt64 = 0 private var getSearchDelayNanoseconds: UInt64 = 0 private var completionStreamNetworkErrorMessage: String? @@ -85,6 +87,11 @@ private actor MockSybilClient: SybilAPIClienting { completionStreamDelayNanoseconds = delayNanoseconds } + func setListDelays(chats: UInt64 = 0, searches: UInt64 = 0) { + listChatsDelayNanoseconds = chats + listSearchesDelayNanoseconds = searches + } + func setGetChatDelay(_ delayNanoseconds: UInt64) { getChatDelayNanoseconds = delayNanoseconds } @@ -122,6 +129,9 @@ private actor MockSybilClient: SybilAPIClienting { func listChats() async throws -> [ChatSummary] { snapshot.listChats += 1 + if listChatsDelayNanoseconds > 0 { + try await Task.sleep(nanoseconds: listChatsDelayNanoseconds) + } return chatsResponse } @@ -165,6 +175,9 @@ private actor MockSybilClient: SybilAPIClienting { func listSearches() async throws -> [SearchSummary] { snapshot.listSearches += 1 + if listSearchesDelayNanoseconds > 0 { + try await Task.sleep(nanoseconds: listSearchesDelayNanoseconds) + } return searchesResponse } @@ -383,6 +396,33 @@ private func makeSearchDetail(id: String, date: Date, answer: String) -> SearchD #expect(viewModel.selectedItem == .chat("chat-1")) } +@MainActor +@Test func pullToRefreshCompletesWhenRefreshableTaskIsCancelled() async throws { + let date = Date(timeIntervalSince1970: 1_700_000_050) + let chat = makeChatSummary(id: "chat-cancelled", date: date) + let search = makeSearchSummary(id: "search-cancelled", date: date) + let client = MockSybilClient( + chatsResponse: [chat], + searchesResponse: [search] + ) + await client.setListDelays(chats: 50_000_000, searches: 50_000_000) + let viewModel = SybilViewModel(settings: testSettings(named: #function)) { _ in client } + viewModel.isAuthenticated = true + viewModel.isCheckingSession = false + + let refreshTask = Task { + await viewModel.refreshSidebarCollectionsFromPullToRefresh() + } + try await Task.sleep(nanoseconds: 10_000_000) + refreshTask.cancel() + await refreshTask.value + + #expect(viewModel.errorMessage == nil) + #expect(!viewModel.isLoadingCollections) + #expect(viewModel.chats.map(\.id) == ["chat-cancelled"]) + #expect(viewModel.searches.map(\.id) == ["search-cancelled"]) +} + @MainActor @Test func foregroundChatRefreshReloadsSelectedTranscript() async throws { let date = Date(timeIntervalSince1970: 1_700_000_100)