diff --git a/clients/ios/App/IOSThreadStore.swift b/clients/ios/App/IOSThreadStore.swift index 92c577afac7..30d12eade8a 100644 --- a/clients/ios/App/IOSThreadStore.swift +++ b/clients/ios/App/IOSThreadStore.swift @@ -71,6 +71,13 @@ class IOSThreadStore: ObservableObject { private static let threadPageSize = 50 /// Current offset used for the next page fetch; advances by `threadPageSize` on each load. private var threadListOffset: Int = 0 + /// Monotonically increasing token incremented whenever pagination is reset (e.g. reconnect). + /// Responses carrying a stale token are discarded to prevent a page N reply that was + /// in-flight before a reconnect from being misclassified as a first-page result. + private var sessionListToken: UInt64 = 0 + /// Token captured when the most-recent session-list request was sent. + /// The response handler compares the current token against this to detect staleness. + private var inFlightSessionListToken: UInt64 = 0 init(daemonClient: any DaemonClientProtocol) { self.daemonClient = daemonClient @@ -133,12 +140,18 @@ class IOSThreadStore: ObservableObject { // otherwise wait for the daemonDidReconnect notification. if daemon.isConnected { threadListOffset = 0 + sessionListToken += 1 + inFlightSessionListToken = sessionListToken try? daemon.sendSessionList(offset: 0, limit: Self.threadPageSize) } NotificationCenter.default.publisher(for: .daemonDidReconnect) .sink { [weak self, weak daemon] _ in guard let self, let daemon else { return } + // Bump the token so any in-flight page responses from the previous + // connection are identified as stale and discarded in the handler. + self.sessionListToken += 1 + self.inFlightSessionListToken = self.sessionListToken // Reset pagination state on reconnect so the list refreshes from page 1. self.threadListOffset = 0 self.hasMoreThreads = false @@ -148,6 +161,12 @@ class IOSThreadStore: ObservableObject { } private func handleSessionListResponse(_ response: SessionListResponseMessage) { + // Discard responses that arrived after a reconnect reset the token; they + // belong to the previous connection and must not corrupt the fresh list. + guard inFlightSessionListToken == sessionListToken else { + return + } + let filteredSessions = response.sessions.filter { $0.threadType != "private" } guard !filteredSessions.isEmpty || (response.hasMore == false && threadListOffset == 0) else { // Empty non-first page means nothing more to append. @@ -224,8 +243,17 @@ class IOSThreadStore: ObservableObject { !isLoadingMoreThreads, hasMoreThreads else { return } isLoadingMoreThreads = true - threadListOffset += Self.threadPageSize - try? daemon.sendSessionList(offset: threadListOffset, limit: Self.threadPageSize) + let nextOffset = threadListOffset + Self.threadPageSize + threadListOffset = nextOffset + inFlightSessionListToken = sessionListToken + do { + try daemon.sendSessionList(offset: nextOffset, limit: Self.threadPageSize) + } catch { + // Request failed before being sent — roll back pagination state so a + // subsequent call can retry from the same offset. + isLoadingMoreThreads = false + threadListOffset -= Self.threadPageSize + } } private func handleHistoryResponse(_ response: HistoryResponseMessage) { diff --git a/clients/ios/Views/ChatContentView.swift b/clients/ios/Views/ChatContentView.swift index d36a9db4ef5..3c3a11dc1ed 100644 --- a/clients/ios/Views/ChatContentView.swift +++ b/clients/ios/Views/ChatContentView.swift @@ -25,10 +25,12 @@ struct ChatContentView: View { /// The slice of messages shown in the view, honoring the pagination window. private var visibleMessages: [ChatMessage] { - let all = viewModel.messages.filter { !$0.isSubagentNotification } - // displayedMessageCount tracks how many of the most-recent messages to show. - let count = min(viewModel.displayedMessageCount, all.count) - return Array(all.suffix(count)) + let all = viewModel.displayedMessages + // When the user has scrolled back through the full history (displayedMessageCount + // reaches all.count), keep showing everything — don't clamp the window back down + // as new messages arrive, which would cause previously loaded history to vanish. + guard viewModel.displayedMessageCount < all.count else { return all } + return Array(all.suffix(viewModel.displayedMessageCount)) } var body: some View { diff --git a/clients/shared/Features/Chat/ChatViewModel.swift b/clients/shared/Features/Chat/ChatViewModel.swift index 7b309f1b3f8..3c823def9aa 100644 --- a/clients/shared/Features/Chat/ChatViewModel.swift +++ b/clients/shared/Features/Chat/ChatViewModel.swift @@ -324,8 +324,14 @@ public final class ChatViewModel: ObservableObject { /// True while a previous-page load is in progress (brief async delay for UX). @Published public var isLoadingMoreMessages: Bool = false + /// The subset of messages that are actually displayed (excludes subagent notifications + /// and other UI-only messages that the view filters before rendering). + public var displayedMessages: [ChatMessage] { messages.filter { !$0.isSubagentNotification } } + /// Whether there are more messages above the current display window. - public var hasMoreMessages: Bool { displayedMessageCount < messages.count } + /// Compares against the filtered (displayed) count so the "load more" sentinel + /// appears only when there are genuinely more visible messages to reveal. + public var hasMoreMessages: Bool { displayedMessageCount < displayedMessages.count } /// Load the previous page of messages by expanding the display window. /// Returns `true` if there were additional messages to reveal. @@ -335,7 +341,7 @@ public final class ChatViewModel: ObservableObject { isLoadingMoreMessages = true // Brief delay so the loading indicator is visible before the list shifts. try? await Task.sleep(nanoseconds: 150_000_000) - displayedMessageCount = min(displayedMessageCount + Self.messagePageSize, messages.count) + displayedMessageCount = min(displayedMessageCount + Self.messagePageSize, displayedMessages.count) isLoadingMoreMessages = false return true }