From 3bdbd2b73c23fc4d254f0d1586fc77757acf98af Mon Sep 17 00:00:00 2001 From: Vellum Assistant Date: Sun, 24 May 2026 12:00:45 -0500 Subject: [PATCH] fix(macos): paginate conversation list so scheduled section doesn't truncate ConversationRestorer single-shot fetched 50 rows from ?conversationType=background and never paginated, so users with many scheduled conversations saw a small recent-activity slice (e.g. 14 of 1856 in one report). This is the macOS analogue of LUM-1618 which was fixed for web in #31472. Add fetchAllConversationPages helper that loops until hasMore is false (with a 50-page safety cap), used for both the foreground and background fetches in fetchConversationList. Page size is 200 to match ConversationListStore.loadAllRemainingConversations and reduce cold- launch round-trips on macOS, which talks to the daemon over loopback. --- .../MainWindow/ConversationRestorer.swift | 58 +++++++++++-- .../ConversationRestorerTests.swift | 82 +++++++++++++++++++ 2 files changed, 135 insertions(+), 5 deletions(-) diff --git a/clients/macos/vellum-assistant/Features/MainWindow/ConversationRestorer.swift b/clients/macos/vellum-assistant/Features/MainWindow/ConversationRestorer.swift index ff1be089e8e..947d9803ceb 100644 --- a/clients/macos/vellum-assistant/Features/MainWindow/ConversationRestorer.swift +++ b/clients/macos/vellum-assistant/Features/MainWindow/ConversationRestorer.swift @@ -675,9 +675,11 @@ final class ConversationRestorer { for attempt in 1...maxAttempts { // Fetch foreground and background conversations in parallel so // background conversations don't consume pagination slots from - // the main list. - async let foregroundResult = conversationListClient.fetchConversationList(offset: 0, limit: 50, conversationType: nil) - async let backgroundResult = conversationListClient.fetchConversationList(offset: 0, limit: 50, conversationType: "background") + // the main list. Each side paginates until the daemon reports + // `hasMore == false`; without this the sidebar truncates at the + // first 50 rows (LUM-1618 on web, fixed for macOS here). + async let foregroundResult = self.fetchAllConversationPages(conversationType: nil) + async let backgroundResult = self.fetchAllConversationPages(conversationType: "background") let foreground = await foregroundResult let background = await backgroundResult @@ -693,11 +695,11 @@ final class ConversationRestorer { // Set serverOffset from foreground count BEFORE merging. // loadMoreConversations pages the foreground endpoint only, // so the offset must not include merged background rows. - self.delegate?.serverOffset = foreground.nextOffset ?? foreground.conversations.count + self.delegate?.serverOffset = foreground.conversations.count let merged = ConversationListResponse( type: foreground.type, conversations: foreground.conversations + uniqueBackground, - hasMore: foreground.hasMore, + hasMore: false, groups: foreground.groups ) self.handleConversationListResponse(merged, updateServerOffset: false) @@ -713,4 +715,50 @@ final class ConversationRestorer { self.delegate?.restoreLastActiveConversation() } } + + /// Matches `ConversationListStore.loadAllRemainingConversations` so cold + /// restore and "load all" share a tested page size. Larger than the web + /// client's 50 because macOS talks to the daemon over loopback — the + /// per-request overhead dominates, so fewer round-trips win. + private static let conversationListPageSize = 200 + + /// Safety cap on the pagination loop — `50 * 200 = 10,000` conversations + /// per type. Bounded so a malformed `hasMore` from the server can't spin + /// forever. + private static let conversationListMaxPages = 50 + + /// Page through every conversation of the given type, accumulating until + /// the daemon reports `hasMore == false` or the safety cap is hit. Returns + /// `nil` if any page request fails so the caller's retry/fallback logic + /// can drive recovery from a single-shot failure. + private func fetchAllConversationPages(conversationType: String?) async -> ConversationListResponse? { + let pageSize = Self.conversationListPageSize + var accumulated: [ConversationListResponseItem] = [] + var firstPage: ConversationListResponse? + + for page in 0.. Bool { true } } +/// Mock that returns paged responses (`hasMore=true` until the configured +/// total is exhausted) so tests can verify the restorer drains every page +/// instead of stopping at the first. +private actor PagedConversationListClient: ConversationListClientProtocol { + private let totalsByType: [String?: Int] + private(set) var fetchRequests: [(offset: Int, limit: Int, conversationType: String?)] = [] + + init(totalForeground: Int, totalBackground: Int) { + self.totalsByType = [nil: totalForeground, "background": totalBackground] + } + + func fetchConversationList(offset: Int, limit: Int, conversationType: String?) async -> ConversationListResponse? { + fetchRequests.append((offset: offset, limit: limit, conversationType: conversationType)) + let total = totalsByType[conversationType] ?? 0 + let remaining = max(0, total - offset) + let pageCount = min(remaining, limit) + let prefix = conversationType ?? "fg" + let items: [ConversationListResponseItem] = (0.. Bool { true } + func renameConversation(conversationId: String, name: String) async -> Bool { true } + func clearAllConversations() async -> Bool { true } + func cancelGeneration(conversationId: String) async -> Bool { true } + func undoLastMessage(conversationId: String) async -> Int? { nil } + func searchConversations(query: String, limit: Int?, maxMessagesPerConversation: Int?) async -> ConversationSearchResponse? { nil } + func reorderConversations(updates: [ReorderConversationsRequestUpdate]) async -> Bool { true } + func sendConversationSeen(_ signal: ConversationSeenSignal) async -> Bool { true } +} + // MARK: - Helpers /// Build a ConversationListResponseMessage via JSON round-trip. @@ -917,6 +963,42 @@ struct ConversationRestorerTests { #expect(await listClient.fetchRequests.count == 2) } + @Test @MainActor + func fetchConversationListPaginatesUntilHasMoreIsFalse() async { + let dc = GatewayConnectionManager() + let listClient = PagedConversationListClient(totalForeground: 450, totalBackground: 700) + let restorer = ConversationRestorer( + connectionManager: dc, + eventStreamClient: dc.eventStreamClient, + conversationHistoryClient: NoopConversationHistoryClient(), + conversationListClient: listClient + ) + let delegate = MockConversationRestorerDelegate(connectionManager: dc, eventStreamClient: dc.eventStreamClient) + restorer.delegate = delegate + + restorer.scheduleInvalidationRefetch() + + // scheduleInvalidationRefetch debounces 250ms before fetching; give the + // pagination loop enough time to drain both endpoints. + try? await Task.sleep(nanoseconds: 1_500_000_000) + + // Foreground: 450 rows -> ceil(450/200) = 3 pages (offsets 0, 200, 400). + // Background: 700 rows -> ceil(700/200) = 4 pages (offsets 0, 200, 400, 600). + let requests = await listClient.fetchRequests + let foregroundRequests = requests.filter { $0.conversationType == nil } + let backgroundRequests = requests.filter { $0.conversationType == "background" } + #expect(foregroundRequests.count == 3) + #expect(backgroundRequests.count == 4) + #expect(foregroundRequests.map(\.offset) == [0, 200, 400]) + #expect(backgroundRequests.map(\.offset) == [0, 200, 400, 600]) + + // After draining, every row from both endpoints should be present and + // the sidebar's "Load More" affordance should be disabled. + #expect(delegate.conversations.count == 1150) + #expect(delegate.hasMoreConversations == false) + #expect(delegate.serverOffset == 450) + } + @Test @MainActor func broadSyncRefreshQueuesActiveConversationHistory() { let dc = GatewayConnectionManager()