diff --git a/apps/swift-ios/Features/Chat/ThreadDetailView.swift b/apps/swift-ios/Features/Chat/ThreadDetailView.swift index 800b8a463336..28c39cf6134f 100644 --- a/apps/swift-ios/Features/Chat/ThreadDetailView.swift +++ b/apps/swift-ios/Features/Chat/ThreadDetailView.swift @@ -24,7 +24,6 @@ public struct ThreadDetailView: View { @State private var isPreparingRewind = false @State private var isPreparingInput = false @State private var submittingCompaction = false - @State private var isLoading = true @State private var sendFailed = false @State private var feedbackMessages: [FeatureMessage] = [] @State private var feedbackRevision: UInt64 = 0 @@ -101,11 +100,6 @@ public struct ThreadDetailView: View { threadActionsMenu } } - .task(id: thread.id) { - isLoading = true - _ = await model.detail(for: thread.id, force: true) - isLoading = false - } .task(id: thread.id) { // A cached thread can already show its composer while the server // is catching up. Local drafts must not wait for that request. @@ -143,7 +137,6 @@ public struct ThreadDetailView: View { } } .onDisappear { - model.releaseThread(thread.id) persistDraftBeforeLeaving() } .sheet(item: $toolSurface) { surface in @@ -683,11 +676,12 @@ public struct ThreadDetailView: View { } private func reloadThread() { - isLoading = true - Task { - _ = await model.detail(for: thread.id, force: true, fresh: true) - isLoading = false - } + model.reloadSelectedThread(thread.id) + } + + private var isLoading: Bool { + model.detailLoadStates[thread.id] == .loading + || (model.details[thread.id] == nil && model.detailLoadStates[thread.id] == nil) } private var threadConnectionState: FeatureConnection.State? { diff --git a/apps/swift-ios/Features/Root/FeatureRootModel.swift b/apps/swift-ios/Features/Root/FeatureRootModel.swift index cb91af596570..7c87c8356f67 100644 --- a/apps/swift-ios/Features/Root/FeatureRootModel.swift +++ b/apps/swift-ios/Features/Root/FeatureRootModel.swift @@ -96,6 +96,8 @@ public final class FeatureRootModel { private var pendingCompletionSubmissionIDs: Set = [] private var pendingDiscardSubmissionIDs: Set = [] private var detailRecency: [String] = [] + private var selectedThreadID: String? + @ObservationIgnored private var selectedThreadLoadTask: Task? private var detailLoadGeneration: UInt64 = 0 private var detailLoadRevisions: [String: UInt64] = [:] private var detailLoadRequestRevision: UInt64 = 0 @@ -721,6 +723,34 @@ public final class FeatureRootModel { } } + /// The workspace selection owns transport work. Detail views can disappear + /// during split-view navigation even while their thread is being opened. + func selectThread(_ id: String?) { + guard selectedThreadID != id else { return } + selectedThreadLoadTask?.cancel() + selectedThreadLoadTask = nil + if let previousID = selectedThreadID { + releaseThread(previousID) + } + selectedThreadID = id + if let id { + loadSelectedThread(id, fresh: false) + } + } + + func reloadSelectedThread(_ id: String) { + guard selectedThreadID == id else { return } + loadSelectedThread(id, fresh: true) + } + + private func loadSelectedThread(_ id: String, fresh: Bool) { + selectedThreadLoadTask?.cancel() + selectedThreadLoadTask = Task { + guard !Task.isCancelled else { return } + _ = await detail(for: id, force: true, fresh: fresh) + } + } + public func detail(for id: String, force: Bool = false, fresh: Bool = false) async -> FeatureThreadDetail? { if !force, let cached = details[id] { return cached @@ -744,6 +774,7 @@ public final class FeatureRootModel { } do { var detail = try await client.loadThread(id: id, fresh: fresh) + try Task.checkCancellation() guard currentEnvironmentIdentity == environment else { return details[id] } @@ -797,7 +828,7 @@ public final class FeatureRootModel { } } - /// Ends any selected-thread transport work when its detail view closes. + /// Releases transport work when the workspace changes or clears selection. public func releaseThread(_ id: String) { client.releaseThread(id: id) markDetailRecentlyUsed(id) diff --git a/apps/swift-ios/Features/Workspace/WorkspaceView.swift b/apps/swift-ios/Features/Workspace/WorkspaceView.swift index 2a0d3a448149..6d40e2f3c760 100644 --- a/apps/swift-ios/Features/Workspace/WorkspaceView.swift +++ b/apps/swift-ios/Features/Workspace/WorkspaceView.swift @@ -177,9 +177,12 @@ public struct WorkspaceView: View { .onChange(of: selectedThreadIsAvailable) { _, isAvailable in if !isAvailable { closeSelectedThread() } } - .onChange(of: selectedThreadID) { _, newValue in + .onChange(of: selectedThreadID, initial: true) { _, newValue in + model.selectThread(newValue) preferredCompactColumn = newValue == nil ? .sidebar : .detail } + .onAppear { model.selectThread(selectedThreadID) } + .onDisappear { model.selectThread(nil) } .onChange(of: selectedProjectIsAvailable) { _, isAvailable in if !isAvailable { selectedProjectID = nil } } @@ -297,7 +300,7 @@ public struct WorkspaceView: View { model: model, thread: thread, submitMessage: submitMessage, - onNavigateBack: closeSelectedThread + onNavigateBack: { preferredCompactColumn = .sidebar } ) .id(id) } else { diff --git a/apps/swift-ios/Tests/FeatureTests/FeatureRootModelTests.swift b/apps/swift-ios/Tests/FeatureTests/FeatureRootModelTests.swift index 241354476b03..9db36a1fc76a 100644 --- a/apps/swift-ios/Tests/FeatureTests/FeatureRootModelTests.swift +++ b/apps/swift-ios/Tests/FeatureTests/FeatureRootModelTests.swift @@ -9,6 +9,103 @@ import XCTest @MainActor @Suite("Feature root model") struct FeatureRootModelTests { + @Test(arguments: [[1, 2, 3], [3, 2, 1]]) + func selectedThreadOwnsLoadsAcrossRapidNavigation(completionOrder: [Int]) async throws { + let client = FeatureClientStub() + let model = testRootModel(client: client) + let started = AsyncStream.makeStream() + let returned = AsyncStream.makeStream() + var starts = started.stream.makeAsyncIterator() + var returns = returned.stream.makeAsyncIterator() + var continuations: [Int: CheckedContinuation] = [:] + var loadIDs: [String] = [] + var cancelledLoads: [Int] = [] + defer { + model.selectThread(nil) + started.continuation.finish() + returned.continuation.finish() + for continuation in continuations.values { continuation.resume() } + } + client.loadThreadHandler = { id in + loadIDs.append(id) + let index = loadIDs.count + started.continuation.yield(index) + await withCheckedContinuation { continuations[index] = $0 } + if Task.isCancelled { cancelledLoads.append(index) } + returned.continuation.yield(index) + // Simulate a transport that returns a late result despite cancellation. + return FeatureThreadDetail( + thread: FeatureThread(id: id, projectID: "project", title: id), + messages: [.init(id: "message-\(index)", role: .assistant, text: "Response \(index)")] + ) + } + + for (offset, id) in ["first", "second", "first"].enumerated() { + model.selectThread(id) + #expect(await starts.next() == offset + 1) + } + // Showing the same selection again must keep its in-flight load. + model.selectThread("first") + #expect(loadIDs == ["first", "second", "first"]) + #expect(client.releasedThreadIDs == ["first", "second"]) + + for index in completionOrder { + let pending = continuations.removeValue(forKey: index) + let continuation = try #require(pending) + continuation.resume() + #expect(await returns.next() == index) + if index != 3 { + #expect(model.details["first"]?.messages.first?.text != "Response 1") + #expect(model.details["second"] == nil) + } + } + #expect(cancelledLoads.sorted() == [1, 2]) + #expect(model.details["first"]?.messages.first?.text == "Response 3") + #expect(model.detailLoadStates["first"] == nil) + model.selectThread(nil) + #expect(client.releasedThreadIDs == ["first", "second", "first"]) + } + + @Test + func leavingSelectionCancelsItsRefreshAndIgnoresRetriesFromOldViews() async throws { + let client = FeatureClientStub() + let model = testRootModel(client: client) + let started = AsyncStream.makeStream() + let returned = AsyncStream.makeStream() + var starts = started.stream.makeAsyncIterator() + var returns = returned.stream.makeAsyncIterator() + var pending: CheckedContinuation? + defer { + model.selectThread(nil) + pending?.resume() + started.continuation.finish() + returned.continuation.finish() + } + client.loadThreadHandler = { id in + started.continuation.yield(()) + await withCheckedContinuation { pending = $0 } + returned.continuation.yield(Task.isCancelled) + return .init(thread: .init(id: id, projectID: "project", title: id)) + } + model.selectThread("first") + _ = await starts.next() + var continuation = try #require(pending) + pending = nil + continuation.resume() + #expect(await returns.next() == false) + + model.reloadSelectedThread("first") + _ = await starts.next() + model.selectThread(nil) + model.reloadSelectedThread("first") + continuation = try #require(pending) + pending = nil + continuation.resume() + #expect(await returns.next() == true) + #expect(client.releasedThreadIDs == ["first"]) + #expect(model.detailLoadStates["first"] == nil) + } + @Test func rewindLocksSendingAndSavesRecoveredInputAfterLeavingTheThread() async throws { let directory = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString) @@ -2309,11 +2406,13 @@ struct FeatureRootModelTests { return nil } defer { + model.selectThread(nil) pendingLoad?.resume(returning: cached) loads.continuation.finish() uploads.continuation.finish() } + model.selectThread(thread.id) let controller = UIHostingController(rootView: ThreadDetailView( model: model, thread: thread, submitMessage: { _ in false }, draftStore: draftStore )) @@ -3814,6 +3913,7 @@ private final class FeatureClientStub: FeatureClient, T3ConnectCapable { var beforeSaveSettings: (@MainActor () async throws -> Void)? var loadThreadError: (any Error)? var loadThreadHandler: ((String) async throws -> FeatureThreadDetail)? + var releasedThreadIDs: [String] = [] var preuploadHandler: ((FeatureUploadAttachment, String) async throws -> FeatureUploadedAttachmentReference?)? var beforeLoadThreadReturn: (() async -> Void)? var loadEarlierCallCount = 0 @@ -3963,6 +4063,10 @@ private final class FeatureClientStub: FeatureClient, T3ConnectCapable { return FeatureThreadDetail(thread: createdThread) } + func releaseThread(id: String) { + releasedThreadIDs.append(id) + } + func loadEarlierThreadTurns(id: String) async throws -> FeatureThreadDetail? { loadEarlierCallCount += 1 return earlierThreadDetail