diff --git a/clients/ios/App/IOSThreadStore.swift b/clients/ios/App/IOSThreadStore.swift index 2f25add1cf3..9f27b904469 100644 --- a/clients/ios/App/IOSThreadStore.swift +++ b/clients/ios/App/IOSThreadStore.swift @@ -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") diff --git a/clients/macos/vellum-assistant/Features/MainWindow/ThreadManager.swift b/clients/macos/vellum-assistant/Features/MainWindow/ThreadManager.swift index 02413149d92..83482e331f0 100644 --- a/clients/macos/vellum-assistant/Features/MainWindow/ThreadManager.swift +++ b/clients/macos/vellum-assistant/Features/MainWindow/ThreadManager.swift @@ -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) diff --git a/clients/macos/vellum-assistant/Features/MainWindow/ThreadSessionRestorer.swift b/clients/macos/vellum-assistant/Features/MainWindow/ThreadSessionRestorer.swift index 35b126c49f7..8ea9185cb76 100644 --- a/clients/macos/vellum-assistant/Features/MainWindow/ThreadSessionRestorer.swift +++ b/clients/macos/vellum-assistant/Features/MainWindow/ThreadSessionRestorer.swift @@ -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, @@ -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") diff --git a/clients/macos/vellum-assistantTests/ThreadSessionRestorerTests.swift b/clients/macos/vellum-assistantTests/ThreadSessionRestorerTests.swift index 3676fe1a657..135f712270c 100644 --- a/clients/macos/vellum-assistantTests/ThreadSessionRestorerTests.swift +++ b/clients/macos/vellum-assistantTests/ThreadSessionRestorerTests.swift @@ -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 } diff --git a/clients/shared/Features/Chat/ChatViewModel.swift b/clients/shared/Features/Chat/ChatViewModel.swift index 9ac6f2b6f23..7dc380407d8 100644 --- a/clients/shared/Features/Chat/ChatViewModel.swift +++ b/clients/shared/Features/Chat/ChatViewModel.swift @@ -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? + /// 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 } } @@ -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 + } onLoadMoreHistory?(sessionId, cursor) // The loading indicator is cleared by populateFromHistory when the response arrives. return true @@ -1936,6 +1947,8 @@ public final class ChatViewModel: ObservableObject { } else { displayedMessageCount = Int.max } + self.loadMoreTimeoutTask?.cancel() + self.loadMoreTimeoutTask = nil self.isLoadingMoreMessages = false trimOldMessagesIfNeeded() return