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
32 changes: 30 additions & 2 deletions clients/ios/App/IOSThreadStore.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand All @@ -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
Comment thread
tkheyfets marked this conversation as resolved.
}

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.
Expand Down Expand Up @@ -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) {
Expand Down
10 changes: 6 additions & 4 deletions clients/ios/Views/ChatContentView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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))
Comment thread
tkheyfets marked this conversation as resolved.
}

var body: some View {
Expand Down
10 changes: 8 additions & 2 deletions clients/shared/Features/Chat/ChatViewModel.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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
}
Expand Down
Loading