From 90e9d25149fabfa6e007fa99d206dea30a593ec4 Mon Sep 17 00:00:00 2001 From: Alex Southwell Date: Mon, 10 Aug 2026 11:03:38 +1000 Subject: [PATCH 01/13] fix(swift-ios): add settle to pinned thread swipe --- .../Workspace/HomeThreadCollectionView.swift | 103 +++++++++++++----- .../FeatureTests/DailyUXSidebarTests.swift | 19 ++++ 2 files changed, 95 insertions(+), 27 deletions(-) diff --git a/apps/swift-ios/Features/Workspace/HomeThreadCollectionView.swift b/apps/swift-ios/Features/Workspace/HomeThreadCollectionView.swift index 90b048fa804..831ac6e6cbb 100644 --- a/apps/swift-ios/Features/Workspace/HomeThreadCollectionView.swift +++ b/apps/swift-ios/Features/Workspace/HomeThreadCollectionView.swift @@ -1,6 +1,39 @@ import SwiftUI import UIKit +enum HomeThreadSwipeActionKind: Equatable { + case delete + case restore + case unpin + case settle + case reopen + case archive +} + +enum HomeThreadSwipeActions { + static func kinds( + for thread: FeatureThread, + isArchived: Bool, + now: Date + ) -> [HomeThreadSwipeActionKind] { + if isArchived { + return [.delete, .restore] + } + + var kinds: [HomeThreadSwipeActionKind] = [.delete] + if thread.pinnedAt != nil, thread.canTogglePin { + kinds.append(.unpin) + } + if thread.canToggleSettlement { + kinds.append(thread.isEffectivelySettled(at: now) ? .reopen : .settle) + } + if kinds.count == 1 { + kinds.append(.archive) + } + return kinds + } +} + /// A recycled, diffable Home surface. SwiftUI still owns the surrounding shell, /// while UIKit keeps row creation and updates proportional to visible threads. struct HomeThreadCollectionView: UIViewRepresentable { @@ -193,55 +226,71 @@ struct HomeThreadCollectionView: UIViewRepresentable { return nil } - let delete = UIContextualAction(style: .destructive, title: "Delete") { [weak self] _, _, finish in - self?.parent.onDelete(thread) - finish(true) - } - delete.image = UIImage(systemName: "trash") + let actions = HomeThreadSwipeActions.kinds( + for: thread, + isArchived: isArchived, + now: .now + ).map { swipeAction($0, for: thread) } + let configuration = UISwipeActionsConfiguration(actions: actions) + configuration.performsFirstActionWithFullSwipe = false + return configuration + } - let primaryAction: UIContextualAction - if isArchived { - primaryAction = UIContextualAction(style: .normal, title: "Restore") { + private func swipeAction( + _ kind: HomeThreadSwipeActionKind, + for thread: FeatureThread + ) -> UIContextualAction { + switch kind { + case .delete: + let action = UIContextualAction(style: .destructive, title: "Delete") { + [weak self] _, _, finish in + self?.parent.onDelete(thread) + finish(true) + } + action.image = UIImage(systemName: "trash") + return action + case .restore: + let action = UIContextualAction(style: .normal, title: "Restore") { [weak self] _, _, finish in self?.parent.onArchive(thread, false) finish(true) } - primaryAction.image = UIImage(systemName: "arrow.uturn.backward") - primaryAction.backgroundColor = .systemBlue - } else if thread.pinnedAt != nil, thread.canTogglePin { - primaryAction = UIContextualAction(style: .normal, title: "Unpin") { + action.image = UIImage(systemName: "arrow.uturn.backward") + action.backgroundColor = .systemBlue + return action + case .unpin: + let action = UIContextualAction(style: .normal, title: "Unpin") { [weak self] _, _, finish in self?.parent.onPin(thread, false) finish(true) } - primaryAction.image = UIImage(systemName: "pin.slash") - primaryAction.backgroundColor = .systemBlue - } else if thread.canToggleSettlement { - let isSettled = thread.isEffectivelySettled(at: .now) - primaryAction = UIContextualAction( + action.image = UIImage(systemName: "pin.slash") + action.backgroundColor = .systemBlue + return action + case .settle, .reopen: + let isSettled = kind == .reopen + let action = UIContextualAction( style: .normal, title: isSettled ? "Reopen" : "Settle" ) { [weak self] _, _, finish in self?.parent.onSettle(thread, !isSettled) finish(true) } - primaryAction.image = UIImage( + action.image = UIImage( systemName: isSettled ? "arrow.counterclockwise" : "checkmark" ) - primaryAction.backgroundColor = isSettled ? .systemBlue : .systemGreen - } else { - primaryAction = UIContextualAction(style: .normal, title: "Archive") { + action.backgroundColor = isSettled ? .systemBlue : .systemGreen + return action + case .archive: + let action = UIContextualAction(style: .normal, title: "Archive") { [weak self] _, _, finish in self?.parent.onArchive(thread, true) finish(true) } - primaryAction.image = UIImage(systemName: "archivebox") - primaryAction.backgroundColor = .systemGray + action.image = UIImage(systemName: "archivebox") + action.backgroundColor = .systemGray + return action } - - let configuration = UISwipeActionsConfiguration(actions: [delete, primaryAction]) - configuration.performsFirstActionWithFullSwipe = false - return configuration } private func configure( diff --git a/apps/swift-ios/Tests/FeatureTests/DailyUXSidebarTests.swift b/apps/swift-ios/Tests/FeatureTests/DailyUXSidebarTests.swift index 313f59a0014..399a45e8d10 100644 --- a/apps/swift-ios/Tests/FeatureTests/DailyUXSidebarTests.swift +++ b/apps/swift-ios/Tests/FeatureTests/DailyUXSidebarTests.swift @@ -117,6 +117,25 @@ struct DailyUXSidebarTests { #expect(DailyUXSidebarRefresh.nextBoundary(for: [pinnedSettled], after: now) == nil) } + @Test + func pinnedThreadSwipeIncludesUnpinAndSettle() { + var pinned = thread( + id: "pinned", + created: -100, + updated: -50, + state: .idle + ) + pinned.pinnedAt = now + + let actions = HomeThreadSwipeActions.kinds( + for: pinned, + isArchived: false, + now: now + ) + + #expect(actions == [.delete, .unpin, .settle]) + } + @Test func pinActionsTolerateMissingCapabilitiesAndKeepPinsReversible() { var legacyDescriptor = thread(id: "legacy", created: -20, updated: -10) From 62e9c962464050474fd8c2d8de7124503988b66f Mon Sep 17 00:00:00 2001 From: Alex Southwell Date: Mon, 10 Aug 2026 14:15:15 +1000 Subject: [PATCH 02/13] fix(swift-ios): make settle the full-swipe action --- .../swift-ios/Features/Workspace/HomeThreadCollectionView.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/swift-ios/Features/Workspace/HomeThreadCollectionView.swift b/apps/swift-ios/Features/Workspace/HomeThreadCollectionView.swift index 831ac6e6cbb..16b5751ed3b 100644 --- a/apps/swift-ios/Features/Workspace/HomeThreadCollectionView.swift +++ b/apps/swift-ios/Features/Workspace/HomeThreadCollectionView.swift @@ -232,7 +232,7 @@ struct HomeThreadCollectionView: UIViewRepresentable { now: .now ).map { swipeAction($0, for: thread) } let configuration = UISwipeActionsConfiguration(actions: actions) - configuration.performsFirstActionWithFullSwipe = false + configuration.performsFirstActionWithFullSwipe = true return configuration } From 5fe49454ae719d83fd6920a6ce6f4681103afa4d Mon Sep 17 00:00:00 2001 From: Alex Southwell Date: Mon, 10 Aug 2026 17:34:09 +1000 Subject: [PATCH 03/13] fix(swift-ios): keep destructive swipe action secondary --- .../Workspace/HomeThreadCollectionView.swift | 15 +++++++++------ .../Tests/FeatureTests/DailyUXSidebarTests.swift | 4 +++- 2 files changed, 12 insertions(+), 7 deletions(-) diff --git a/apps/swift-ios/Features/Workspace/HomeThreadCollectionView.swift b/apps/swift-ios/Features/Workspace/HomeThreadCollectionView.swift index 16b5751ed3b..d409aec9bb5 100644 --- a/apps/swift-ios/Features/Workspace/HomeThreadCollectionView.swift +++ b/apps/swift-ios/Features/Workspace/HomeThreadCollectionView.swift @@ -17,19 +17,20 @@ enum HomeThreadSwipeActions { now: Date ) -> [HomeThreadSwipeActionKind] { if isArchived { - return [.delete, .restore] + return [.restore, .delete] } - var kinds: [HomeThreadSwipeActionKind] = [.delete] - if thread.pinnedAt != nil, thread.canTogglePin { - kinds.append(.unpin) - } + var kinds: [HomeThreadSwipeActionKind] = [] if thread.canToggleSettlement { kinds.append(thread.isEffectivelySettled(at: now) ? .reopen : .settle) } - if kinds.count == 1 { + if thread.pinnedAt != nil, thread.canTogglePin { + kinds.append(.unpin) + } + if kinds.isEmpty { kinds.append(.archive) } + kinds.append(.delete) return kinds } } @@ -232,6 +233,8 @@ struct HomeThreadCollectionView: UIViewRepresentable { now: .now ).map { swipeAction($0, for: thread) } let configuration = UISwipeActionsConfiguration(actions: actions) + // UIKit performs actions[0] on a full swipe. The model keeps the + // reversible lifecycle action first and destructive Delete last. configuration.performsFirstActionWithFullSwipe = true return configuration } diff --git a/apps/swift-ios/Tests/FeatureTests/DailyUXSidebarTests.swift b/apps/swift-ios/Tests/FeatureTests/DailyUXSidebarTests.swift index 399a45e8d10..917360534fe 100644 --- a/apps/swift-ios/Tests/FeatureTests/DailyUXSidebarTests.swift +++ b/apps/swift-ios/Tests/FeatureTests/DailyUXSidebarTests.swift @@ -133,7 +133,9 @@ struct DailyUXSidebarTests { now: now ) - #expect(actions == [.delete, .unpin, .settle]) + #expect(actions == [.settle, .unpin, .delete]) + #expect(actions.first == .settle) + #expect(actions.last == .delete) } @Test From 9c7ea5e9df0c12209aab2615a33f6909406435d7 Mon Sep 17 00:00:00 2001 From: Alex Southwell Date: Mon, 10 Aug 2026 18:01:06 +1000 Subject: [PATCH 04/13] test(swift-ios): protect reversible full swipe invariant --- .../FeatureTests/DailyUXSidebarTests.swift | 23 +++++++++++++++++++ 1 file changed, 23 insertions(+) diff --git a/apps/swift-ios/Tests/FeatureTests/DailyUXSidebarTests.swift b/apps/swift-ios/Tests/FeatureTests/DailyUXSidebarTests.swift index 917360534fe..310efb7b70a 100644 --- a/apps/swift-ios/Tests/FeatureTests/DailyUXSidebarTests.swift +++ b/apps/swift-ios/Tests/FeatureTests/DailyUXSidebarTests.swift @@ -138,6 +138,29 @@ struct DailyUXSidebarTests { #expect(actions.last == .delete) } + @Test + func fullSwipeAlwaysChoosesAReversibleAction() { + let settled = thread( + id: "settled", + created: -100, + updated: -50, + state: .idle, + isSettled: true + ) + var fallback = thread(id: "fallback", created: -100, updated: -50, state: .idle) + fallback.supportsSettlement = false + let shapes = [ + HomeThreadSwipeActions.kinds(for: fallback, isArchived: true, now: now), + HomeThreadSwipeActions.kinds(for: settled, isArchived: false, now: now), + HomeThreadSwipeActions.kinds(for: fallback, isArchived: false, now: now), + ] + + #expect(shapes[0] == [.restore, .delete]) + #expect(shapes[1].first == .reopen) + #expect(shapes[2] == [.archive, .delete]) + #expect(shapes.allSatisfy { $0.first != .delete }) + } + @Test func pinActionsTolerateMissingCapabilitiesAndKeepPinsReversible() { var legacyDescriptor = thread(id: "legacy", created: -20, updated: -10) From 2b197daa3bf20f1ff3d3e6762be319d128e538fe Mon Sep 17 00:00:00 2001 From: Alex Southwell Date: Thu, 13 Aug 2026 22:29:17 +1000 Subject: [PATCH 05/13] fix(swift-ios): gate settle actions by thread state --- .../Features/Workspace/DailyUXModels.swift | 12 +++++--- .../Workspace/HomeThreadCollectionView.swift | 28 +++++++++++-------- .../FeatureTests/DailyUXSidebarTests.swift | 25 +++++++++++++++++ 3 files changed, 50 insertions(+), 15 deletions(-) diff --git a/apps/swift-ios/Features/Workspace/DailyUXModels.swift b/apps/swift-ios/Features/Workspace/DailyUXModels.swift index 3dfdad7dd4d..d7b4b8a0203 100644 --- a/apps/swift-ios/Features/Workspace/DailyUXModels.swift +++ b/apps/swift-ios/Features/Workspace/DailyUXModels.swift @@ -653,13 +653,17 @@ extension FeatureThread { state == .waitingForApproval || state == .waitingForInput || state == .failed } - func isEffectivelySettled(at now: Date) -> Bool { + var canSettle: Bool { switch state { - case .queued, .working, .monitoring, .waitingForApproval, .waitingForInput: - return false case .idle, .failed, .completed: - break + true + case .queued, .working, .monitoring, .waitingForApproval, .waitingForInput: + false } + } + + func isEffectivelySettled(at now: Date) -> Bool { + guard canSettle else { return false } if isSettled { return true } diff --git a/apps/swift-ios/Features/Workspace/HomeThreadCollectionView.swift b/apps/swift-ios/Features/Workspace/HomeThreadCollectionView.swift index d409aec9bb5..9dd434df1fd 100644 --- a/apps/swift-ios/Features/Workspace/HomeThreadCollectionView.swift +++ b/apps/swift-ios/Features/Workspace/HomeThreadCollectionView.swift @@ -22,7 +22,11 @@ enum HomeThreadSwipeActions { var kinds: [HomeThreadSwipeActionKind] = [] if thread.canToggleSettlement { - kinds.append(thread.isEffectivelySettled(at: now) ? .reopen : .settle) + if thread.isEffectivelySettled(at: now) { + kinds.append(.reopen) + } else if thread.canSettle { + kinds.append(.settle) + } } if thread.pinnedAt != nil, thread.canTogglePin { kinds.append(.unpin) @@ -457,16 +461,18 @@ struct HomeThreadCollectionView: UIViewRepresentable { } if thread.canToggleSettlement { let isSettled = thread.isEffectivelySettled(at: .now) - actions.append( - UIAction( - title: isSettled ? "Reopen" : "Settle", - image: UIImage( - systemName: isSettled ? "arrow.counterclockwise" : "checkmark" - ) - ) { [weak self] _ in - self?.parent.onSettle(thread, !isSettled) - } - ) + if isSettled || thread.canSettle { + actions.append( + UIAction( + title: isSettled ? "Reopen" : "Settle", + image: UIImage( + systemName: isSettled ? "arrow.counterclockwise" : "checkmark" + ) + ) { [weak self] _ in + self?.parent.onSettle(thread, !isSettled) + } + ) + } } if thread.canToggleSnooze { diff --git a/apps/swift-ios/Tests/FeatureTests/DailyUXSidebarTests.swift b/apps/swift-ios/Tests/FeatureTests/DailyUXSidebarTests.swift index 310efb7b70a..797714d996e 100644 --- a/apps/swift-ios/Tests/FeatureTests/DailyUXSidebarTests.swift +++ b/apps/swift-ios/Tests/FeatureTests/DailyUXSidebarTests.swift @@ -138,6 +138,31 @@ struct DailyUXSidebarTests { #expect(actions.last == .delete) } + @Test(arguments: [ + FeatureThreadState.queued, + .working, + .monitoring, + .waitingForApproval, + .waitingForInput, + ]) + func blockedThreadsDoNotOfferSettleAsAFullSwipe(state: FeatureThreadState) { + let blocked = thread( + id: state.rawValue, + created: -100, + updated: -50, + state: state + ) + + #expect(blocked.canSettle == false) + #expect( + HomeThreadSwipeActions.kinds( + for: blocked, + isArchived: false, + now: now + ) == [.archive, .delete] + ) + } + @Test func fullSwipeAlwaysChoosesAReversibleAction() { let settled = thread( From 4e387268cb54903714a999b68ffce63db73848e1 Mon Sep 17 00:00:00 2001 From: Alex Southwell Date: Thu, 13 Aug 2026 22:49:23 +1000 Subject: [PATCH 06/13] fix(ios): preserve settlement eligibility inputs --- apps/swift-ios/App/NativeFeatureClient.swift | 19 +++++ .../Features/Shared/FeatureModels.swift | 64 +++++++++++++++++ .../Features/Workspace/DailyUXModels.swift | 9 +-- .../Workspace/HomeThreadCollectionView.swift | 15 +++- .../FeatureTests/DailyUXSidebarTests.swift | 70 ++++++++++++++++++- 5 files changed, 168 insertions(+), 9 deletions(-) diff --git a/apps/swift-ios/App/NativeFeatureClient.swift b/apps/swift-ios/App/NativeFeatureClient.swift index c3449a7d7f1..b4e2c91f933 100644 --- a/apps/swift-ios/App/NativeFeatureClient.swift +++ b/apps/swift-ios/App/NativeFeatureClient.swift @@ -3788,6 +3788,15 @@ final class NativeFeatureClient: FeatureClient, FeatureDeviceManaging, supportsSnooze: environment.descriptor?.capabilities.threadSnooze, supportsPinning: environment.descriptor?.capabilities.threadPinning, supportsTitleRegeneration: environment.descriptor?.capabilities.threadTitleRegeneration, + settlementInput: FeatureSettlementProjectionInput( + hasPendingApprovals: thread.hasPendingApprovals, + hasPendingUserInput: thread.hasPendingUserInput, + sessionStatus: thread.session?.status, + latestUserMessageAt: thread.latestUserMessageAt, + latestTurnRequestedAt: thread.latestTurn?.requestedAt, + latestTurnStartedAt: thread.latestTurn?.startedAt, + latestTurnCompletedAt: thread.latestTurn?.completedAt + ), attentionAt: failureDate( latestTurn: thread.latestTurn, session: thread.session @@ -3857,6 +3866,16 @@ final class NativeFeatureClient: FeatureClient, FeatureDeviceManaging, supportsSnooze: environment.descriptor?.capabilities.threadSnooze, supportsPinning: environment.descriptor?.capabilities.threadPinning, supportsTitleRegeneration: environment.descriptor?.capabilities.threadTitleRegeneration, + settlementInput: FeatureSettlementProjectionInput( + hasPendingApprovals: false, + hasPendingUserInput: false, + sessionStatus: thread.session?.status, + latestUserMessageAt: thread.messages + .last(where: { $0.role == "user" })?.createdAt, + latestTurnRequestedAt: thread.latestTurn?.requestedAt, + latestTurnStartedAt: thread.latestTurn?.startedAt, + latestTurnCompletedAt: thread.latestTurn?.completedAt + ), attentionAt: failureDate( latestTurn: thread.latestTurn, session: thread.session diff --git a/apps/swift-ios/Features/Shared/FeatureModels.swift b/apps/swift-ios/Features/Shared/FeatureModels.swift index 97068bd6de2..46d6635f4a9 100644 --- a/apps/swift-ios/Features/Shared/FeatureModels.swift +++ b/apps/swift-ios/Features/Shared/FeatureModels.swift @@ -178,6 +178,7 @@ public struct FeatureThread: Identifiable, Sendable, Equatable, Hashable, Codabl public var supportsSnooze: Bool? public var supportsPinning: Bool? public var supportsTitleRegeneration: Bool? + public var settlementInput: FeatureSettlementProjectionInput? public var attentionAt: Date? public var workingStartedAt: Date? public var latestTurnCompletedAt: Date? @@ -213,6 +214,7 @@ public struct FeatureThread: Identifiable, Sendable, Equatable, Hashable, Codabl supportsSnooze: Bool? = nil, supportsPinning: Bool? = nil, supportsTitleRegeneration: Bool? = nil, + settlementInput: FeatureSettlementProjectionInput? = nil, attentionAt: Date? = nil, workingStartedAt: Date? = nil, latestTurnCompletedAt: Date? = nil, @@ -247,6 +249,7 @@ public struct FeatureThread: Identifiable, Sendable, Equatable, Hashable, Codabl self.supportsSnooze = supportsSnooze self.supportsPinning = supportsPinning self.supportsTitleRegeneration = supportsTitleRegeneration + self.settlementInput = settlementInput self.attentionAt = attentionAt self.workingStartedAt = workingStartedAt self.latestTurnCompletedAt = latestTurnCompletedAt @@ -269,6 +272,67 @@ public struct FeatureThread: Identifiable, Sendable, Equatable, Hashable, Codabl } } +public struct FeatureSettlementProjectionInput: Sendable, Equatable, Hashable, Codable { + public var hasPendingApprovals: Bool + public var hasPendingUserInput: Bool + public var sessionStatus: String? + public var latestUserMessageAt: String? + public var latestTurnRequestedAt: String? + public var latestTurnStartedAt: String? + public var latestTurnCompletedAt: String? + + public init( + hasPendingApprovals: Bool, + hasPendingUserInput: Bool, + sessionStatus: String?, + latestUserMessageAt: String?, + latestTurnRequestedAt: String?, + latestTurnStartedAt: String?, + latestTurnCompletedAt: String? + ) { + self.hasPendingApprovals = hasPendingApprovals + self.hasPendingUserInput = hasPendingUserInput + self.sessionStatus = sessionStatus + self.latestUserMessageAt = latestUserMessageAt + self.latestTurnRequestedAt = latestTurnRequestedAt + self.latestTurnStartedAt = latestTurnStartedAt + self.latestTurnCompletedAt = latestTurnCompletedAt + } +} + +enum FeatureSettlementProjection { + static let queuedTurnStartGrace: TimeInterval = 2 * 60 + + static func canSettle( + _ input: FeatureSettlementProjectionInput, + now: Date + ) -> Bool { + if input.hasPendingApprovals || input.hasPendingUserInput { return false } + if input.sessionStatus == "starting" || input.sessionStatus == "running" { return false } + if input.sessionStatus == "error" { return true } + guard let rawMessageAt = input.latestUserMessageAt, + let messageAt = parseDate(rawMessageAt), + abs(now.timeIntervalSince(messageAt)) <= queuedTurnStartGrace else { + return true + } + let latestTurnDates = [ + input.latestTurnRequestedAt, + input.latestTurnStartedAt, + input.latestTurnCompletedAt, + ] + let queued = latestTurnDates.allSatisfy { candidate in + guard let candidate else { return true } + guard let date = parseDate(candidate) else { return false } + return date < messageAt + } + return !queued + } + + private static func parseDate(_ value: String) -> Date? { + try? Date(value, strategy: .iso8601) + } +} + public enum FeatureMessageRole: String, Sendable, Codable { case user case assistant diff --git a/apps/swift-ios/Features/Workspace/DailyUXModels.swift b/apps/swift-ios/Features/Workspace/DailyUXModels.swift index d7b4b8a0203..be26d4c86af 100644 --- a/apps/swift-ios/Features/Workspace/DailyUXModels.swift +++ b/apps/swift-ios/Features/Workspace/DailyUXModels.swift @@ -654,12 +654,9 @@ extension FeatureThread { } var canSettle: Bool { - switch state { - case .idle, .failed, .completed: - true - case .queued, .working, .monitoring, .waitingForApproval, .waitingForInput: - false - } + isSettled || settlementInput.map { + FeatureSettlementProjection.canSettle($0, now: .now) + } == true } func isEffectivelySettled(at now: Date) -> Bool { diff --git a/apps/swift-ios/Features/Workspace/HomeThreadCollectionView.swift b/apps/swift-ios/Features/Workspace/HomeThreadCollectionView.swift index 9dd434df1fd..46b4032e4c2 100644 --- a/apps/swift-ios/Features/Workspace/HomeThreadCollectionView.swift +++ b/apps/swift-ios/Features/Workspace/HomeThreadCollectionView.swift @@ -11,6 +11,15 @@ enum HomeThreadSwipeActionKind: Equatable { } enum HomeThreadSwipeActions { + static func allowsFullSwipe( + for thread: FeatureThread, + isArchived: Bool, + now: Date + ) -> Bool { + if isArchived || !thread.canToggleSettlement { return true } + return thread.isEffectivelySettled(at: now) || thread.canSettle + } + static func kinds( for thread: FeatureThread, isArchived: Bool, @@ -239,7 +248,11 @@ struct HomeThreadCollectionView: UIViewRepresentable { let configuration = UISwipeActionsConfiguration(actions: actions) // UIKit performs actions[0] on a full swipe. The model keeps the // reversible lifecycle action first and destructive Delete last. - configuration.performsFirstActionWithFullSwipe = true + configuration.performsFirstActionWithFullSwipe = HomeThreadSwipeActions.allowsFullSwipe( + for: thread, + isArchived: isArchived, + now: .now + ) return configuration } diff --git a/apps/swift-ios/Tests/FeatureTests/DailyUXSidebarTests.swift b/apps/swift-ios/Tests/FeatureTests/DailyUXSidebarTests.swift index 797714d996e..9170cf65fc4 100644 --- a/apps/swift-ios/Tests/FeatureTests/DailyUXSidebarTests.swift +++ b/apps/swift-ios/Tests/FeatureTests/DailyUXSidebarTests.swift @@ -150,7 +150,8 @@ struct DailyUXSidebarTests { id: state.rawValue, created: -100, updated: -50, - state: state + state: state, + settlementEligible: false ) #expect(blocked.canSettle == false) @@ -161,6 +162,61 @@ struct DailyUXSidebarTests { now: now ) == [.archive, .delete] ) + #expect( + !HomeThreadSwipeActions.allowsFullSwipe( + for: blocked, + isArchived: false, + now: now + ) + ) + } + + @Test + func freshUnadoptedUserMessageBlocksSettlementProjection() { + let eligible = FeatureSettlementProjection.canSettle( + FeatureSettlementProjectionInput( + hasPendingApprovals: false, + hasPendingUserInput: false, + sessionStatus: nil, + latestUserMessageAt: now.addingTimeInterval(-30).ISO8601Format(), + latestTurnRequestedAt: nil, + latestTurnStartedAt: nil, + latestTurnCompletedAt: nil + ), + now: now + ) + + #expect(!eligible) + } + + @Test(arguments: [FeatureThreadState.working, .monitoring]) + func backgroundOnlyShellsRemainSettlementEligible(state: FeatureThreadState) { + let eligible = FeatureSettlementProjection.canSettle( + FeatureSettlementProjectionInput( + hasPendingApprovals: false, + hasPendingUserInput: false, + sessionStatus: nil, + latestUserMessageAt: now.addingTimeInterval(-300).ISO8601Format(), + latestTurnRequestedAt: nil, + latestTurnStartedAt: nil, + latestTurnCompletedAt: nil + ), + now: now + ) + let projected = thread( + id: state.rawValue, + created: -300, + updated: -300, + state: state, + settlementEligible: eligible + ) + + #expect(eligible) + #expect(projected.canSettle) + #expect( + HomeThreadSwipeActions.kinds(for: projected, isArchived: false, now: now).first + == .settle + ) } @Test @@ -529,7 +585,8 @@ struct DailyUXSidebarTests { created: TimeInterval, updated: TimeInterval, state: FeatureThreadState = .idle, - isSettled: Bool = false + isSettled: Bool = false, + settlementEligible: Bool = true ) -> FeatureThread { FeatureThread( id: id, @@ -539,6 +596,15 @@ struct DailyUXSidebarTests { updatedAt: now.addingTimeInterval(updated), state: state, isSettled: isSettled, + settlementInput: FeatureSettlementProjectionInput( + hasPendingApprovals: !settlementEligible, + hasPendingUserInput: false, + sessionStatus: nil, + latestUserMessageAt: nil, + latestTurnRequestedAt: nil, + latestTurnStartedAt: nil, + latestTurnCompletedAt: nil + ), lastActivityAt: now.addingTimeInterval(updated) ) } From 460e40e64828c63a184f97e6c847bf05c31313f0 Mon Sep 17 00:00:00 2001 From: Alex Southwell Date: Thu, 13 Aug 2026 23:02:03 +1000 Subject: [PATCH 07/13] fix(ios): retain settlement blockers in detail updates --- apps/swift-ios/App/NativeFeatureClient.swift | 21 +++++++++ .../Features/Shared/FeatureModels.swift | 7 +++ .../FeatureTests/DailyUXSidebarTests.swift | 44 +++++++++++++++++++ 3 files changed, 72 insertions(+) diff --git a/apps/swift-ios/App/NativeFeatureClient.swift b/apps/swift-ios/App/NativeFeatureClient.swift index b4e2c91f933..9f362aa2271 100644 --- a/apps/swift-ios/App/NativeFeatureClient.swift +++ b/apps/swift-ios/App/NativeFeatureClient.swift @@ -2233,6 +2233,10 @@ final class NativeFeatureClient: FeatureClient, FeatureDeviceManaging, if detail.approvals.isEmpty, detail.thread.state == .waitingForApproval { detail.thread.state = detail.userInputs.isEmpty ? .idle : .waitingForInput } + detail.thread.settlementInput = detail.thread.settlementInput?.withPendingRequests( + approvals: !detail.approvals.isEmpty, + userInput: !detail.userInputs.isEmpty + ) publish(detail, threadID: threadID) } @@ -2242,6 +2246,10 @@ final class NativeFeatureClient: FeatureClient, FeatureDeviceManaging, if detail.userInputs.isEmpty, detail.thread.state == .waitingForInput { detail.thread.state = detail.approvals.isEmpty ? .idle : .waitingForApproval } + detail.thread.settlementInput = detail.thread.settlementInput?.withPendingRequests( + approvals: !detail.approvals.isEmpty, + userInput: !detail.userInputs.isEmpty + ) publish(detail, threadID: threadID) } @@ -3389,6 +3397,15 @@ final class NativeFeatureClient: FeatureClient, FeatureDeviceManaging, hasUserInput: !detail.userInputs.isEmpty, backgroundLiveness: backgroundLiveness ) + detail.thread.settlementInput = FeatureSettlementProjectionInput( + hasPendingApprovals: !detail.approvals.isEmpty, + hasPendingUserInput: !detail.userInputs.isEmpty, + sessionStatus: shellThread.session?.status, + latestUserMessageAt: shellThread.latestUserMessageAt, + latestTurnRequestedAt: shellThread.latestTurn?.requestedAt, + latestTurnStartedAt: shellThread.latestTurn?.startedAt, + latestTurnCompletedAt: shellThread.latestTurn?.completedAt + ) detail.thread.workingStartedAt = workingStartedAt( latestTurn: shellThread.latestTurn, session: shellThread.session, @@ -3955,6 +3972,10 @@ final class NativeFeatureClient: FeatureClient, FeatureDeviceManaging, hasUserInput: !cache.userInputs.isEmpty, backgroundLiveness: backgroundLiveness ) + mappedThread.settlementInput = mappedThread.settlementInput?.withPendingRequests( + approvals: !cache.approvals.isEmpty, + userInput: !cache.userInputs.isEmpty + ) return FeatureThreadDetail( thread: mappedThread, messages: cache.mergedMessages, diff --git a/apps/swift-ios/Features/Shared/FeatureModels.swift b/apps/swift-ios/Features/Shared/FeatureModels.swift index 46d6635f4a9..567f187aa1c 100644 --- a/apps/swift-ios/Features/Shared/FeatureModels.swift +++ b/apps/swift-ios/Features/Shared/FeatureModels.swift @@ -298,6 +298,13 @@ public struct FeatureSettlementProjectionInput: Sendable, Equatable, Hashable, C self.latestTurnStartedAt = latestTurnStartedAt self.latestTurnCompletedAt = latestTurnCompletedAt } + + func withPendingRequests(approvals: Bool, userInput: Bool) -> Self { + var copy = self + copy.hasPendingApprovals = approvals + copy.hasPendingUserInput = userInput + return copy + } } enum FeatureSettlementProjection { diff --git a/apps/swift-ios/Tests/FeatureTests/DailyUXSidebarTests.swift b/apps/swift-ios/Tests/FeatureTests/DailyUXSidebarTests.swift index 9170cf65fc4..4a56e115442 100644 --- a/apps/swift-ios/Tests/FeatureTests/DailyUXSidebarTests.swift +++ b/apps/swift-ios/Tests/FeatureTests/DailyUXSidebarTests.swift @@ -219,6 +219,50 @@ struct DailyUXSidebarTests { ) } + @Test + func detailPendingRequestsKeepHomeSettlementAndFullSwipeBlocked() { + let base = FeatureSettlementProjectionInput( + hasPendingApprovals: false, + hasPendingUserInput: false, + sessionStatus: nil, + latestUserMessageAt: now.addingTimeInterval(-300).ISO8601Format(), + latestTurnRequestedAt: nil, + latestTurnStartedAt: nil, + latestTurnCompletedAt: nil + ) + var pendingApproval = thread( + id: "approval", + created: -300, + updated: -300, + settlementEligible: true + ) + pendingApproval.settlementInput = base.withPendingRequests( + approvals: true, + userInput: false + ) + var pendingInput = thread( + id: "input", + created: -300, + updated: -300, + settlementEligible: true + ) + pendingInput.settlementInput = base.withPendingRequests( + approvals: false, + userInput: true + ) + + for thread in [pendingApproval, pendingInput] { + #expect(!thread.canSettle) + #expect( + HomeThreadSwipeActions.kinds(for: thread, isArchived: false, now: now) + == [.archive, .delete] + ) + #expect( + !HomeThreadSwipeActions.allowsFullSwipe(for: thread, isArchived: false, now: now) + ) + } + } + @Test func fullSwipeAlwaysChoosesAReversibleAction() { let settled = thread( From ca1feb920eb00fad52f20bf2c6ae05a3421d48a5 Mon Sep 17 00:00:00 2001 From: Alex Southwell Date: Thu, 13 Aug 2026 23:10:41 +1000 Subject: [PATCH 08/13] fix(ios): reconcile pending settlement authority --- apps/swift-ios/App/NativeFeatureClient.swift | 36 ++++++++++++----- .../NativeMultiEnvironmentTests.swift | 39 +++++++++++++++++-- 2 files changed, 62 insertions(+), 13 deletions(-) diff --git a/apps/swift-ios/App/NativeFeatureClient.swift b/apps/swift-ios/App/NativeFeatureClient.swift index 9f362aa2271..4827a7bdfcd 100644 --- a/apps/swift-ios/App/NativeFeatureClient.swift +++ b/apps/swift-ios/App/NativeFeatureClient.swift @@ -2229,7 +2229,12 @@ final class NativeFeatureClient: FeatureClient, FeatureDeviceManaging, private func removeCachedApproval(id: String, threadID: String) { guard var detail = latestDetails[threadID] else { return } - detail.approvals.removeAll { $0.id == id } + if let cache = detailRenderCaches[threadID] { + cache.approvals.removeAll { $0.id == id } + detail.approvals = cache.approvals + } else { + detail.approvals.removeAll { $0.id == id } + } if detail.approvals.isEmpty, detail.thread.state == .waitingForApproval { detail.thread.state = detail.userInputs.isEmpty ? .idle : .waitingForInput } @@ -2242,7 +2247,12 @@ final class NativeFeatureClient: FeatureClient, FeatureDeviceManaging, private func removeCachedInput(id: String, threadID: String) { guard var detail = latestDetails[threadID] else { return } - detail.userInputs.removeAll { $0.id == id } + if let cache = detailRenderCaches[threadID] { + cache.userInputs.removeAll { $0.id == id } + detail.userInputs = cache.userInputs + } else { + detail.userInputs.removeAll { $0.id == id } + } if detail.userInputs.isEmpty, detail.thread.state == .waitingForInput { detail.thread.state = detail.approvals.isEmpty ? .idle : .waitingForApproval } @@ -3390,16 +3400,18 @@ final class NativeFeatureClient: FeatureClient, FeatureDeviceManaging, let backgroundWorkIsActive = backgroundLiveness == .working let sessionIsLive = shellThread.session?.status == "starting" || shellThread.session?.status == "running" + let hasApprovals = shellThread.hasPendingApprovals || !detail.approvals.isEmpty + let hasUserInput = shellThread.hasPendingUserInput || !detail.userInputs.isEmpty detail.thread.state = Self.resolveThreadState( latestTurn: shellThread.latestTurn, session: shellThread.session, - hasApprovals: !detail.approvals.isEmpty, - hasUserInput: !detail.userInputs.isEmpty, + hasApprovals: hasApprovals, + hasUserInput: hasUserInput, backgroundLiveness: backgroundLiveness ) detail.thread.settlementInput = FeatureSettlementProjectionInput( - hasPendingApprovals: !detail.approvals.isEmpty, - hasPendingUserInput: !detail.userInputs.isEmpty, + hasPendingApprovals: hasApprovals, + hasPendingUserInput: hasUserInput, sessionStatus: shellThread.session?.status, latestUserMessageAt: shellThread.latestUserMessageAt, latestTurnRequestedAt: shellThread.latestTurn?.requestedAt, @@ -3965,16 +3977,20 @@ final class NativeFeatureClient: FeatureClient, FeatureDeviceManaging, let backgroundWorkIsActive = backgroundLiveness == .working let sessionIsLive = thread.session?.status == "starting" || thread.session?.status == "running" + let shellThread = shellsByEnvironmentID[environment.id]?.threads + .first(where: { $0.id == thread.id }) + let hasApprovals = shellThread?.hasPendingApprovals == true || !cache.approvals.isEmpty + let hasUserInput = shellThread?.hasPendingUserInput == true || !cache.userInputs.isEmpty mappedThread.state = Self.resolveThreadState( latestTurn: thread.latestTurn, session: thread.session, - hasApprovals: !cache.approvals.isEmpty, - hasUserInput: !cache.userInputs.isEmpty, + hasApprovals: hasApprovals, + hasUserInput: hasUserInput, backgroundLiveness: backgroundLiveness ) mappedThread.settlementInput = mappedThread.settlementInput?.withPendingRequests( - approvals: !cache.approvals.isEmpty, - userInput: !cache.userInputs.isEmpty + approvals: hasApprovals, + userInput: hasUserInput ) return FeatureThreadDetail( thread: mappedThread, diff --git a/apps/swift-ios/Tests/FeatureTests/NativeMultiEnvironmentTests.swift b/apps/swift-ios/Tests/FeatureTests/NativeMultiEnvironmentTests.swift index 9a7be78c507..796c30a4774 100644 --- a/apps/swift-ios/Tests/FeatureTests/NativeMultiEnvironmentTests.swift +++ b/apps/swift-ios/Tests/FeatureTests/NativeMultiEnvironmentTests.swift @@ -137,6 +137,37 @@ final class NativeMultiEnvironmentTests: XCTestCase { await fixture.client.disconnect() } + func testEmptyDetailPreservesShellPendingSettlementBlockers() async throws { + let fixture = try await makeFixture() + defer { try? FileManager.default.removeItem(at: fixture.directory) } + await fixture.transport.setShell( + multiEnvironmentShell( + projectID: "project-one", + threadID: "thread-one", + title: "Pending work", + hasPendingApprovals: true, + hasPendingUserInput: true + ), + host: "one.example" + ) + + let snapshot = try await fixture.client.initialSnapshot() + let thread = try XCTUnwrap(snapshot.threads.first(where: { $0.wireID == "thread-one" })) + XCTAssertFalse(thread.canSettle) + + let detail = try await fixture.client.loadThread(id: thread.id) + XCTAssertEqual(detail.thread.state, .waitingForApproval) + XCTAssertFalse(detail.thread.canSettle) + XCTAssertFalse( + HomeThreadSwipeActions.allowsFullSwipe( + for: detail.thread, + isArchived: false, + now: .now + ) + ) + await fixture.client.disconnect() + } + func testSnapshotKeepsRepositoryIdentityForCrossComputerProjectGrouping() async throws { let identity = RepositoryIdentity( canonicalKey: "github.com/t3/example", @@ -812,7 +843,9 @@ private func multiEnvironmentShell( providerID: String = "codex", modelID: String = "gpt-5.6-sol", repositoryIdentity: RepositoryIdentity? = nil, - backgroundLiveness: OrchestrationBackgroundLiveness? = nil + backgroundLiveness: OrchestrationBackgroundLiveness? = nil, + hasPendingApprovals: Bool = false, + hasPendingUserInput: Bool = false ) -> OrchestrationShellSnapshot { let timestamp = "2026-07-31T12:00:00.000Z" let model = ModelSelection(instanceId: providerID, model: modelID) @@ -852,8 +885,8 @@ private func multiEnvironmentShell( pinnedAt: nil, session: nil, latestUserMessageAt: nil, - hasPendingApprovals: false, - hasPendingUserInput: false, + hasPendingApprovals: hasPendingApprovals, + hasPendingUserInput: hasPendingUserInput, hasActionableProposedPlan: false, backgroundLiveness: backgroundLiveness ), From 1df2fbde44d54af250841a0bc68abd56805a2221 Mon Sep 17 00:00:00 2001 From: Alex Southwell Date: Thu, 13 Aug 2026 23:36:29 +1000 Subject: [PATCH 09/13] fix(ios): retain shell-only settlement blockers --- apps/swift-ios/App/NativeFeatureClient.swift | 45 +++-- .../NativeMultiEnvironmentTests.swift | 173 +++++++++++++++++- 2 files changed, 203 insertions(+), 15 deletions(-) diff --git a/apps/swift-ios/App/NativeFeatureClient.swift b/apps/swift-ios/App/NativeFeatureClient.swift index 4827a7bdfcd..da2993471b0 100644 --- a/apps/swift-ios/App/NativeFeatureClient.swift +++ b/apps/swift-ios/App/NativeFeatureClient.swift @@ -2235,13 +2235,7 @@ final class NativeFeatureClient: FeatureClient, FeatureDeviceManaging, } else { detail.approvals.removeAll { $0.id == id } } - if detail.approvals.isEmpty, detail.thread.state == .waitingForApproval { - detail.thread.state = detail.userInputs.isEmpty ? .idle : .waitingForInput - } - detail.thread.settlementInput = detail.thread.settlementInput?.withPendingRequests( - approvals: !detail.approvals.isEmpty, - userInput: !detail.userInputs.isEmpty - ) + reconcilePendingRequestState(&detail, threadID: threadID) publish(detail, threadID: threadID) } @@ -2253,14 +2247,41 @@ final class NativeFeatureClient: FeatureClient, FeatureDeviceManaging, } else { detail.userInputs.removeAll { $0.id == id } } - if detail.userInputs.isEmpty, detail.thread.state == .waitingForInput { - detail.thread.state = detail.approvals.isEmpty ? .idle : .waitingForApproval + reconcilePendingRequestState(&detail, threadID: threadID) + publish(detail, threadID: threadID) + } + + private func reconcilePendingRequestState( + _ detail: inout FeatureThreadDetail, + threadID: String + ) { + let shellThread = threadEnvironmentIDs[threadID].flatMap { environmentID in + threadWireIDs[threadID].flatMap { wireID in + shellsByEnvironmentID[environmentID]?.threads.first { $0.id == wireID } + } + } + let hasApprovals = shellThread?.hasPendingApprovals == true || !detail.approvals.isEmpty + let hasUserInput = shellThread?.hasPendingUserInput == true || !detail.userInputs.isEmpty + if let shellThread { + detail.thread.state = Self.resolveThreadState( + latestTurn: shellThread.latestTurn, + session: shellThread.session, + hasApprovals: hasApprovals, + hasUserInput: hasUserInput, + backgroundLiveness: shellThread.backgroundLiveness + ) + } else if hasApprovals { + detail.thread.state = .waitingForApproval + } else if hasUserInput { + detail.thread.state = .waitingForInput + } else if detail.thread.state == .waitingForApproval + || detail.thread.state == .waitingForInput { + detail.thread.state = .idle } detail.thread.settlementInput = detail.thread.settlementInput?.withPendingRequests( - approvals: !detail.approvals.isEmpty, - userInput: !detail.userInputs.isEmpty + approvals: hasApprovals, + userInput: hasUserInput ) - publish(detail, threadID: threadID) } private func workspaceContext(route: NativeThreadRoute) throws -> ( diff --git a/apps/swift-ios/Tests/FeatureTests/NativeMultiEnvironmentTests.swift b/apps/swift-ios/Tests/FeatureTests/NativeMultiEnvironmentTests.swift index 796c30a4774..fe58f4e2a29 100644 --- a/apps/swift-ios/Tests/FeatureTests/NativeMultiEnvironmentTests.swift +++ b/apps/swift-ios/Tests/FeatureTests/NativeMultiEnvironmentTests.swift @@ -168,6 +168,112 @@ final class NativeMultiEnvironmentTests: XCTestCase { await fixture.client.disconnect() } + func testResolvingVisibleApprovalPreservesShellOnlyBlockerWhenRefreshFails() async throws { + let fixture = try await makeFixture() + defer { try? FileManager.default.removeItem(at: fixture.directory) } + await fixture.transport.setShell( + multiEnvironmentShell( + projectID: "project-one", + threadID: "thread-one", + title: "Pending approval", + hasPendingApprovals: true + ), + host: "one.example" + ) + await fixture.transport.setDetail( + multiEnvironmentDetail( + projectID: "project-one", + threadID: "thread-one", + activities: [visibleApprovalActivity()] + ), + host: "one.example" + ) + + let snapshot = try await fixture.client.initialSnapshot() + let thread = try XCTUnwrap(snapshot.threads.first(where: { $0.wireID == "thread-one" })) + let initialDetail = try await fixture.client.loadThread(id: thread.id) + let approval = try XCTUnwrap(initialDetail.approvals.first) + let events = fixture.client.events() + var iterator = events.makeAsyncIterator() + await fixture.transport.failThreadReadsAfterNextDispatch(host: "one.example") + + try await fixture.client.resolveApproval(id: approval.id, decision: .allowOnce) + var resolvedDetail: FeatureThreadDetail? + while let event = await iterator.next() { + if case let .detail(detail) = event, + detail.thread.id == thread.id, + detail.approvals.isEmpty { + resolvedDetail = detail + break + } + } + + let detail = try XCTUnwrap(resolvedDetail) + XCTAssertEqual(detail.thread.state, .waitingForApproval) + XCTAssertFalse(detail.thread.canSettle) + XCTAssertFalse( + HomeThreadSwipeActions.allowsFullSwipe( + for: detail.thread, + isArchived: false, + now: .now + ) + ) + await fixture.client.disconnect() + } + + func testResolvingVisibleInputPreservesShellOnlyBlockerWhenRefreshFails() async throws { + let fixture = try await makeFixture() + defer { try? FileManager.default.removeItem(at: fixture.directory) } + await fixture.transport.setShell( + multiEnvironmentShell( + projectID: "project-one", + threadID: "thread-one", + title: "Pending input", + hasPendingUserInput: true + ), + host: "one.example" + ) + await fixture.transport.setDetail( + multiEnvironmentDetail( + projectID: "project-one", + threadID: "thread-one", + activities: [visibleUserInputActivity()] + ), + host: "one.example" + ) + + let snapshot = try await fixture.client.initialSnapshot() + let thread = try XCTUnwrap(snapshot.threads.first(where: { $0.wireID == "thread-one" })) + let initialDetail = try await fixture.client.loadThread(id: thread.id) + let input = try XCTUnwrap(initialDetail.userInputs.first) + let events = fixture.client.events() + var iterator = events.makeAsyncIterator() + await fixture.transport.failThreadReadsAfterNextDispatch(host: "one.example") + + try await fixture.client.resolveUserInput(id: input.id, answers: [:]) + var resolvedDetail: FeatureThreadDetail? + while let event = await iterator.next() { + if case let .detail(detail) = event, + detail.thread.id == thread.id, + detail.userInputs.isEmpty { + resolvedDetail = detail + break + } + } + + let detail = try XCTUnwrap(resolvedDetail) + XCTAssertEqual(detail.thread.state, .waitingForInput) + XCTAssertFalse(detail.thread.canSettle) + XCTAssertFalse( + HomeThreadSwipeActions.allowsFullSwipe( + for: detail.thread, + isArchived: false, + now: .now + ) + ) + await fixture.client.disconnect() + } + func testSnapshotKeepsRepositoryIdentityForCrossComputerProjectGrouping() async throws { let identity = RepositoryIdentity( canonicalKey: "github.com/t3/example", @@ -717,8 +823,11 @@ private struct MultiEnvironmentFixture { private actor MultiEnvironmentHTTPTransport: HTTPTransport { private let shells: [String: OrchestrationShellSnapshot] private var shellData: [String: Data] + private var detailData: [String: Data] = [:] private var reachableHosts: Set private var shellReadsEnabledHosts: Set + private var threadReadsEnabledHosts: Set + private var hostsFailingThreadReadsAfterDispatch = Set() private var dispatched: [MultiEnvironmentDispatchRecord] = [] private var hostsDroppingNextCreateReply = Set() @@ -727,6 +836,7 @@ private actor MultiEnvironmentHTTPTransport: HTTPTransport { shellData = shells.mapValues { try! JSONEncoder.t3.encode($0) } reachableHosts = Set(shells.keys) shellReadsEnabledHosts = Set(shells.keys) + threadReadsEnabledHosts = Set(shells.keys) } func setReachable(_ reachable: Bool, host: String) { @@ -749,6 +859,14 @@ private actor MultiEnvironmentHTTPTransport: HTTPTransport { shellData[host] = try! JSONEncoder.t3.encode(shell) } + func setDetail(_ detail: OrchestrationThreadDetailSnapshot, host: String) { + detailData[host] = try! JSONEncoder.t3.encode(detail) + } + + func failThreadReadsAfterNextDispatch(host: String) { + hostsFailingThreadReadsAfterDispatch.insert(host) + } + func dispatchHosts() -> [String] { dispatched.map(\.host) } @@ -772,7 +890,11 @@ private actor MultiEnvironmentHTTPTransport: HTTPTransport { let data = shellData[host] { return (data, multiEnvironmentResponse(request)) } - if path.hasPrefix("/api/orchestration/threads/") { + if path.hasPrefix("/api/orchestration/threads/"), + threadReadsEnabledHosts.contains(host) { + if let detail = detailData[host] { + return (detail, multiEnvironmentResponse(request)) + } let threadID = request.url?.lastPathComponent.removingPercentEncoding ?? "thread" let projectID = shells[host]?.threads .first(where: { $0.id == threadID })? @@ -790,6 +912,9 @@ private actor MultiEnvironmentHTTPTransport: HTTPTransport { dispatched.append( MultiEnvironmentDispatchRecord(host: host, command: command) ) + if hostsFailingThreadReadsAfterDispatch.remove(host) != nil { + threadReadsEnabledHosts.remove(host) + } if command["type"]?.stringValue == "thread.create", hostsDroppingNextCreateReply.remove(host) != nil, let projectID = command["projectId"]?.stringValue, @@ -897,7 +1022,8 @@ private func multiEnvironmentShell( private func multiEnvironmentDetail( projectID: String, - threadID: String + threadID: String, + activities: [OrchestrationActivity] = [] ) -> OrchestrationThreadDetailSnapshot { let timestamp = "2026-07-31T12:00:00.000Z" return OrchestrationThreadDetailSnapshot( @@ -922,13 +1048,54 @@ private func multiEnvironmentDetail( pinnedAt: nil, deletedAt: nil, messages: [], - activities: [], + activities: activities, checkpoints: [], session: nil ) ) } +private func visibleApprovalActivity() -> OrchestrationActivity { + OrchestrationActivity( + id: "activity-approval", + tone: "warning", + kind: "approval.requested", + summary: "Approve command", + payload: .object([ + "requestId": .string("approval-visible"), + "requestKind": .string("command"), + "detail": .string("Run focused tests"), + ]), + turnId: "turn-one", + sequence: 1, + createdAt: "2026-07-31T12:00:00.000Z" + ) +} + +private func visibleUserInputActivity() -> OrchestrationActivity { + OrchestrationActivity( + id: "activity-input", + tone: "warning", + kind: "user-input.requested", + summary: "Choose an option", + payload: .object([ + "requestId": .string("input-visible"), + "questions": .array([ + .object([ + "id": .string("choice"), + "header": .string("Choice"), + "question": .string("Continue?"), + "options": .array([]), + "multiSelect": .bool(false), + ]), + ]), + ]), + turnId: "turn-one", + sequence: 1, + createdAt: "2026-07-31T12:00:00.000Z" + ) +} + private func multiEnvironmentResponse(_ request: URLRequest) -> HTTPURLResponse { HTTPURLResponse( url: request.url!, From e2ea292e186f2dd76ac717a3e2475082c91c817c Mon Sep 17 00:00:00 2001 From: Alex Southwell Date: Fri, 14 Aug 2026 01:47:45 +1000 Subject: [PATCH 10/13] fix(swift-ios): preserve automatic settlement blockers --- apps/swift-ios/Features/Workspace/DailyUXModels.swift | 6 ++++++ apps/swift-ios/Tests/FeatureTests/DailyUXSidebarTests.swift | 4 ++-- 2 files changed, 8 insertions(+), 2 deletions(-) diff --git a/apps/swift-ios/Features/Workspace/DailyUXModels.swift b/apps/swift-ios/Features/Workspace/DailyUXModels.swift index be26d4c86af..8b60bce1fb5 100644 --- a/apps/swift-ios/Features/Workspace/DailyUXModels.swift +++ b/apps/swift-ios/Features/Workspace/DailyUXModels.swift @@ -660,6 +660,12 @@ extension FeatureThread { } func isEffectivelySettled(at now: Date) -> Bool { + switch state { + case .queued, .working, .monitoring, .waitingForApproval, .waitingForInput: + return false + case .idle, .failed, .completed: + break + } guard canSettle else { return false } if isSettled { return true diff --git a/apps/swift-ios/Tests/FeatureTests/DailyUXSidebarTests.swift b/apps/swift-ios/Tests/FeatureTests/DailyUXSidebarTests.swift index 4a56e115442..97e905c2f58 100644 --- a/apps/swift-ios/Tests/FeatureTests/DailyUXSidebarTests.swift +++ b/apps/swift-ios/Tests/FeatureTests/DailyUXSidebarTests.swift @@ -640,6 +640,7 @@ struct DailyUXSidebarTests { updatedAt: now.addingTimeInterval(updated), state: state, isSettled: isSettled, + lastActivityAt: now.addingTimeInterval(updated), settlementInput: FeatureSettlementProjectionInput( hasPendingApprovals: !settlementEligible, hasPendingUserInput: false, @@ -648,8 +649,7 @@ struct DailyUXSidebarTests { latestTurnRequestedAt: nil, latestTurnStartedAt: nil, latestTurnCompletedAt: nil - ), - lastActivityAt: now.addingTimeInterval(updated) + ) ) } } From 7388b78392d6e6d80499c06c24e6a17d55b8de8a Mon Sep 17 00:00:00 2001 From: Alex Southwell Date: Fri, 14 Aug 2026 01:32:39 +1000 Subject: [PATCH 11/13] fix(swift-ios): preserve provider catalog fallback --- apps/swift-ios/Features/Workspace/DailyUXModels.swift | 9 ++++++++- apps/swift-ios/Features/Workspace/WorkspaceView.swift | 9 +++++---- 2 files changed, 13 insertions(+), 5 deletions(-) diff --git a/apps/swift-ios/Features/Workspace/DailyUXModels.swift b/apps/swift-ios/Features/Workspace/DailyUXModels.swift index 8b60bce1fb5..2955088124d 100644 --- a/apps/swift-ios/Features/Workspace/DailyUXModels.swift +++ b/apps/swift-ios/Features/Workspace/DailyUXModels.swift @@ -646,7 +646,14 @@ extension FeatureThread { return providerName } guard let providerID else { return nil } - return snapshot.providers.first(where: { $0.id == providerID })?.name ?? providerID + let projectEnvironmentID = snapshot.projects + .first(where: { $0.id == projectID })? + .environmentID + let resolvedEnvironmentID = environmentID ?? projectEnvironmentID + let providers = resolvedEnvironmentID.flatMap { + snapshot.providersByEnvironment?[$0] + } ?? snapshot.providers + return providers.first(where: { $0.id == providerID })?.name ?? providerID } var needsAttention: Bool { diff --git a/apps/swift-ios/Features/Workspace/WorkspaceView.swift b/apps/swift-ios/Features/Workspace/WorkspaceView.swift index 910c0c6c64c..e98e1a0644c 100644 --- a/apps/swift-ios/Features/Workspace/WorkspaceView.swift +++ b/apps/swift-ios/Features/Workspace/WorkspaceView.swift @@ -812,9 +812,6 @@ struct HomeThreadRowContext: Equatable { let environmentByID = snapshot.environments.reduce(into: [String: FeatureEnvironment]()) { $0[$1.id] = $1 } - let providerByID = snapshot.providers.reduce(into: [String: FeatureProvider]()) { - $0[$1.id] = $1 - } let activeEnvironmentID = snapshot.environments.first(where: \.isActive)?.id return snapshot.threads.reduce(into: [String: HomeThreadRowContext]()) { result, thread in @@ -825,7 +822,11 @@ struct HomeThreadRowContext: Equatable { .trimmingCharacters(in: .whitespacesAndNewlines) let explicitProvider = thread.providerName? .trimmingCharacters(in: .whitespacesAndNewlines) - let configuredProvider = thread.providerID.flatMap { providerByID[$0] } + let configuredProvider = thread.providerID.flatMap { providerID in + environmentID.flatMap { + snapshot.providersByEnvironment?[$0]?.first(where: { $0.id == providerID }) + } ?? snapshot.providers.first(where: { $0.id == providerID }) + } let providerName = (explicitProvider?.isEmpty == false ? explicitProvider : nil) ?? configuredProvider?.name ?? thread.providerID From 3e251413271ca2f6a65449894c6f03f1025265db Mon Sep 17 00:00:00 2001 From: Alex Southwell Date: Fri, 14 Aug 2026 02:39:38 +1000 Subject: [PATCH 12/13] fix(swift-ios): close settlement swipe review gaps --- .../Features/Shared/FeatureModels.swift | 10 +++++- .../Workspace/HomeThreadCollectionView.swift | 6 ++-- .../FeatureTests/DailyUXSidebarTests.swift | 36 +++++++++++++++++++ 3 files changed, 49 insertions(+), 3 deletions(-) diff --git a/apps/swift-ios/Features/Shared/FeatureModels.swift b/apps/swift-ios/Features/Shared/FeatureModels.swift index 567f187aa1c..f8eee102e80 100644 --- a/apps/swift-ios/Features/Shared/FeatureModels.swift +++ b/apps/swift-ios/Features/Shared/FeatureModels.swift @@ -336,8 +336,16 @@ enum FeatureSettlementProjection { } private static func parseDate(_ value: String) -> Date? { - try? Date(value, strategy: .iso8601) + fractionalDateFormatter.date(from: value) ?? dateFormatter.date(from: value) } + + private static let fractionalDateFormatter: ISO8601DateFormatter = { + let formatter = ISO8601DateFormatter() + formatter.formatOptions = [.withInternetDateTime, .withFractionalSeconds] + return formatter + }() + + private static let dateFormatter = ISO8601DateFormatter() } public enum FeatureMessageRole: String, Sendable, Codable { diff --git a/apps/swift-ios/Features/Workspace/HomeThreadCollectionView.swift b/apps/swift-ios/Features/Workspace/HomeThreadCollectionView.swift index 46b4032e4c2..57aeb44af25 100644 --- a/apps/swift-ios/Features/Workspace/HomeThreadCollectionView.swift +++ b/apps/swift-ios/Features/Workspace/HomeThreadCollectionView.swift @@ -16,8 +16,10 @@ enum HomeThreadSwipeActions { isArchived: Bool, now: Date ) -> Bool { - if isArchived || !thread.canToggleSettlement { return true } - return thread.isEffectivelySettled(at: now) || thread.canSettle + switch kinds(for: thread, isArchived: isArchived, now: now).first { + case .restore, .unpin, .settle, .reopen: true + case .archive, .delete, nil: false + } } static func kinds( diff --git a/apps/swift-ios/Tests/FeatureTests/DailyUXSidebarTests.swift b/apps/swift-ios/Tests/FeatureTests/DailyUXSidebarTests.swift index 97e905c2f58..058d0f864b1 100644 --- a/apps/swift-ios/Tests/FeatureTests/DailyUXSidebarTests.swift +++ b/apps/swift-ios/Tests/FeatureTests/DailyUXSidebarTests.swift @@ -189,6 +189,24 @@ struct DailyUXSidebarTests { #expect(!eligible) } + @Test + func fractionalSecondUserMessageBlocksSettlementProjection() { + let eligible = FeatureSettlementProjection.canSettle( + FeatureSettlementProjectionInput( + hasPendingApprovals: false, + hasPendingUserInput: false, + sessionStatus: nil, + latestUserMessageAt: "1970-01-24T03:32:50.000Z", + latestTurnRequestedAt: nil, + latestTurnStartedAt: nil, + latestTurnCompletedAt: nil + ), + now: now + ) + + #expect(!eligible) + } + @Test(arguments: [FeatureThreadState.working, .monitoring]) func backgroundOnlyShellsRemainSettlementEligible(state: FeatureThreadState) { let eligible = FeatureSettlementProjection.canSettle( @@ -300,6 +318,24 @@ struct DailyUXSidebarTests { #expect(explicitlyUnsupported.canTogglePin) } + @Test + func pinnedBlockedThreadCanFullSwipeToUnpin() { + let pinned = thread( + id: "pinned-blocked", + created: -20, + updated: -10, + state: .queued, + pinned: -5, + settlementEligible: false + ) + + #expect( + HomeThreadSwipeActions.kinds(for: pinned, isArchived: false, now: now).first + == .unpin + ) + #expect(HomeThreadSwipeActions.allowsFullSwipe(for: pinned, isArchived: false, now: now)) + } + @Test func lifecycleActionsHonorCapabilitiesAndKeepReverseActionsReachable() { var capabilityThread = thread(id: "capabilities", created: -20, updated: -10) From bb748e66fe531808f5cd8d5f7641e2b7e69a68f4 Mon Sep 17 00:00:00 2001 From: Alex Southwell Date: Fri, 14 Aug 2026 03:11:30 +1000 Subject: [PATCH 13/13] fix(swift-ios): keep queued turns unsettled --- .../Features/Shared/FeatureModels.swift | 20 ++++++--------- .../FeatureTests/DailyUXSidebarTests.swift | 25 +++++++++++++++++++ 2 files changed, 33 insertions(+), 12 deletions(-) diff --git a/apps/swift-ios/Features/Shared/FeatureModels.swift b/apps/swift-ios/Features/Shared/FeatureModels.swift index 9028e59698f..ba95084c5f7 100644 --- a/apps/swift-ios/Features/Shared/FeatureModels.swift +++ b/apps/swift-ios/Features/Shared/FeatureModels.swift @@ -357,21 +357,17 @@ enum FeatureSettlementProjection { if input.sessionStatus == "starting" || input.sessionStatus == "running" { return false } if input.sessionStatus == "error" { return true } guard let rawMessageAt = input.latestUserMessageAt, - let messageAt = parseDate(rawMessageAt), - abs(now.timeIntervalSince(messageAt)) <= queuedTurnStartGrace else { + let messageAt = parseDate(rawMessageAt) else { return true } - let latestTurnDates = [ - input.latestTurnRequestedAt, - input.latestTurnStartedAt, - input.latestTurnCompletedAt, - ] - let queued = latestTurnDates.allSatisfy { candidate in - guard let candidate else { return true } - guard let date = parseDate(candidate) else { return false } - return date < messageAt + let messageAge = now.timeIntervalSince(messageAt) + if messageAge < 0 { return false } + if messageAge > queuedTurnStartGrace { return true } + if let completedAt = input.latestTurnCompletedAt.flatMap(parseDate), + completedAt >= messageAt { + return true } - return !queued + return false } private static func parseDate(_ value: String) -> Date? { diff --git a/apps/swift-ios/Tests/FeatureTests/DailyUXSidebarTests.swift b/apps/swift-ios/Tests/FeatureTests/DailyUXSidebarTests.swift index 44b5d2de0e1..fef279db9fa 100644 --- a/apps/swift-ios/Tests/FeatureTests/DailyUXSidebarTests.swift +++ b/apps/swift-ios/Tests/FeatureTests/DailyUXSidebarTests.swift @@ -207,6 +207,31 @@ struct DailyUXSidebarTests { #expect(!eligible) } + @Test + func queuedTurnAndFutureMessageBlockSettlementProjection() { + let queued = FeatureSettlementProjectionInput( + hasPendingApprovals: false, + hasPendingUserInput: false, + sessionStatus: nil, + latestUserMessageAt: now.addingTimeInterval(-30).ISO8601Format(), + latestTurnRequestedAt: now.addingTimeInterval(-20).ISO8601Format(), + latestTurnStartedAt: nil, + latestTurnCompletedAt: nil + ) + let future = FeatureSettlementProjectionInput( + hasPendingApprovals: false, + hasPendingUserInput: false, + sessionStatus: nil, + latestUserMessageAt: now.addingTimeInterval(300).ISO8601Format(), + latestTurnRequestedAt: nil, + latestTurnStartedAt: nil, + latestTurnCompletedAt: nil + ) + + #expect(!FeatureSettlementProjection.canSettle(queued, now: now)) + #expect(!FeatureSettlementProjection.canSettle(future, now: now)) + } + @Test(arguments: [FeatureThreadState.working, .monitoring]) func backgroundOnlyShellsRemainSettlementEligible(state: FeatureThreadState) { let eligible = FeatureSettlementProjection.canSettle(