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
10 changes: 9 additions & 1 deletion clients/ios/App/IOSThreadStore.swift
Original file line number Diff line number Diff line change
Expand Up @@ -470,7 +470,15 @@ class IOSThreadStore: ObservableObject {
/// Request an older page of history for pagination.
private func requestPaginatedHistory(sessionId: String, beforeTimestamp: Double) {
guard let daemon = daemonClient as? DaemonClient,
let thread = threads.first(where: { $0.sessionId == sessionId }) else { return }
let thread = threads.first(where: { $0.sessionId == sessionId }) else {
// Clear loading state so the user isn't stuck with a permanent spinner.
// The daemon cast may fail (e.g. HTTP transport) while the thread is still findable.
if let thread = threads.first(where: { $0.sessionId == sessionId }),
let vm = viewModels[thread.id] {
vm.isLoadingMoreMessages = false
}
return
}
pendingHistoryBySessionId[sessionId] = thread.id
do {
try daemon.sendHistoryRequest(sessionId: sessionId, limit: 50, beforeTimestamp: beforeTimestamp, mode: "light")
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -513,6 +513,14 @@ final class ThreadManager: ObservableObject, ThreadRestorerDelegate {
return vm
}

func existingChatViewModel(forSessionId sessionId: String) -> ChatViewModel? {
for (threadId, vm) in chatViewModels where vm.sessionId == sessionId {
touchVMAccessOrder(threadId)
return vm
}
return nil
}

func setChatViewModel(_ vm: ChatViewModel, for threadId: UUID) {
chatViewModels[threadId] = vm
touchVMAccessOrder(threadId)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,8 @@ protocol ThreadRestorerDelegate: AnyObject {
func isSessionArchived(_ sessionId: String) -> Bool
func restoreLastActiveThread()
func appendThreads(from response: SessionListResponseMessage)
/// Returns an existing ChatViewModel matching the given session ID, if any.
func existingChatViewModel(forSessionId sessionId: String) -> ChatViewModel?
}

/// Handles daemon session restoration: fetching the session list on connect,
Expand Down Expand Up @@ -126,7 +128,12 @@ final class ThreadSessionRestorer {
/// trigger in the message list when all locally loaded messages are visible.
func requestPaginatedHistory(sessionId: String, beforeTimestamp: Double) {
guard let delegate else { return }
guard let thread = delegate.threads.first(where: { $0.sessionId == sessionId }) else { return }
guard let thread = delegate.threads.first(where: { $0.sessionId == sessionId }) else {
// Thread removed from the list during a concurrent reconnect/refresh.
// Reset loading state so the user isn't stuck with a permanent spinner.
delegate.existingChatViewModel(forSessionId: sessionId)?.isLoadingMoreMessages = false
return
}
pendingHistoryBySessionId[sessionId] = thread.id
do {
try daemonClient.sendHistoryRequest(sessionId: sessionId, limit: 50, beforeTimestamp: beforeTimestamp, mode: "light")
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,13 @@ final class MockThreadRestorerDelegate: ThreadRestorerDelegate {
viewModels[threadId]
}

func existingChatViewModel(forSessionId sessionId: String) -> ChatViewModel? {
for (_, vm) in viewModels where vm.sessionId == sessionId {
return vm
}
return nil
}

func setChatViewModel(_ vm: ChatViewModel, for threadId: UUID) {
viewModels[threadId] = vm
}
Expand Down
13 changes: 13 additions & 0 deletions clients/shared/Features/Chat/ChatViewModel.swift
Original file line number Diff line number Diff line change
Expand Up @@ -390,6 +390,9 @@ 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

/// Timeout task that resets `isLoadingMoreMessages` if the daemon never responds.
private var loadMoreTimeoutTask: Task<Void, Never>?

/// 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 } }
Expand Down Expand Up @@ -446,6 +449,14 @@ public final class ChatViewModel: ObservableObject {
// All local messages are visible — fetch the next page from the daemon.
guard hasMoreHistory, let cursor = historyCursor, let sessionId else { return false }
isLoadingMoreMessages = true
// Safety timeout: if the daemon drops the connection or never responds,
// reset the flag so the user can retry.
loadMoreTimeoutTask?.cancel()
loadMoreTimeoutTask = Task { @MainActor [weak self] in
try? await Task.sleep(nanoseconds: 15_000_000_000) // 15 seconds
guard let self, self.isLoadingMoreMessages else { return }
self.isLoadingMoreMessages = false
}
Comment thread
ashleeradka marked this conversation as resolved.
onLoadMoreHistory?(sessionId, cursor)
// The loading indicator is cleared by populateFromHistory when the response arrives.
return true
Expand Down Expand Up @@ -1936,6 +1947,8 @@ public final class ChatViewModel: ObservableObject {
} else {
displayedMessageCount = Int.max
}
self.loadMoreTimeoutTask?.cancel()
self.loadMoreTimeoutTask = nil
self.isLoadingMoreMessages = false
trimOldMessagesIfNeeded()
return
Expand Down
Loading