Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand All @@ -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)
Expand All @@ -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..<Self.conversationListMaxPages {
let offset = page * pageSize
guard let response = await conversationListClient.fetchConversationList(
offset: offset, limit: pageSize, conversationType: conversationType
) else {
Comment thread
siddseethepalli marked this conversation as resolved.
return nil
}
if firstPage == nil { firstPage = response }
accumulated.append(contentsOf: response.conversations)
let hasMore = response.hasMore ?? false
if !hasMore || response.conversations.isEmpty { break }
}

guard let first = firstPage else { return nil }
// Groups are sent with the first page only; preserve them on the
// synthesized response. `hasMore` is forced to false because we've
// already exhausted the server (or hit the safety cap).
return ConversationListResponse(
type: first.type,
conversations: accumulated,
hasMore: false,
nextOffset: nil,
groups: first.groups
)
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -146,6 +146,52 @@ private actor RecordingConversationListClient: ConversationListClientProtocol {
func sendConversationSeen(_ signal: ConversationSeenSignal) async -> 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..<pageCount).map { i in
let dict: [String: Any] = [
"id": "\(prefix)-\(offset + i)",
"title": "row \(offset + i)",
"updatedAt": 1_700_000_000 + offset + i,
]
let data = try! JSONSerialization.data(withJSONObject: dict)
return try! JSONDecoder().decode(ConversationListResponseItem.self, from: data)
}
let nextOffset = offset + pageCount
return ConversationListResponse(
type: "conversation_list_response",
conversations: items,
hasMore: nextOffset < total,
nextOffset: nextOffset,
groups: nil
)
}

func switchConversation(conversationId: String) async -> 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.
Expand Down Expand Up @@ -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()
Expand Down