Skip to content
Open
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
18 changes: 6 additions & 12 deletions apps/swift-ios/Features/Chat/ThreadDetailView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -143,7 +137,6 @@ public struct ThreadDetailView: View {
}
}
.onDisappear {
model.releaseThread(thread.id)
persistDraftBeforeLeaving()
}
.sheet(item: $toolSurface) { surface in
Expand Down Expand Up @@ -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? {
Expand Down
33 changes: 32 additions & 1 deletion apps/swift-ios/Features/Root/FeatureRootModel.swift
Original file line number Diff line number Diff line change
Expand Up @@ -96,6 +96,8 @@ public final class FeatureRootModel {
private var pendingCompletionSubmissionIDs: Set<String> = []
private var pendingDiscardSubmissionIDs: Set<String> = []
private var detailRecency: [String] = []
private var selectedThreadID: String?
@ObservationIgnored private var selectedThreadLoadTask: Task<Void, Never>?
private var detailLoadGeneration: UInt64 = 0
private var detailLoadRevisions: [String: UInt64] = [:]
private var detailLoadRequestRevision: UInt64 = 0
Expand Down Expand Up @@ -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
Expand All @@ -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]
}
Expand Down Expand Up @@ -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)
Expand Down
7 changes: 5 additions & 2 deletions apps/swift-ios/Features/Workspace/WorkspaceView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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 }
}
Expand Down Expand Up @@ -297,7 +300,7 @@ public struct WorkspaceView: View {
model: model,
thread: thread,
submitMessage: submitMessage,
onNavigateBack: closeSelectedThread
onNavigateBack: { preferredCompactColumn = .sidebar }
)
.id(id)
} else {
Expand Down
104 changes: 104 additions & 0 deletions apps/swift-ios/Tests/FeatureTests/FeatureRootModelTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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<Int>.makeStream()
let returned = AsyncStream<Int>.makeStream()
var starts = started.stream.makeAsyncIterator()
var returns = returned.stream.makeAsyncIterator()
var continuations: [Int: CheckedContinuation<Void, Never>] = [:]
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<Void>.makeStream()
let returned = AsyncStream<Bool>.makeStream()
var starts = started.stream.makeAsyncIterator()
var returns = returned.stream.makeAsyncIterator()
var pending: CheckedContinuation<Void, Never>?
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)
Expand Down Expand Up @@ -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
))
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
Loading