diff --git a/apps/swift-ios/App/NativeFeatureClient.swift b/apps/swift-ios/App/NativeFeatureClient.swift index 592636e65c80..d724697ce402 100644 --- a/apps/swift-ios/App/NativeFeatureClient.swift +++ b/apps/swift-ios/App/NativeFeatureClient.swift @@ -1514,10 +1514,56 @@ final class NativeFeatureClient: FeatureClient, FeatureDeviceManaging, try? await refresh(client: route.client) } - func regenerateThreadTitle(id: String) async throws { + func regenerateThreadTitle( + id: String, + requestID: String + ) async throws -> FeatureTitleRegenerationDispatchReceipt { let route = try threadRoute(for: id) - _ = try await route.client.regenerateTitle(threadID: route.wireID) - try? await refresh(client: route.client) + let previousThread = cachedThread(id: route.uiID) + let previousTitle = previousThread?.title + let dispatch = try await route.client.regenerateTitle( + threadID: route.wireID, + commandID: requestID + ) + do { + try await refresh( + client: route.client, + includeArchived: previousThread?.isArchived == true + ) + return Self.titleRegenerationDispatchReceipt( + previousTitle: previousTitle, + dispatchRequestID: requestID, + dispatchSequence: dispatch.sequence, + refreshedSequence: shellsByEnvironmentID[route.environmentID]?.snapshotSequence, + refreshedThread: cachedThread(id: route.uiID) + ) + } catch { + return .refreshUnavailable + } + } + + static func titleRegenerationDispatchReceipt( + previousTitle: String?, + dispatchRequestID: String, + dispatchSequence: Int, + refreshedSequence: Int?, + refreshedThread: FeatureThread? + ) -> FeatureTitleRegenerationDispatchReceipt { + guard let previousTitle, + let refreshedSequence, + refreshedSequence >= dispatchSequence, + let refreshedThread else { return .refreshUnavailable } + if refreshedThread.isRegeneratingTitle == true { + // A different request id means another client's regeneration + // replaced this dispatch; the server will never apply ours. + return refreshedThread.titleRegenerationRequestID == dispatchRequestID + ? .regenerating + : .failed + } + guard refreshedThread.title == previousTitle else { + return .completed(title: refreshedThread.title) + } + return .failed } func setThreadArchived(id: String, archived: Bool) async throws { @@ -2626,6 +2672,7 @@ final class NativeFeatureClient: FeatureClient, FeatureDeviceManaging, snoozedUntil: thread.snoozedUntil, snoozedAt: thread.snoozedAt, pinnedAt: thread.pinnedAt, + titleRegeneration: thread.titleRegeneration, session: thread.session, latestUserMessageAt: thread.latestUserMessageAt, hasPendingApprovals: thread.hasPendingApprovals, @@ -3835,6 +3882,17 @@ final class NativeFeatureClient: FeatureClient, FeatureDeviceManaging, let backgroundWorkIsActive = backgroundLiveness == .working let sessionIsLive = shellThread.session?.status == "starting" || shellThread.session?.status == "running" + let shellIsCurrent = shell.snapshotSequence >= (activeThreadSequence ?? .min) + // The shell is the authority for title metadata; the cached detail + // keeps whatever the last detail fetch saw. Without this copy every + // shell snapshot would republish a stale pre-regeneration title and + // revert a freshly regenerated one. A stale shell snapshot must not + // overwrite newer detail-stream metadata. + if shellIsCurrent { + detail.thread.title = shellThread.title + detail.thread.isRegeneratingTitle = shellThread.titleRegeneration != nil + detail.thread.titleRegenerationRequestID = shellThread.titleRegeneration?.requestId + } detail.thread.state = Self.resolveThreadState( latestTurn: shellThread.latestTurn, session: shellThread.session, @@ -3848,7 +3906,7 @@ final class NativeFeatureClient: FeatureClient, FeatureDeviceManaging, backgroundWorkIsActive: backgroundWorkIsActive, fallbackUpdatedAt: shellThread.updatedAt ) - if shell.snapshotSequence >= (activeThreadSequence ?? .min) { + if shellIsCurrent { applySettlementAuthority(from: shellThread, to: &detail.thread) } detail.backgroundWorkIsActive = backgroundWorkIsActive @@ -4260,6 +4318,8 @@ final class NativeFeatureClient: FeatureClient, FeatureDeviceManaging, supportsPinning: environment.descriptor?.capabilities.threadPinning, supportsTitleRegeneration: environment.descriptor?.capabilities.threadTitleRegeneration, supportsPullRequestLinking: environment.descriptor?.capabilities.threadPullRequestLinking, + isRegeneratingTitle: thread.titleRegeneration != nil, + titleRegenerationRequestID: thread.titleRegeneration?.requestId, attentionAt: failureDate( latestTurn: thread.latestTurn, session: thread.session @@ -4341,6 +4401,8 @@ final class NativeFeatureClient: FeatureClient, FeatureDeviceManaging, supportsPinning: environment.descriptor?.capabilities.threadPinning, supportsTitleRegeneration: environment.descriptor?.capabilities.threadTitleRegeneration, supportsPullRequestLinking: environment.descriptor?.capabilities.threadPullRequestLinking, + isRegeneratingTitle: thread.titleRegeneration != nil, + titleRegenerationRequestID: thread.titleRegeneration?.requestId, attentionAt: failureDate( latestTurn: thread.latestTurn, session: thread.session @@ -4624,6 +4686,7 @@ final class NativeFeatureClient: FeatureClient, FeatureDeviceManaging, snoozedUntil: loaded.snoozedUntil, snoozedAt: loaded.snoozedAt, pinnedAt: loaded.pinnedAt, + titleRegeneration: loaded.titleRegeneration, deletedAt: loaded.deletedAt, messages: prependByID(older.messages, loaded.messages), activities: prependByID(older.activities, loaded.activities), @@ -6339,6 +6402,7 @@ enum NativeThreadDetailReducer { snoozedUntil: thread.snoozedUntil, snoozedAt: thread.snoozedAt, pinnedAt: thread.pinnedAt, + titleRegeneration: thread.titleRegeneration, deletedAt: thread.deletedAt, messages: messages ?? thread.messages, activities: activities ?? thread.activities, diff --git a/apps/swift-ios/Core/Models.swift b/apps/swift-ios/Core/Models.swift index c314dfc4ce51..40d38402317d 100644 --- a/apps/swift-ios/Core/Models.swift +++ b/apps/swift-ios/Core/Models.swift @@ -413,6 +413,11 @@ public struct ThreadLinkedPullRequest: Codable, Equatable, Hashable, Sendable { } } +public struct ThreadTitleRegeneration: Codable, Equatable, Sendable { + public let requestId: String + public let startedAt: String +} + public struct OrchestrationThreadShell: Codable, Identifiable, Equatable, Sendable { public let id: String public let projectId: String @@ -433,6 +438,7 @@ public struct OrchestrationThreadShell: Codable, Identifiable, Equatable, Sendab public let snoozedUntil: String? public let snoozedAt: String? public let pinnedAt: String? + public var titleRegeneration: ThreadTitleRegeneration? = nil public let session: OrchestrationSession? public let latestUserMessageAt: String? public let hasPendingApprovals: Bool @@ -508,6 +514,7 @@ public struct OrchestrationThread: Codable, Identifiable, Equatable, Sendable { public let snoozedUntil: String? public let snoozedAt: String? public let pinnedAt: String? + public var titleRegeneration: ThreadTitleRegeneration? = nil public let deletedAt: String? @ForwardCompatibleArray public var messages: [OrchestrationMessage] @ForwardCompatibleArray public var activities: [OrchestrationActivity] diff --git a/apps/swift-ios/Core/T3Client.swift b/apps/swift-ios/Core/T3Client.swift index a49ac4597744..712bb19c1197 100644 --- a/apps/swift-ios/Core/T3Client.swift +++ b/apps/swift-ios/Core/T3Client.swift @@ -557,8 +557,13 @@ public actor T3Client { } @discardableResult - public func regenerateTitle(threadID: String) async throws -> DispatchResult { - try await dispatch(OrchestrationCommands.regenerateTitle(threadID: threadID)) + public func regenerateTitle( + threadID: String, + commandID: String = UUID().uuidString + ) async throws -> DispatchResult { + try await dispatch( + OrchestrationCommands.regenerateTitle(threadID: threadID, commandID: commandID) + ) } @discardableResult diff --git a/apps/swift-ios/Features/Chat/ThreadDetailView.swift b/apps/swift-ios/Features/Chat/ThreadDetailView.swift index 9131c0b41c86..d9dbc81f0667 100644 --- a/apps/swift-ios/Features/Chat/ThreadDetailView.swift +++ b/apps/swift-ios/Features/Chat/ThreadDetailView.swift @@ -286,7 +286,11 @@ public struct ThreadDetailView: View { } private var threadActionsMenu: some View { - Menu { + let titleRegenerationMenuState = ThreadTitleRegenerationMenuState.resolve( + thread: currentThread, + regeneratingThreadIDs: model.regeneratingTitleThreadIDs + ) + return Menu { Section("Thread") { if let pullRequest = currentPullRequest { Button { @@ -295,12 +299,20 @@ public struct ThreadDetailView: View { Label("Open pull request #\(pullRequest.number)", systemImage: "arrow.triangle.pull") } } - if currentThread.supportsTitleRegeneration == true { + switch titleRegenerationMenuState { + case .hidden: + EmptyView() + case .available, .regenerating: + let isRegenerating = titleRegenerationMenuState == .regenerating Button { Task { await model.regenerateThreadTitle(thread.id) } } label: { - Label("Regenerate title", systemImage: "sparkles") + Label( + isRegenerating ? "Regenerating…" : "Regenerate title", + systemImage: "sparkles" + ) } + .disabled(isRegenerating) } if currentThread.canTogglePin, !currentThread.isArchived { Button { diff --git a/apps/swift-ios/Features/Root/FeatureRootModel.swift b/apps/swift-ios/Features/Root/FeatureRootModel.swift index 132cdc36a271..3fc79881c06b 100644 --- a/apps/swift-ios/Features/Root/FeatureRootModel.swift +++ b/apps/swift-ios/Features/Root/FeatureRootModel.swift @@ -1,5 +1,6 @@ import Foundation import Observation +import UIKit private struct FeatureConnectionUnavailableError: LocalizedError { var errorDescription: String? { @@ -23,6 +24,125 @@ enum FeatureThreadLoadState: Equatable { case failed(String) } +private struct FeatureTitleRegenerationTracker { + enum Resolution: Equatable { + case completed(title: String) + case failed(title: String) + case cancelled + } + + private struct Pending { + let projectID: String + let environmentID: String? + let requestID: String + let originalTitle: String + var observedServerRequest = false + } + + private var pendingByThreadID: [String: Pending] = [:] + + var threadIDs: Set { Set(pendingByThreadID.keys) } + var observedServerRequestThreadIDs: Set { + Set(pendingByThreadID.compactMap { $0.value.observedServerRequest ? $0.key : nil }) + } + + mutating func begin(_ thread: FeatureThread, requestID: String) -> Bool { + guard pendingByThreadID[thread.id] == nil, thread.isRegeneratingTitle != true else { + return false + } + pendingByThreadID[thread.id] = Pending( + projectID: thread.projectID, + environmentID: thread.environmentID, + requestID: requestID, + originalTitle: thread.title + ) + return true + } + + mutating func cancel(threadID: String) { + pendingByThreadID.removeValue(forKey: threadID) + } + + mutating func expire(threadID: String) -> Resolution? { + guard let pending = pendingByThreadID[threadID], !pending.observedServerRequest else { + return nil + } + pendingByThreadID.removeValue(forKey: threadID) + return .failed(title: pending.originalTitle) + } + + mutating func finishDispatch( + threadID: String, + receipt: FeatureTitleRegenerationDispatchReceipt + ) -> Resolution? { + guard var pending = pendingByThreadID[threadID] else { return nil } + switch receipt { + case let .completed(title): + pendingByThreadID.removeValue(forKey: threadID) + return title == pending.originalTitle + ? .failed(title: pending.originalTitle) + : .completed(title: title) + case .failed: + pendingByThreadID.removeValue(forKey: threadID) + return .failed(title: pending.originalTitle) + case .regenerating: + pending.observedServerRequest = true + case .refreshUnavailable: + break + } + pendingByThreadID[threadID] = pending + return nil + } + + mutating func reconcile(thread: FeatureThread) -> Resolution? { + guard var pending = pendingByThreadID[thread.id] else { return nil } + guard thread.projectID == pending.projectID, + thread.environmentID == pending.environmentID else { + pendingByThreadID.removeValue(forKey: thread.id) + return .cancelled + } + if thread.isRegeneratingTitle == true { + guard thread.titleRegenerationRequestID == pending.requestID else { + // Another client's request replaced this dispatch server-side; + // ours can no longer produce a title, so stop tracking it + // without claiming an outcome. + pendingByThreadID.removeValue(forKey: thread.id) + return .cancelled + } + pending.observedServerRequest = true + pendingByThreadID[thread.id] = pending + return nil + } + guard pending.observedServerRequest else { + return nil + } + guard thread.title == pending.originalTitle else { + pendingByThreadID.removeValue(forKey: thread.id) + return .completed(title: thread.title) + } + pendingByThreadID.removeValue(forKey: thread.id) + return .failed(title: pending.originalTitle) + } + + mutating func reconcile(with threads: [FeatureThread]) -> [Resolution] { + let threadsByID = Dictionary( + threads.map { ($0.id, $0) }, + uniquingKeysWith: { first, _ in first } + ) + var resolutions: [Resolution] = [] + for threadID in Array(pendingByThreadID.keys) { + guard let thread = threadsByID[threadID] else { + pendingByThreadID.removeValue(forKey: threadID) + continue + } + if let resolution = reconcile(thread: thread) { + resolutions.append(resolution) + } + } + return resolutions + } +} + @MainActor @Observable public final class FeatureRootModel { @@ -66,6 +186,14 @@ public final class FeatureRootModel { private(set) var isSigningOutT3Connect = false public var errorMessage: String? + var regeneratingTitleThreadIDs: Set { + snapshot.threads.reduce(into: titleRegenerationTracker.threadIDs) { ids, thread in + if thread.isRegeneratingTitle == true { + ids.insert(thread.id) + } + } + } + let client: any FeatureClient private let outboxStore: FeatureOutboxStore private let draftStore: FeatureComposerDraftStore @@ -84,15 +212,30 @@ public final class FeatureRootModel { private var outboxDrainTask: Task? private var outboxRetryAttempt = 0 private var outboxGeneration: UInt64 = 0 + private var titleRegenerationTracker = FeatureTitleRegenerationTracker() + /// In-flight regeneration dispatches, so a rename can wait for the server + /// to register the request before it sends the title that cancels it. + private var titleRegenerationTasks: [String: Task] = [:] + private var titleRegenerationTaskGenerations: [String: UInt64] = [:] + private var titleRegenerationRecoveryTasks: [String: Task] = [:] + private let titleRegenerationRefreshTimeout: Duration + private let accessibilityAnnouncer: @MainActor (String) -> Void public init( client: any FeatureClient, outboxStore: FeatureOutboxStore = .shared, - draftStore: FeatureComposerDraftStore = .shared + draftStore: FeatureComposerDraftStore = .shared, + titleRegenerationRefreshTimeout: Duration = .seconds(60), + accessibilityAnnouncer: @escaping @MainActor (String) -> Void = { message in + guard UIAccessibility.isVoiceOverRunning else { return } + UIAccessibility.post(notification: .announcement, argument: message) + } ) { self.client = client self.outboxStore = outboxStore self.draftStore = draftStore + self.titleRegenerationRefreshTimeout = titleRegenerationRefreshTimeout + self.accessibilityAnnouncer = accessibilityAnnouncer } public func start() async { @@ -408,17 +551,87 @@ public final class FeatureRootModel { } public func renameThread(_ id: String, title: String) async { + // The server cancels a pending regeneration when a rename lands, but + // only for requests it has already registered. A rename dispatched + // while the regeneration command is still in flight can be processed + // first and leave the regeneration pending behind the user's manual + // title. Let the dispatch settle so the server-side cancellation + // always engages. + await titleRegenerationTasks[id]?.value let environment = currentEnvironmentIdentity - await perform { + let renamed = await perform { try await client.renameThread(id: id, title: title) guard currentEnvironmentIdentity == environment else { return } mutateThread(id: id) { $0.title = title } } + if renamed { + titleRegenerationTracker.cancel(threadID: id) + cancelTitleRegenerationRecovery(threadID: id) + } } public func regenerateThreadTitle(_ id: String) async { - await perform { - try await client.regenerateThreadTitle(id: id) + guard let thread = snapshot.threads.first(where: { $0.id == id }) else { + errorMessage = "This thread is no longer available." + return + } + guard thread.supportsTitleRegeneration == true else { + errorMessage = "Title regeneration is not available for this thread." + return + } + let requestID = UUID().uuidString + guard titleRegenerationTracker.begin(thread, requestID: requestID) else { return } + cancelTitleRegenerationRecovery(threadID: id) + accessibilityAnnouncer("Regenerating title for \(thread.title).") + + let generation = titleRegenerationTaskGenerations[id, default: 0] &+ 1 + titleRegenerationTaskGenerations[id] = generation + let task = Task { [weak self] in + guard let self else { return } + await self.dispatchTitleRegeneration( + threadID: id, + threadTitle: thread.title, + requestID: requestID + ) + } + titleRegenerationTasks[id] = task + await task.value + if titleRegenerationTaskGenerations[id] == generation { + titleRegenerationTasks[id] = nil + } + } + + private func dispatchTitleRegeneration( + threadID: String, + threadTitle: String, + requestID: String + ) async { + do { + let receipt = try await client.regenerateThreadTitle(id: threadID, requestID: requestID) + if let resolution = titleRegenerationTracker.finishDispatch( + threadID: threadID, + receipt: receipt + ) { + resolveTitleRegeneration(resolution, threadID: threadID) + } else if receipt == .refreshUnavailable, + titleRegenerationTracker.threadIDs.contains(threadID) { + scheduleTitleRegenerationRecovery(threadID: threadID) + synchronizeTitleRegenerationRecoveryTasks() + } + } catch { + if Self.isBenignCancellation(error) { + titleRegenerationTracker.cancel(threadID: threadID) + cancelTitleRegenerationRecovery(threadID: threadID) + } else if Self.mayHaveReachedServer(error) { + // The dispatch may have crossed; keep tracking and let shell + // observation or the bounded recovery timeout resolve it. + scheduleTitleRegenerationRecovery(threadID: threadID) + synchronizeTitleRegenerationRecoveryTasks() + } else { + titleRegenerationTracker.cancel(threadID: threadID) + cancelTitleRegenerationRecovery(threadID: threadID) + showTitleRegenerationFailure(title: threadTitle) + } } } @@ -614,7 +827,7 @@ public final class FeatureRootModel { } store(detail, invalidatesInFlightLoad: false) storedDetailLoadRequestRevisions[id] = loadRequestRevision - upsert(detail.thread) + upsert(detail.thread, reconcilesTitleRegeneration: false) return detail } catch { if !Self.isBenignCancellation(error), @@ -878,6 +1091,25 @@ public final class FeatureRootModel { return message == "cancelled" || message == "canceled" } + /// Whether a failed dispatch may still have been processed server-side. + /// Timeouts and dropped transports leave the request's fate unknown, so + /// pending regeneration state must survive them instead of freeing the + /// row for a duplicate request. + private static func mayHaveReachedServer(_ error: any Error) -> Bool { + if let rpcError = error as? RPCError { + switch rpcError { + case .responseTimedOut, .disconnected: + return true + case .connectionUnavailable, .remote, .protocolViolation: + return false + } + } + if error is URLError { return true } + let message = error.localizedDescription.lowercased() + return ["timed out", "timeout", "connection", "network", "socket", "offline"] + .contains { message.contains($0) } + } + private var currentEnvironmentIdentity: String { snapshot.environments .sorted { $0.id < $1.id } @@ -903,21 +1135,52 @@ public final class FeatureRootModel { removeThread(id: id) removeDetail(id: id) case let .detail(value): + let value = deferringToShellMetadata(value) pendingThreadsByID.removeValue(forKey: value.thread.id) store(value) - upsert(value.thread) + upsert(value.thread, reconcilesTitleRegeneration: false) case let .detailDelta(value, delta): + let value = deferringToShellMetadata(value) pendingThreadsByID.removeValue(forKey: value.thread.id) store(value, delta: delta) - upsert(value.thread) + upsert(value.thread, reconcilesTitleRegeneration: false) case let .failure(message): errorMessage = message } } - private func upsert(_ thread: FeatureThread) { + /// Detail frames carry thread metadata as of their composition, while the + /// shell stream is the authority for it. A frame fetched before a title + /// regeneration or rename landed can arrive after the shell already + /// published the newer title; applying it wholesale would revert the row. + /// Keep the shell's title and regeneration state only when it is newer + /// than the frame's. Equal timestamps do not prove that the shell wins. + private func deferringToShellMetadata(_ detail: FeatureThreadDetail) -> FeatureThreadDetail { + guard let shell = snapshot.threads.first(where: { $0.id == detail.thread.id }), + shell.updatedAt > detail.thread.updatedAt, + shell.title != detail.thread.title + || shell.isRegeneratingTitle != detail.thread.isRegeneratingTitle + || shell.titleRegenerationRequestID != detail.thread.titleRegenerationRequestID + else { return detail } + var detail = detail + detail.thread.title = shell.title + detail.thread.isRegeneratingTitle = shell.isRegeneratingTitle + detail.thread.titleRegenerationRequestID = shell.titleRegenerationRequestID + detail.thread.updatedAt = shell.updatedAt + return detail + } + + private func upsert( + _ thread: FeatureThread, + reconcilesTitleRegeneration: Bool = true + ) { let thread = retainingPendingSettlement(in: thread) discardStalePullRequest(for: thread) + if reconcilesTitleRegeneration, + let resolution = titleRegenerationTracker.reconcile(thread: thread) { + resolveTitleRegeneration(resolution, threadID: thread.id) + } + synchronizeTitleRegenerationRecoveryTasks() var metadataChanged = false if let index = snapshot.threads.firstIndex(where: { $0.id == thread.id }) { let previous = snapshot.threads[index] @@ -956,6 +1219,8 @@ public final class FeatureRootModel { snapshot.threads.remove(at: index) pullRequestsByThreadID.removeValue(forKey: id) pullRequestObservationIdentities.removeValue(forKey: id) + titleRegenerationTracker.cancel(threadID: id) + cancelTitleRegenerationRecovery(threadID: id) adjustProjectCount(id: projectID, by: -1) threadCollectionRevision &+= 1 homePresentationRevision &+= 1 @@ -1024,6 +1289,10 @@ public final class FeatureRootModel { threadCollectionRevision &+= 1 } snapshot = value + for resolution in titleRegenerationTracker.reconcile(with: value.threads) { + resolveTitleRegeneration(resolution) + } + synchronizeTitleRegenerationRecoveryTasks() if value.connection.state == .connected || value.environments.contains(where: { $0.connectionState == .connected }) { scheduleOutboxDrain() @@ -1065,6 +1334,61 @@ public final class FeatureRootModel { } } + private func showTitleRegenerationFailure(title: String) { + errorMessage = "Couldn’t regenerate “\(title)”. Try again." + accessibilityAnnouncer("Couldn’t regenerate title for \(title). Try again.") + } + + private func announceTitleRegenerationCompletion(title: String) { + accessibilityAnnouncer("Title regeneration completed for \(title).") + } + + private func resolveTitleRegeneration( + _ resolution: FeatureTitleRegenerationTracker.Resolution, + threadID: String? = nil + ) { + if let threadID { + cancelTitleRegenerationRecovery(threadID: threadID) + } + switch resolution { + case let .completed(title): + if let threadID { + mutateThread(id: threadID) { $0.title = title } + } + announceTitleRegenerationCompletion(title: title) + case let .failed(title): + showTitleRegenerationFailure(title: title) + case .cancelled: + break + } + } + + private func scheduleTitleRegenerationRecovery(threadID: String) { + cancelTitleRegenerationRecovery(threadID: threadID) + let timeout = titleRegenerationRefreshTimeout + titleRegenerationRecoveryTasks[threadID] = Task { @MainActor [weak self] in + try? await Task.sleep(for: timeout) + guard !Task.isCancelled, let self else { return } + self.titleRegenerationRecoveryTasks.removeValue(forKey: threadID) + if let resolution = self.titleRegenerationTracker.expire(threadID: threadID) { + self.resolveTitleRegeneration(resolution, threadID: threadID) + } + } + } + + private func cancelTitleRegenerationRecovery(threadID: String) { + titleRegenerationRecoveryTasks.removeValue(forKey: threadID)?.cancel() + } + + private func synchronizeTitleRegenerationRecoveryTasks() { + let pendingThreadIDs = titleRegenerationTracker.threadIDs + let observedThreadIDs = titleRegenerationTracker.observedServerRequestThreadIDs + for threadID in Array(titleRegenerationRecoveryTasks.keys) + where !pendingThreadIDs.contains(threadID) || observedThreadIDs.contains(threadID) { + cancelTitleRegenerationRecovery(threadID: threadID) + } + } + private func store( _ incoming: FeatureThreadDetail, invalidatesInFlightLoad: Bool = true diff --git a/apps/swift-ios/Features/Shared/FeatureClient.swift b/apps/swift-ios/Features/Shared/FeatureClient.swift index 63e020073ab0..c3cd1a88b219 100644 --- a/apps/swift-ios/Features/Shared/FeatureClient.swift +++ b/apps/swift-ios/Features/Shared/FeatureClient.swift @@ -1,5 +1,12 @@ import Foundation +public enum FeatureTitleRegenerationDispatchReceipt: Equatable, Sendable { + case regenerating + case completed(title: String) + case failed + case refreshUnavailable +} + /// The app-owned adapter between the native feature layer and T3's WebSocket/Core runtime. /// Implementations are main-actor isolated so UI state never depends on locking. @MainActor @@ -55,7 +62,10 @@ public protocol FeatureClient: AnyObject { refresh: Bool ) async throws -> [FeatureWorkspaceBranch] func renameThread(id: String, title: String) async throws - func regenerateThreadTitle(id: String) async throws + /// `requestID` is the command identity of the dispatch; the server echoes + /// it on the thread's `titleRegeneration`, letting the caller distinguish + /// its own request from another client's. + func regenerateThreadTitle(id: String, requestID: String) async throws -> FeatureTitleRegenerationDispatchReceipt func setThreadArchived(id: String, archived: Bool) async throws func setThreadSettled(id: String, settled: Bool) async throws func setThreadSnoozed(id: String, until: Date?) async throws @@ -197,7 +207,7 @@ public extension FeatureClient { try await initialSnapshot() } - func regenerateThreadTitle(id _: String) async throws { + func regenerateThreadTitle(id _: String, requestID _: String) async throws -> FeatureTitleRegenerationDispatchReceipt { throw FeatureCapabilityUnavailable("Thread title regeneration") } diff --git a/apps/swift-ios/Features/Shared/FeatureModels.swift b/apps/swift-ios/Features/Shared/FeatureModels.swift index dd8785cf33fc..ca60c47dda28 100644 --- a/apps/swift-ios/Features/Shared/FeatureModels.swift +++ b/apps/swift-ios/Features/Shared/FeatureModels.swift @@ -275,6 +275,11 @@ public struct FeatureThread: Identifiable, Sendable, Equatable, Hashable, Codabl public var supportsPinning: Bool? public var supportsTitleRegeneration: Bool? public var supportsPullRequestLinking: Bool? + public var isRegeneratingTitle: Bool? + /// Identity of the server-side regeneration request this thread is + /// currently running, so clients can tell their own dispatch from another + /// client's. + public var titleRegenerationRequestID: String? public var attentionAt: Date? public var workingStartedAt: Date? public var latestTurnCompletedAt: Date? @@ -314,6 +319,8 @@ public struct FeatureThread: Identifiable, Sendable, Equatable, Hashable, Codabl supportsPinning: Bool? = nil, supportsTitleRegeneration: Bool? = nil, supportsPullRequestLinking: Bool? = nil, + isRegeneratingTitle: Bool? = nil, + titleRegenerationRequestID: String? = nil, attentionAt: Date? = nil, workingStartedAt: Date? = nil, latestTurnCompletedAt: Date? = nil, @@ -352,6 +359,8 @@ public struct FeatureThread: Identifiable, Sendable, Equatable, Hashable, Codabl self.supportsPinning = supportsPinning self.supportsTitleRegeneration = supportsTitleRegeneration self.supportsPullRequestLinking = supportsPullRequestLinking + self.isRegeneratingTitle = isRegeneratingTitle + self.titleRegenerationRequestID = titleRegenerationRequestID self.attentionAt = attentionAt self.workingStartedAt = workingStartedAt self.latestTurnCompletedAt = latestTurnCompletedAt diff --git a/apps/swift-ios/Features/Workspace/HomeThreadCollectionView.swift b/apps/swift-ios/Features/Workspace/HomeThreadCollectionView.swift index d55641835e69..08d3dfb8b70a 100644 --- a/apps/swift-ios/Features/Workspace/HomeThreadCollectionView.swift +++ b/apps/swift-ios/Features/Workspace/HomeThreadCollectionView.swift @@ -1,6 +1,22 @@ import SwiftUI import UIKit +enum ThreadTitleRegenerationMenuState: Equatable { + case hidden + case available + case regenerating + + static func resolve( + thread: FeatureThread, + regeneratingThreadIDs: Set + ) -> Self { + guard thread.supportsTitleRegeneration == true else { return .hidden } + return regeneratingThreadIDs.contains(thread.id) || thread.isRegeneratingTitle == true + ? .regenerating + : .available + } +} + /// 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 { @@ -22,6 +38,7 @@ struct HomeThreadCollectionView: UIViewRepresentable { let onToggleArchive: () -> Void let onShowMoreSettled: () -> Void let onRename: (FeatureThread) -> Void + let regeneratingTitleThreadIDs: Set let onRegenerateTitle: (FeatureThread) -> Void let onArchive: (FeatureThread, Bool) -> Void let onSettle: (FeatureThread, Bool, @escaping (Bool) -> Void) -> Void @@ -86,6 +103,7 @@ struct HomeThreadCollectionView: UIViewRepresentable { private var itemsByID: [HomeCollectionItem.ID: HomeCollectionItem] = [:] private var pullRequestsByThreadID: [String: HomeThreadPullRequestPresentation] = [:] private var selectedThreadID: String? + private var regeneratingTitleThreadIDs: Set private weak var collectionView: UICollectionView? private var timer: Timer? private var timerTick = 0 @@ -95,6 +113,7 @@ struct HomeThreadCollectionView: UIViewRepresentable { init(parent: HomeThreadCollectionView) { self.parent = parent selectedThreadID = parent.selectedThreadID + regeneratingTitleThreadIDs = parent.regeneratingTitleThreadIDs } func configure(_ collectionView: UICollectionView) { @@ -123,8 +142,10 @@ struct HomeThreadCollectionView: UIViewRepresentable { func update(parent: HomeThreadCollectionView, collectionView: UICollectionView) { let previousItems = itemsByID let previousSelection = selectedThreadID + let previousRegeneratingTitleThreadIDs = regeneratingTitleThreadIDs self.parent = parent selectedThreadID = parent.selectedThreadID + regeneratingTitleThreadIDs = parent.regeneratingTitleThreadIDs var seenIdentifiers = Set() let items = parent.collectionItems.filter { item in @@ -161,7 +182,11 @@ struct HomeThreadCollectionView: UIViewRepresentable { ? [previousSelection, selectedThreadID] : []) .compactMap { $0.map(HomeCollectionItem.ID.thread) } .filter { newIdentifiers.contains($0) } - let identifiers = Array(Set(changed + selectionChanged)) + let regenerationChanged = previousRegeneratingTitleThreadIDs + .symmetricDifference(regeneratingTitleThreadIDs) + .map(HomeCollectionItem.ID.thread) + .filter { newIdentifiers.contains($0) } + let identifiers = Array(Set(changed + selectionChanged + regenerationChanged)) if !identifiers.isEmpty { var snapshot = dataSource.snapshot() snapshot.reconfigureItems(identifiers) @@ -182,10 +207,17 @@ struct HomeThreadCollectionView: UIViewRepresentable { let retained = Set(currentIdentifiers) let selectionChanged = previousSelection != selectedThreadID ? Set([previousSelection, selectedThreadID].compactMap { $0 }) : [] + let regenerationChanged = previousRegeneratingTitleThreadIDs + .symmetricDifference(regeneratingTitleThreadIDs) snapshot.reconfigureItems(newIdentifiers.filter { identifier in - retained.contains(identifier) - && (previousItems[identifier] != itemsByID[identifier] - || identifier.threadID.map(selectionChanged.contains) == true) + Self.needsReconfiguration( + identifier: identifier, + retained: retained, + previousItems: previousItems, + itemsByID: itemsByID, + selectionChanged: selectionChanged, + regenerationChanged: regenerationChanged + ) }) let shouldAnimate = !resolvedSwipeCompletions.isEmpty && !currentIdentifiers.isEmpty @@ -210,6 +242,24 @@ struct HomeThreadCollectionView: UIViewRepresentable { timer = nil } + /// Rows retained across a membership or order change still need fresh + /// content when their own item, selection, or title-regeneration state + /// changed in the same update; regeneration state lives outside + /// `HomeCollectionItem`, so it is compared separately. + nonisolated static func needsReconfiguration( + identifier: HomeCollectionItem.ID, + retained: Set, + previousItems: [HomeCollectionItem.ID: HomeCollectionItem], + itemsByID: [HomeCollectionItem.ID: HomeCollectionItem], + selectionChanged: Set, + regenerationChanged: Set + ) -> Bool { + retained.contains(identifier) + && (previousItems[identifier] != itemsByID[identifier] + || identifier.threadID.map(selectionChanged.contains) == true + || identifier.threadID.map(regenerationChanged.contains) == true) + } + func cancelPendingSwipeActions() { pendingSwipeCompletions.values.forEach { $0.finish(false) } pendingSwipeCompletions.removeAll() @@ -357,6 +407,9 @@ struct HomeThreadCollectionView: UIViewRepresentable { item: item, projectFaviconClient: parent.projectFaviconClient, isSelected: identifier.threadID == selectedThreadID, + isRegeneratingTitle: identifier.threadID.map { + regeneratingTitleThreadIDs.contains($0) + } ?? false, now: now, onPullRequestChange: { [weak self, weak cell] pullRequest in guard let self, @@ -479,6 +532,12 @@ struct HomeThreadCollectionView: UIViewRepresentable { ) { values.append("Settled") } + if ThreadTitleRegenerationMenuState.resolve( + thread: thread, + regeneratingThreadIDs: parent.regeneratingTitleThreadIDs + ) == .regenerating { + values.append("Regenerating title") + } values.append("Provider \(context.providerName)") if let environment = context.environmentLabel { values.append("on \(environment)") @@ -512,7 +571,10 @@ struct HomeThreadCollectionView: UIViewRepresentable { coordinator.parent.onRename(thread) }] - if thread.supportsTitleRegeneration == true { + if ThreadTitleRegenerationMenuState.resolve( + thread: thread, + regeneratingThreadIDs: parent.regeneratingTitleThreadIDs + ) == .available { actions.append(accessibilityAction("Regenerate title", systemImage: "sparkles") { coordinator in coordinator.parent.onRegenerateTitle(thread) }) @@ -629,7 +691,13 @@ struct HomeThreadCollectionView: UIViewRepresentable { } var titleActions: [UIMenuElement] = [rename] - if thread.supportsTitleRegeneration == true { + switch ThreadTitleRegenerationMenuState.resolve( + thread: thread, + regeneratingThreadIDs: parent.regeneratingTitleThreadIDs + ) { + case .hidden: + break + case .available: titleActions.append( UIAction( title: "Regenerate title", @@ -638,6 +706,13 @@ struct HomeThreadCollectionView: UIViewRepresentable { self?.parent.onRegenerateTitle(thread) } ) + case .regenerating: + let action = UIAction( + title: "Regenerating…", + image: UIImage(systemName: "sparkles") + ) { _ in } + action.attributes = .disabled + titleActions.append(action) } var statusActions: [UIMenuElement] = [] @@ -999,7 +1074,7 @@ private final class HomeCollectionCell: UICollectionViewListCell { } } -private enum HomeShelf: String, Hashable { +enum HomeShelf: String, Hashable { case active case snoozed case settled @@ -1010,7 +1085,7 @@ private enum HomeShelf: String, Hashable { } } -private enum HomeCollectionItem: Equatable { +enum HomeCollectionItem: Equatable { enum ID: Hashable { case thread(String) case shelfHeader(HomeShelf) @@ -1048,6 +1123,7 @@ private struct HomeCollectionCellContent: View { let item: HomeCollectionItem let projectFaviconClient: any FeatureClient let isSelected: Bool + let isRegeneratingTitle: Bool let now: Date let onPullRequestChange: (HomeThreadPullRequestPresentation?) -> Void @@ -1061,6 +1137,7 @@ private struct HomeCollectionCellContent: View { projectFaviconClient: projectFaviconClient, onPullRequestChange: onPullRequestChange, isSelected: isSelected, + isRegeneratingTitle: isRegeneratingTitle, style: style, now: now, allowsMultilineTitle: allowsMultilineTitle diff --git a/apps/swift-ios/Features/Workspace/WorkspaceView.swift b/apps/swift-ios/Features/Workspace/WorkspaceView.swift index 319f58df865b..4fb63cd9a382 100644 --- a/apps/swift-ios/Features/Workspace/WorkspaceView.swift +++ b/apps/swift-ios/Features/Workspace/WorkspaceView.swift @@ -291,6 +291,7 @@ public struct WorkspaceView: View { renameTitle = thread.title renamingThread = thread }, + regeneratingTitleThreadIDs: model.regeneratingTitleThreadIDs, onRegenerateTitle: { thread in Task { await model.regenerateThreadTitle(thread.id) } }, @@ -993,6 +994,7 @@ struct FeatureThreadRow: View { private let projectFaviconClient: (any FeatureClient)? private let onPullRequestChange: (HomeThreadPullRequestPresentation?) -> Void let isSelected: Bool + let isRegeneratingTitle: Bool let style: Style let now: Date let allowsMultilineTitle: Bool @@ -1004,6 +1006,7 @@ struct FeatureThreadRow: View { projectFaviconClient: (any FeatureClient)? = nil, onPullRequestChange: @escaping (HomeThreadPullRequestPresentation?) -> Void = { _ in }, isSelected: Bool = false, + isRegeneratingTitle: Bool = false, style: Style = .rich, now: Date = .now, allowsMultilineTitle: Bool = false @@ -1013,6 +1016,7 @@ struct FeatureThreadRow: View { self.projectFaviconClient = projectFaviconClient self.onPullRequestChange = onPullRequestChange self.isSelected = isSelected + self.isRegeneratingTitle = isRegeneratingTitle self.style = style self.now = now self.allowsMultilineTitle = allowsMultilineTitle @@ -1055,12 +1059,15 @@ struct FeatureThreadRow: View { .font(T3Typography.homeMetadata.weight(.medium)) .frame(minHeight: 20) - Text(thread.title) - .font(T3Typography.homeTitle) - .tracking(-0.14) - .foregroundStyle(T3Colors.textPrimary) - .lineLimit(allowsMultilineTitle ? 2 : 1) - .padding(.top, 4) + HStack(spacing: 7) { + Text(thread.title) + .font(T3Typography.homeTitle) + .tracking(-0.14) + .foregroundStyle(T3Colors.textPrimary) + .lineLimit(allowsMultilineTitle ? 2 : 1) + titleRegenerationProgress + } + .padding(.top, 4) HStack(spacing: 6) { Image(systemName: "arrow.triangle.branch") @@ -1116,6 +1123,7 @@ struct FeatureThreadRow: View { .font(T3Typography.homeTitle) .foregroundStyle(T3Colors.textSecondary) .lineLimit(allowsMultilineTitle ? 2 : 1) + titleRegenerationProgress Spacer(minLength: 8) if let pullRequest { pullRequestIndicator(pullRequest) @@ -1165,6 +1173,17 @@ struct FeatureThreadRow: View { } } + @ViewBuilder + private var titleRegenerationProgress: some View { + if isRegeneratingTitle { + ProgressView() + .controlSize(.small) + .tint(T3Colors.accent) + .accessibilityHidden(true) + .accessibilityIdentifier("thread-title-regeneration-progress-\(thread.id)") + } + } + private var statusColor: Color { switch thread.homeStatus { case .working: T3Colors.statusRunning @@ -1328,6 +1347,9 @@ struct FeatureThreadRow: View { if let environmentLabel { values.append("on \(environmentLabel)") } + if isRegeneratingTitle { + values.append("Regenerating title") + } if isConnectionStale { values.append("last known state") } diff --git a/apps/swift-ios/Tests/CoreTests/WireFixtureContractTests.swift b/apps/swift-ios/Tests/CoreTests/WireFixtureContractTests.swift index 69383fd40cad..a2437ffb0fff 100644 --- a/apps/swift-ios/Tests/CoreTests/WireFixtureContractTests.swift +++ b/apps/swift-ios/Tests/CoreTests/WireFixtureContractTests.swift @@ -114,6 +114,29 @@ final class WireFixtureContractTests: XCTestCase { XCTAssertEqual(decodedDetail.thread.unsettledAt, timestamp) } + func testShellSnapshotDecodesTitleRegenerationState() throws { + var payload = try XCTUnwrap(try fixtureObject("shell-snapshot") as? [String: Any]) + var threads = try XCTUnwrap(payload["threads"] as? [[String: Any]]) + threads[0]["titleRegeneration"] = [ + "requestId": "command-regenerate-title", + "startedAt": "2026-08-07T12:01:00.000Z", + ] + payload["threads"] = threads + + let snapshot = try JSONDecoder.t3.decode( + OrchestrationShellSnapshot.self, + from: JSONSerialization.data(withJSONObject: payload, options: [.sortedKeys]) + ) + + XCTAssertEqual( + snapshot.threads.first?.titleRegeneration, + ThreadTitleRegeneration( + requestId: "command-regenerate-title", + startedAt: "2026-08-07T12:01:00.000Z" + ) + ) + } + func testUnknownStreamItemsRequestRefreshWithoutEndingDecoding() throws { let shell = try JSONDecoder.t3.decode( ShellStreamItem.self, diff --git a/apps/swift-ios/Tests/FeatureTests/FeatureRootModelTests.swift b/apps/swift-ios/Tests/FeatureTests/FeatureRootModelTests.swift index 7573e1654259..a91464c3efd4 100644 --- a/apps/swift-ios/Tests/FeatureTests/FeatureRootModelTests.swift +++ b/apps/swift-ios/Tests/FeatureTests/FeatureRootModelTests.swift @@ -37,6 +37,562 @@ struct FeatureRootModelTests { #expect(model.snapshot.connection.environmentName == "Remote") } + @Test + func titleRegenerationBlocksDuplicatesAndAppliesOnlyTheAuthoritativeRow() async { + let client = FeatureClientStub() + let first = regeneratableThread(id: "first", title: "First") + let second = regeneratableThread(id: "second", title: "Second") + client.snapshot = FeatureSnapshot(threads: [first, second]) + client.titleRegenerationReceipt = .regenerating + client.holdTitleRegeneration = true + var announcements: [String] = [] + let model = testRootModel(client: client) { announcements.append($0) } + await model.reload() + + let started = AsyncStream.makeStream() + client.onTitleRegenerationStarted = { + started.continuation.yield() + started.continuation.finish() + } + let request = Task { await model.regenerateThreadTitle(first.id) } + for await _ in started.stream { break } + + #expect(model.regeneratingTitleThreadIDs == [first.id]) + #expect(announcements == ["Regenerating title for First."]) + await model.regenerateThreadTitle(first.id) + #expect(client.regeneratedTitleThreadIDs == [first.id]) + #expect(announcements == ["Regenerating title for First."]) + + client.resumeTitleRegeneration() + await request.value + var pending = first + pending.isRegeneratingTitle = true + pending.titleRegenerationRequestID = client.regeneratedTitleRequestIDs[0] + client.snapshot = FeatureSnapshot(threads: [pending, second]) + await model.reload() + + var renamed = first + renamed.title = "Authoritative title" + client.snapshot = FeatureSnapshot(threads: [renamed, second]) + await model.reload() + + #expect(model.snapshot.threads.first { $0.id == first.id }?.title == "Authoritative title") + #expect(model.snapshot.threads.first { $0.id == second.id }?.title == "Second") + #expect(model.regeneratingTitleThreadIDs.isEmpty) + #expect(announcements == [ + "Regenerating title for First.", + "Title regeneration completed for Authoritative title.", + ]) + } + + @Test + func regenerationDispatchReceiptRequiresAnAuthoritativeRefresh() { + let unchanged = regeneratableThread(id: "stale", title: "Original title") + + #expect( + NativeFeatureClient.titleRegenerationDispatchReceipt( + previousTitle: unchanged.title, + dispatchRequestID: "dispatch-request", + dispatchSequence: 12, + refreshedSequence: 11, + refreshedThread: unchanged + ) == .refreshUnavailable + ) + + var pending = unchanged + pending.isRegeneratingTitle = true + pending.titleRegenerationRequestID = "dispatch-request" + #expect( + NativeFeatureClient.titleRegenerationDispatchReceipt( + previousTitle: unchanged.title, + dispatchRequestID: "dispatch-request", + dispatchSequence: 12, + refreshedSequence: 12, + refreshedThread: pending + ) == .regenerating + ) + + // The server replaced this dispatch with another client's request, so + // this one can no longer produce a title. + var foreign = unchanged + foreign.isRegeneratingTitle = true + foreign.titleRegenerationRequestID = "another-client" + #expect( + NativeFeatureClient.titleRegenerationDispatchReceipt( + previousTitle: unchanged.title, + dispatchRequestID: "dispatch-request", + dispatchSequence: 12, + refreshedSequence: 12, + refreshedThread: foreign + ) == .failed + ) + + var completed = unchanged + completed.title = "Authoritative title" + #expect( + NativeFeatureClient.titleRegenerationDispatchReceipt( + previousTitle: unchanged.title, + dispatchRequestID: "dispatch-request", + dispatchSequence: 12, + refreshedSequence: 13, + refreshedThread: completed + ) == .completed(title: "Authoritative title") + ) + #expect( + NativeFeatureClient.titleRegenerationDispatchReceipt( + previousTitle: unchanged.title, + dispatchRequestID: "dispatch-request", + dispatchSequence: 12, + refreshedSequence: 13, + refreshedThread: unchanged + ) == .failed + ) + #expect( + NativeFeatureClient.titleRegenerationDispatchReceipt( + previousTitle: nil, + dispatchRequestID: "dispatch-request", + dispatchSequence: 12, + refreshedSequence: 13, + refreshedThread: nil + ) == .refreshUnavailable + ) + } + + @Test + func staleRefreshAndUnchangedUpdateDoNotEndAcceptedRegeneration() async { + let client = FeatureClientStub() + let thread = regeneratableThread(id: "stale", title: "Original title") + client.snapshot = FeatureSnapshot(threads: [thread]) + client.titleRegenerationReceipt = .refreshUnavailable + var announcements: [String] = [] + let model = testRootModel(client: client) { announcements.append($0) } + await model.reload() + + await model.regenerateThreadTitle(thread.id) + await model.reload() + + #expect(model.regeneratingTitleThreadIDs == [thread.id]) + #expect(model.errorMessage == nil) + #expect(announcements == ["Regenerating title for Original title."]) + + var pending = thread + pending.isRegeneratingTitle = true + pending.titleRegenerationRequestID = client.regeneratedTitleRequestIDs[0] + client.snapshot = FeatureSnapshot(threads: [pending]) + await model.reload() + client.snapshot = FeatureSnapshot(threads: [thread]) + await model.reload() + + #expect(model.regeneratingTitleThreadIDs.isEmpty) + #expect(model.errorMessage == "Couldn’t regenerate “Original title”. Try again.") + #expect(announcements == [ + "Regenerating title for Original title.", + "Couldn’t regenerate title for Original title. Try again.", + ]) + } + + @Test + func unavailableRefreshEventuallyEndsProgressWithRetryableFailure() async { + let client = FeatureClientStub() + let thread = regeneratableThread(id: "timeout", title: "Original title") + client.snapshot = FeatureSnapshot(threads: [thread]) + client.titleRegenerationReceipt = .refreshUnavailable + var announcements: [String] = [] + let model = testRootModel( + client: client, + titleRegenerationRefreshTimeout: .zero + ) { announcements.append($0) } + await model.reload() + + await model.regenerateThreadTitle(thread.id) + for _ in 0..<100 where !model.regeneratingTitleThreadIDs.isEmpty { + await Task.yield() + } + + #expect(model.regeneratingTitleThreadIDs.isEmpty) + #expect(model.errorMessage == "Couldn’t regenerate “Original title”. Try again.") + #expect(announcements == [ + "Regenerating title for Original title.", + "Couldn’t regenerate title for Original title. Try again.", + ]) + } + + @Test + func observedServerRegenerationCancelsUnavailableRefreshRecovery() async { + let client = FeatureClientStub() + let thread = regeneratableThread(id: "observed", title: "Original title") + client.snapshot = FeatureSnapshot(threads: [thread]) + client.titleRegenerationReceipt = .refreshUnavailable + client.holdTitleRegeneration = true + let started = AsyncStream.makeStream() + client.onTitleRegenerationStarted = { + started.continuation.yield() + started.continuation.finish() + } + var announcements: [String] = [] + let model = testRootModel( + client: client, + titleRegenerationRefreshTimeout: .zero + ) { announcements.append($0) } + await model.reload() + + let request = Task { await model.regenerateThreadTitle(thread.id) } + for await _ in started.stream { break } + var pending = thread + pending.isRegeneratingTitle = true + pending.titleRegenerationRequestID = client.regeneratedTitleRequestIDs[0] + client.snapshot = FeatureSnapshot(threads: [pending]) + await model.reload() + client.resumeTitleRegeneration() + await request.value + await Task.yield() + + #expect(model.regeneratingTitleThreadIDs == [thread.id]) + #expect(model.errorMessage == nil) + + var completed = thread + completed.title = "Authoritative title" + client.snapshot = FeatureSnapshot(threads: [completed]) + await model.reload() + + #expect(model.regeneratingTitleThreadIDs.isEmpty) + #expect(model.errorMessage == nil) + #expect(announcements == [ + "Regenerating title for Original title.", + "Title regeneration completed for Authoritative title.", + ]) + } + + @Test + func staleDetailLoadDoesNotResolvePendingTitleRegeneration() async throws { + let client = FeatureClientStub() + let thread = regeneratableThread(id: "stale-detail", title: "Original title") + client.snapshot = FeatureSnapshot(threads: [thread]) + client.titleRegenerationReceipt = .regenerating + let loadStarted = AsyncStream.makeStream() + var loadContinuation: CheckedContinuation? + defer { + loadStarted.continuation.finish() + loadContinuation?.resume(returning: FeatureThreadDetail(thread: thread)) + } + client.loadThreadHandler = { _ in + loadStarted.continuation.yield() + return await withCheckedContinuation { continuation in + loadContinuation = continuation + } + } + var announcements: [String] = [] + let model = testRootModel(client: client) { announcements.append($0) } + await model.reload() + + let detailLoad = Task { await model.detail(for: thread.id, force: true) } + var starts = loadStarted.stream.makeAsyncIterator() + _ = await starts.next() + await model.regenerateThreadTitle(thread.id) + let continuation = try #require(loadContinuation) + loadContinuation = nil + continuation.resume(returning: FeatureThreadDetail(thread: thread)) + _ = await detailLoad.value + + #expect(model.regeneratingTitleThreadIDs == [thread.id]) + #expect(model.errorMessage == nil) + + var completed = thread + completed.title = "Authoritative title" + client.snapshot = FeatureSnapshot(threads: [completed]) + await model.reload() + + #expect(model.regeneratingTitleThreadIDs.isEmpty) + #expect(model.errorMessage == nil) + #expect(announcements == [ + "Regenerating title for Original title.", + "Title regeneration completed for Authoritative title.", + ]) + } + + @Test + func manualRenameCancelsPendingRegenerationWithoutACompletionAnnouncement() async { + let client = FeatureClientStub() + let thread = regeneratableThread(id: "rename", title: "Original title") + client.snapshot = FeatureSnapshot(threads: [thread]) + client.titleRegenerationReceipt = .regenerating + var announcements: [String] = [] + let model = testRootModel(client: client) { announcements.append($0) } + await model.reload() + + await model.regenerateThreadTitle(thread.id) + await model.renameThread(thread.id, title: "Manual title") + + #expect(model.snapshot.threads.first?.title == "Manual title") + #expect(model.regeneratingTitleThreadIDs.isEmpty) + #expect(announcements == ["Regenerating title for Original title."]) + } + + @Test + func unchangedServerAcknowledgementEndsProgressWithRetryableFailure() async { + let client = FeatureClientStub() + let thread = regeneratableThread(id: "retry", title: "Keep this title") + client.snapshot = FeatureSnapshot(threads: [thread]) + client.titleRegenerationReceipt = .regenerating + var announcements: [String] = [] + let model = testRootModel(client: client) { announcements.append($0) } + await model.reload() + + await model.regenerateThreadTitle(thread.id) + var pending = thread + pending.isRegeneratingTitle = true + pending.titleRegenerationRequestID = client.regeneratedTitleRequestIDs[0] + client.snapshot = FeatureSnapshot(threads: [pending]) + await model.reload() + client.snapshot = FeatureSnapshot(threads: [thread]) + await model.reload() + + #expect(model.snapshot.threads.first?.title == "Keep this title") + #expect(model.snapshot.threads.first?.state == thread.state) + #expect(model.errorMessage == "Couldn’t regenerate “Keep this title”. Try again.") + #expect(model.regeneratingTitleThreadIDs.isEmpty) + #expect(announcements == [ + "Regenerating title for Keep this title.", + "Couldn’t regenerate title for Keep this title. Try again.", + ]) + + model.errorMessage = nil + await model.regenerateThreadTitle(thread.id) + var recovered = thread + recovered.title = "Recovered title" + client.snapshot = FeatureSnapshot(threads: [recovered]) + await model.reload() + + #expect(client.regeneratedTitleThreadIDs == [thread.id, thread.id]) + #expect(model.snapshot.threads.first?.title == "Recovered title") + #expect(model.snapshot.threads.first?.state == thread.state) + #expect(model.errorMessage == nil) + #expect(model.regeneratingTitleThreadIDs.isEmpty) + #expect(announcements == [ + "Regenerating title for Keep this title.", + "Couldn’t regenerate title for Keep this title. Try again.", + "Regenerating title for Keep this title.", + "Title regeneration completed for Recovered title.", + ]) + } + + @Test + func immediatelyCompletedUnchangedTitleReportsRetryableFailure() async { + let client = FeatureClientStub() + let thread = regeneratableThread(id: "unchanged", title: "Already descriptive") + client.snapshot = FeatureSnapshot(threads: [thread]) + client.titleRegenerationReceipt = .completed(title: thread.title) + let model = testRootModel(client: client) + await model.reload() + + await model.regenerateThreadTitle(thread.id) + + #expect(client.regeneratedTitleThreadIDs == [thread.id]) + #expect(model.snapshot.threads == [thread]) + #expect(model.errorMessage == "Couldn’t regenerate “Already descriptive”. Try again.") + #expect(model.regeneratingTitleThreadIDs.isEmpty) + } + + @Test + func titleRegenerationDispatchFailureIsVisibleAndRetryable() async { + let client = FeatureClientStub() + let thread = regeneratableThread(id: "retry", title: "Keep this title") + client.snapshot = FeatureSnapshot(threads: [thread]) + client.titleRegenerationError = FeatureCapabilityUnavailable("Temporary failure") + var announcements: [String] = [] + let model = testRootModel(client: client) { announcements.append($0) } + await model.reload() + + await model.regenerateThreadTitle(thread.id) + + #expect(model.snapshot.threads == [thread]) + #expect(model.errorMessage == "Couldn’t regenerate “Keep this title”. Try again.") + #expect(model.regeneratingTitleThreadIDs.isEmpty) + #expect(announcements == [ + "Regenerating title for Keep this title.", + "Couldn’t regenerate title for Keep this title. Try again.", + ]) + + client.titleRegenerationError = nil + client.titleRegenerationReceipt = .completed(title: "Recovered title") + model.errorMessage = nil + await model.regenerateThreadTitle(thread.id) + + #expect(client.regeneratedTitleThreadIDs == [thread.id, thread.id]) + #expect(model.snapshot.threads.first?.title == "Recovered title") + #expect(model.errorMessage == nil) + #expect(model.regeneratingTitleThreadIDs.isEmpty) + #expect(announcements == [ + "Regenerating title for Keep this title.", + "Couldn’t regenerate title for Keep this title. Try again.", + "Regenerating title for Keep this title.", + "Title regeneration completed for Recovered title.", + ]) + } + + @Test + func staleDetailEventDoesNotRevertARegeneratedTitle() async { + let client = FeatureClientStub() + let thread = regeneratableThread(id: "open", title: "Original title") + client.snapshot = FeatureSnapshot(threads: [thread]) + client.titleRegenerationReceipt = .completed(title: "Regenerated title") + let model = testRootModel(client: client) + await model.reload() + + await model.regenerateThreadTitle(thread.id) + var regenerated = thread + regenerated.title = "Regenerated title" + regenerated.updatedAt = thread.updatedAt.addingTimeInterval(60) + client.snapshot = FeatureSnapshot(threads: [regenerated]) + await model.reload() + + #expect(model.snapshot.threads.first?.title == "Regenerated title") + + // A detail frame composed before the regeneration landed keeps the + // old title on its cached thread and must not republish it over the + // shell’s newer one. + let run = Task { await model.start() } + client.emit(.detail(FeatureThreadDetail(thread: thread))) + client.finishEvents() + await run.value + + #expect(model.snapshot.threads.first?.title == "Regenerated title") + #expect(model.details[thread.id]?.thread.title == "Regenerated title") + } + + @Test + func foreignTitleRegenerationRequestIsNotTrackedAsOurs() async { + let client = FeatureClientStub() + let thread = regeneratableThread(id: "shared", title: "Original title") + client.snapshot = FeatureSnapshot(threads: [thread]) + client.titleRegenerationReceipt = .refreshUnavailable + var announcements: [String] = [] + let model = testRootModel(client: client) { announcements.append($0) } + await model.reload() + + await model.regenerateThreadTitle(thread.id) + + // Another client’s regeneration replaced this request server-side. + var foreign = thread + foreign.isRegeneratingTitle = true + foreign.titleRegenerationRequestID = "another-client" + client.snapshot = FeatureSnapshot(threads: [foreign]) + await model.reload() + + // The shell still reports a live regeneration, so the row stays + // honest, but this client no longer claims an outcome for it. + #expect(model.regeneratingTitleThreadIDs == [thread.id]) + #expect(announcements == ["Regenerating title for Original title."]) + + var completed = thread + completed.title = "Foreign regenerated title" + client.snapshot = FeatureSnapshot(threads: [completed]) + await model.reload() + + #expect(model.snapshot.threads.first?.title == "Foreign regenerated title") + #expect(model.regeneratingTitleThreadIDs.isEmpty) + #expect(announcements == ["Regenerating title for Original title."]) + } + + @Test + func renameWaitsForAnInFlightTitleRegenerationDispatch() async { + let client = FeatureClientStub() + let thread = regeneratableThread(id: "rename", title: "Original title") + client.snapshot = FeatureSnapshot(threads: [thread]) + client.titleRegenerationReceipt = .regenerating + client.holdTitleRegeneration = true + var announcements: [String] = [] + let model = testRootModel(client: client) { announcements.append($0) } + await model.reload() + + let regeneration = Task { await model.regenerateThreadTitle(thread.id) } + for _ in 0..<50 { + await Task.yield() + } + let rename = Task { await model.renameThread(thread.id, title: "Manual title") } + for _ in 0..<50 { + await Task.yield() + } + + // The rename must not reach the server before the regeneration + // dispatch is acknowledged, or the server would register the + // regeneration behind the manual title. + #expect(client.renamedThreadTitles.isEmpty) + + client.resumeTitleRegeneration() + await regeneration.value + await rename.value + + #expect(client.renamedThreadTitles.map(\.title) == ["Manual title"]) + #expect(model.snapshot.threads.first?.title == "Manual title") + #expect(model.regeneratingTitleThreadIDs.isEmpty) + #expect(announcements == ["Regenerating title for Original title."]) + } + + @Test + func ambiguousDispatchFailureKeepsRegenerationPending() async { + let client = FeatureClientStub() + let thread = regeneratableThread(id: "ambiguous", title: "Original title") + client.snapshot = FeatureSnapshot(threads: [thread]) + client.titleRegenerationError = RPCError.responseTimedOut + var announcements: [String] = [] + let model = testRootModel(client: client) { announcements.append($0) } + await model.reload() + + await model.regenerateThreadTitle(thread.id) + + // The dispatch may still have been processed; the row must not free + // itself for a duplicate request. + #expect(model.regeneratingTitleThreadIDs == [thread.id]) + #expect(model.errorMessage == nil) + await model.regenerateThreadTitle(thread.id) + #expect(client.regeneratedTitleThreadIDs == [thread.id]) + + // A later authoritative snapshot first confirms that the server saw + // the request, then resolves the pending work. + client.titleRegenerationError = nil + var acknowledged = thread + acknowledged.isRegeneratingTitle = true + acknowledged.titleRegenerationRequestID = client.regeneratedTitleRequestIDs[0] + client.snapshot = FeatureSnapshot(threads: [acknowledged]) + await model.reload() + + var completed = thread + completed.title = "Authoritative title" + client.snapshot = FeatureSnapshot(threads: [completed]) + await model.reload() + + #expect(model.snapshot.threads.first?.title == "Authoritative title") + #expect(model.regeneratingTitleThreadIDs.isEmpty) + #expect( + announcements == [ + "Regenerating title for Original title.", + "Title regeneration completed for Authoritative title.", + ] + ) + } + + @Test + func failedRenameKeepsPendingRegeneration() async { + let client = FeatureClientStub() + let thread = regeneratableThread(id: "rename-failure", title: "Original title") + client.snapshot = FeatureSnapshot(threads: [thread]) + client.titleRegenerationReceipt = .regenerating + client.renameThreadError = RPCError.remote("rejected") + var announcements: [String] = [] + let model = testRootModel(client: client) { announcements.append($0) } + await model.reload() + + await model.regenerateThreadTitle(thread.id) + await model.renameThread(thread.id, title: "Manual title") + + // The rename never reached the server, so the regeneration it would + // have cancelled must stay tracked. + #expect(model.regeneratingTitleThreadIDs == [thread.id]) + #expect(model.snapshot.threads.first?.title == "Original title") + #expect(announcements == ["Regenerating title for Original title."]) + } + @Test func savedServersKeepWorkspaceNavigationAvailableWhileDisconnected() { let savedEnvironment = FeatureEnvironment( @@ -2553,13 +3109,29 @@ private func textInputText(_ view: UIView?) -> String? { } @MainActor -private func testRootModel(client: FeatureClientStub) -> FeatureRootModel { +private func testRootModel( + client: FeatureClientStub, + titleRegenerationRefreshTimeout: Duration = .seconds(60), + accessibilityAnnouncer: @escaping @MainActor (String) -> Void = { _ in } +) -> FeatureRootModel { FeatureRootModel( client: client, outboxStore: FeatureOutboxStore( fileURL: FileManager.default.temporaryDirectory .appendingPathComponent("t3-root-outbox-\(UUID().uuidString).json") - ) + ), + titleRegenerationRefreshTimeout: titleRegenerationRefreshTimeout, + accessibilityAnnouncer: accessibilityAnnouncer + ) +} + +private func regeneratableThread(id: String, title: String) -> FeatureThread { + FeatureThread( + id: id, + projectID: "project", + environmentID: "environment", + title: title, + supportsTitleRegeneration: true ) } @@ -2650,6 +3222,15 @@ private final class FeatureClientStub: FeatureClient, T3ConnectCapable { var resolvedInputID: String? var resolvedInputAnswers: [String: FeatureInputAnswer]? var savedSettings: [FeatureSettings] = [] + var regeneratedTitleThreadIDs: [String] = [] + var regeneratedTitleRequestIDs: [String] = [] + var renamedThreadTitles: [(id: String, title: String)] = [] + var renameThreadError: (any Error)? + var titleRegenerationReceipt: FeatureTitleRegenerationDispatchReceipt = .completed(title: "Title") + var titleRegenerationError: (any Error)? + var holdTitleRegeneration = false + var onTitleRegenerationStarted: (() -> Void)? + private var titleRegenerationContinuation: CheckedContinuation? lazy var t3ConnectController = T3ConnectController( resolution: .unavailable(reason: "T3 Connect is disabled in feature tests.") ) @@ -2765,7 +3346,29 @@ private final class FeatureClientStub: FeatureClient, T3ConnectCapable { return createdThread } - func renameThread(id: String, title: String) async throws {} + func renameThread(id: String, title: String) async throws { + if let renameThreadError { throw renameThreadError } + renamedThreadTitles.append((id, title)) + } + func regenerateThreadTitle( + id: String, + requestID: String + ) async throws -> FeatureTitleRegenerationDispatchReceipt { + regeneratedTitleThreadIDs.append(id) + regeneratedTitleRequestIDs.append(requestID) + onTitleRegenerationStarted?() + onTitleRegenerationStarted = nil + if let titleRegenerationError { throw titleRegenerationError } + if holdTitleRegeneration { + await withCheckedContinuation { titleRegenerationContinuation = $0 } + } + return titleRegenerationReceipt + } + func resumeTitleRegeneration() { + holdTitleRegeneration = false + titleRegenerationContinuation?.resume() + titleRegenerationContinuation = nil + } func setThreadArchived(id: String, archived: Bool) async throws {} func deleteThread(id: String) async throws {} diff --git a/apps/swift-ios/Tests/FeatureTests/HomeThreadSwipeActionTests.swift b/apps/swift-ios/Tests/FeatureTests/HomeThreadSwipeActionTests.swift index 65ab6d8f9789..c5abfec0074c 100644 --- a/apps/swift-ios/Tests/FeatureTests/HomeThreadSwipeActionTests.swift +++ b/apps/swift-ios/Tests/FeatureTests/HomeThreadSwipeActionTests.swift @@ -780,6 +780,54 @@ struct HomeThreadSwipeActionTests { #expect(selected.first?.accessibilityLabel == second.title) } + @Test + func localTitleRegenerationStateShowsProgressAndSuppressesDuplicateAction() throws { + let client = SwipeSettlementClientStub() + var regeneratable = thread(id: "regenerating") + regeneratable.supportsTitleRegeneration = true + let initial = threadList( + client: client, + snapshot: snapshot(threads: [regeneratable]) + ) + let coordinator = initial.makeCoordinator() + let collectionView = testCollectionView() + coordinator.configure(collectionView) + collectionView.layoutIfNeeded() + defer { + coordinator.invalidateTimer() + coordinator.cancelPendingSwipeActions() + } + + let initialCell = try #require( + collectionView.cellForItem(at: IndexPath(item: 0, section: 0)) + ) + #expect(initialCell.accessibilityValue?.contains("Regenerating title") == false) + #expect(initialCell.accessibilityCustomActions?.contains { $0.name == "Regenerate title" } == true) + + let updated = threadList( + client: client, + snapshot: snapshot(threads: [regeneratable]), + regeneratingTitleThreadIDs: [regeneratable.id] + ) + coordinator.update(parent: updated, collectionView: collectionView) + collectionView.layoutIfNeeded() + + let updatedCell = try #require( + collectionView.cellForItem(at: IndexPath(item: 0, section: 0)) + ) + #expect(updatedCell.accessibilityValue?.contains("Regenerating title") == true) + #expect(updatedCell.accessibilityCustomActions?.contains { $0.name == "Regenerate title" } == false) + + coordinator.update(parent: initial, collectionView: collectionView) + collectionView.layoutIfNeeded() + + let completedCell = try #require( + collectionView.cellForItem(at: IndexPath(item: 0, section: 0)) + ) + #expect(completedCell.accessibilityValue?.contains("Regenerating title") == false) + #expect(completedCell.accessibilityCustomActions?.contains { $0.name == "Regenerate title" } == true) + } + private func presentation(for model: FeatureRootModel) -> HomePresentation { HomePresentation( snapshot: model.snapshot, @@ -808,6 +856,7 @@ struct HomeThreadSwipeActionTests { snapshot: FeatureSnapshot, query: String = "", selectedThreadID: String? = nil, + regeneratingTitleThreadIDs: Set = [], settlementResult: Bool = true, onSettle: @escaping (FeatureThread, Bool) -> Void = { _, _ in } ) -> HomeThreadCollectionView { @@ -835,6 +884,7 @@ struct HomeThreadSwipeActionTests { onToggleArchive: {}, onShowMoreSettled: {}, onRename: { _ in }, + regeneratingTitleThreadIDs: regeneratingTitleThreadIDs, onRegenerateTitle: { _ in }, onArchive: { _, _ in }, onSettle: { thread, settled, completion in diff --git a/apps/swift-ios/Tests/FeatureTests/ThreadTitleRegenerationMenuTests.swift b/apps/swift-ios/Tests/FeatureTests/ThreadTitleRegenerationMenuTests.swift new file mode 100644 index 000000000000..64dba08b9f93 --- /dev/null +++ b/apps/swift-ios/Tests/FeatureTests/ThreadTitleRegenerationMenuTests.swift @@ -0,0 +1,94 @@ +import Testing +@testable import T3Code + +@Suite("Thread title regeneration menu") +struct ThreadTitleRegenerationMenuTests { + @Test + func hidesUnsupportedThreadsAndKeepsArchivedThreadsAvailable() { + let unsupported = FeatureThread(id: "thread", projectID: "project", title: "Title") + var archived = supportedThread() + archived.isArchived = true + + #expect(ThreadTitleRegenerationMenuState.resolve( + thread: unsupported, + regeneratingThreadIDs: [] + ) == .hidden) + #expect(ThreadTitleRegenerationMenuState.resolve( + thread: archived, + regeneratingThreadIDs: [] + ) == .available) + } + + @Test + func exposesAvailableAndRegeneratingStates() { + let thread = supportedThread() + + #expect(ThreadTitleRegenerationMenuState.resolve( + thread: thread, + regeneratingThreadIDs: [] + ) == .available) + #expect(ThreadTitleRegenerationMenuState.resolve( + thread: thread, + regeneratingThreadIDs: [thread.id] + ) == .regenerating) + + var serverPending = thread + serverPending.isRegeneratingTitle = true + #expect(ThreadTitleRegenerationMenuState.resolve( + thread: serverPending, + regeneratingThreadIDs: [] + ) == .regenerating) + } + + @Test + func retainedRowsReconfigureWhenRegenerationStateChangesDuringAReorder() { + let thread = supportedThread() + let item = HomeCollectionItem.thread( + thread, + HomeThreadRowContext.fallback, + .rich, + false, + false + ) + let identifier = item.id + // Identical item content: regeneration state lives outside the item, + // so only the external set can force a reconfigure. + let itemsByID: [HomeCollectionItem.ID: HomeCollectionItem] = [identifier: item] + + #expect(HomeThreadCollectionView.Coordinator.needsReconfiguration( + identifier: identifier, + retained: [identifier], + previousItems: itemsByID, + itemsByID: itemsByID, + selectionChanged: [], + regenerationChanged: [thread.id] + )) + #expect(!HomeThreadCollectionView.Coordinator.needsReconfiguration( + identifier: identifier, + retained: [identifier], + previousItems: itemsByID, + itemsByID: itemsByID, + selectionChanged: [], + regenerationChanged: [] + )) + // Rows entering the collection configure fresh; only retained ones + // need the explicit reconfigure. + #expect(!HomeThreadCollectionView.Coordinator.needsReconfiguration( + identifier: identifier, + retained: [], + previousItems: itemsByID, + itemsByID: itemsByID, + selectionChanged: [], + regenerationChanged: [thread.id] + )) + } + + private func supportedThread() -> FeatureThread { + FeatureThread( + id: "thread", + projectID: "project", + title: "Title", + supportsTitleRegeneration: true + ) + } +}