diff --git a/apps/swift-ios/App/NativeFeatureClient.swift b/apps/swift-ios/App/NativeFeatureClient.swift index e3822f8798ec..0c91bf03aaae 100644 --- a/apps/swift-ios/App/NativeFeatureClient.swift +++ b/apps/swift-ios/App/NativeFeatureClient.swift @@ -5282,6 +5282,7 @@ final class NativeFeatureClient: FeatureClient, FeatureDeviceManaging, supportsPinning: environment.descriptor?.capabilities.threadPinning, supportsTitleRegeneration: environment.descriptor?.capabilities.threadTitleRegeneration, supportsPullRequestLinking: environment.descriptor?.capabilities.threadPullRequestLinking, + isRegeneratingTitle: thread.titleRegeneration != nil, attentionAt: failureDate( latestTurn: thread.latestTurn, session: thread.session @@ -5366,6 +5367,7 @@ final class NativeFeatureClient: FeatureClient, FeatureDeviceManaging, supportsPinning: environment.descriptor?.capabilities.threadPinning, supportsTitleRegeneration: environment.descriptor?.capabilities.threadTitleRegeneration, supportsPullRequestLinking: environment.descriptor?.capabilities.threadPullRequestLinking, + isRegeneratingTitle: thread.titleRegeneration != nil, attentionAt: failureDate( latestTurn: thread.latestTurn, session: thread.session @@ -5672,6 +5674,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), @@ -6127,6 +6130,10 @@ final class NativeFeatureClient: FeatureClient, FeatureDeviceManaging, from shell: OrchestrationThreadShell, to thread: inout FeatureThread ) { + // The shell is the freshest source for the title. A cached detail can + // still carry the pre-regeneration title after the server renamed it. + thread.title = shell.title + thread.isRegeneratingTitle = shell.titleRegeneration != nil thread.isSettled = isSettled(shell.settledOverride, settledAt: shell.settledAt) thread.keepsActive = shell.settledOverride == "active" thread.settledAt = shell.settledAt.flatMap(parseValidDate) @@ -7522,6 +7529,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 3c51e51ab162..f20e9f505b28 100644 --- a/apps/swift-ios/Core/Models.swift +++ b/apps/swift-ios/Core/Models.swift @@ -453,6 +453,13 @@ public struct ThreadLinkedPullRequest: Codable, Equatable, Hashable, Sendable { } } +/// Present on a thread while the server is generating a new title for it. +/// Cleared by the server when the regeneration completes or the thread is renamed. +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 @@ -475,6 +482,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 @@ -552,6 +560,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/Features/Chat/ThreadDetailView.swift b/apps/swift-ios/Features/Chat/ThreadDetailView.swift index 7c2b1d5dbe41..8bc96423a1f7 100644 --- a/apps/swift-ios/Features/Chat/ThreadDetailView.swift +++ b/apps/swift-ios/Features/Chat/ThreadDetailView.swift @@ -399,8 +399,12 @@ public struct ThreadDetailView: View { Button { Task { await model.regenerateThreadTitle(thread.id) } } label: { - Label("Regenerate title", systemImage: "sparkles") + Label( + currentThread.isRegeneratingTitle ? "Regenerating title…" : "Regenerate title", + systemImage: "sparkles" + ) } + .disabled(currentThread.isRegeneratingTitle) } Menu { if !FeatureRuntimeMode.allCases.contains(currentThread.runtimeMode) { diff --git a/apps/swift-ios/Features/Shared/FeatureModels.swift b/apps/swift-ios/Features/Shared/FeatureModels.swift index 08ec74b1fa77..2f742e2ed878 100644 --- a/apps/swift-ios/Features/Shared/FeatureModels.swift +++ b/apps/swift-ios/Features/Shared/FeatureModels.swift @@ -292,6 +292,9 @@ public struct FeatureThread: Identifiable, Sendable, Equatable, Hashable, Codabl public var supportsPinning: Bool? public var supportsTitleRegeneration: Bool? public var supportsPullRequestLinking: Bool? + /// True while the server is generating a new title. Derived from the wire + /// snapshot only, the same way the web and React Native clients do it. + public var isRegeneratingTitle: Bool public var attentionAt: Date? public var workingStartedAt: Date? public var latestTurnCompletedAt: Date? @@ -334,6 +337,7 @@ public struct FeatureThread: Identifiable, Sendable, Equatable, Hashable, Codabl supportsPinning: Bool? = nil, supportsTitleRegeneration: Bool? = nil, supportsPullRequestLinking: Bool? = nil, + isRegeneratingTitle: Bool = false, attentionAt: Date? = nil, workingStartedAt: Date? = nil, latestTurnCompletedAt: Date? = nil, @@ -375,6 +379,7 @@ public struct FeatureThread: Identifiable, Sendable, Equatable, Hashable, Codabl self.supportsPinning = supportsPinning self.supportsTitleRegeneration = supportsTitleRegeneration self.supportsPullRequestLinking = supportsPullRequestLinking + self.isRegeneratingTitle = isRegeneratingTitle 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 cd6e5fc42efb..cf66f5c4a3d3 100644 --- a/apps/swift-ios/Features/Workspace/HomeThreadCollectionView.swift +++ b/apps/swift-ios/Features/Workspace/HomeThreadCollectionView.swift @@ -560,7 +560,7 @@ struct HomeThreadCollectionView: UIViewRepresentable { coordinator.parent.onRename(thread) }] - if thread.supportsTitleRegeneration == true { + if thread.supportsTitleRegeneration == true, !thread.isRegeneratingTitle { actions.append(accessibilityAction("Regenerate title", systemImage: "sparkles") { coordinator in coordinator.parent.onRegenerateTitle(thread) }) @@ -680,14 +680,14 @@ struct HomeThreadCollectionView: UIViewRepresentable { var titleActions: [UIMenuElement] = [rename] if thread.supportsTitleRegeneration == true { - titleActions.append( - UIAction( - title: "Regenerate title", - image: UIImage(systemName: "sparkles") - ) { [weak self] _ in - self?.parent.onRegenerateTitle(thread) - } - ) + let regenerate = UIAction( + title: thread.isRegeneratingTitle ? "Regenerating title…" : "Regenerate title", + image: UIImage(systemName: "sparkles") + ) { [weak self] _ in + self?.parent.onRegenerateTitle(thread) + } + regenerate.attributes = thread.isRegeneratingTitle ? .disabled : [] + titleActions.append(regenerate) } let copyActions = ThreadCopyModel.menuActions( for: thread, diff --git a/apps/swift-ios/Features/Workspace/WorkspaceView.swift b/apps/swift-ios/Features/Workspace/WorkspaceView.swift index b1b1c5fd4270..38b83c823cce 100644 --- a/apps/swift-ios/Features/Workspace/WorkspaceView.swift +++ b/apps/swift-ios/Features/Workspace/WorkspaceView.swift @@ -1100,6 +1100,7 @@ struct FeatureThreadRow: View { .font(T3Typography.homeTitle) .tracking(-0.14) .foregroundStyle(T3Colors.textPrimary) + .opacity(titleOpacity) .lineLimit(allowsMultilineTitle ? 2 : 1) .padding(.top, 4) @@ -1156,6 +1157,7 @@ struct FeatureThreadRow: View { Text(thread.title) .font(T3Typography.homeTitle) .foregroundStyle(T3Colors.textSecondary) + .opacity(titleOpacity) .lineLimit(allowsMultilineTitle ? 2 : 1) Spacer(minLength: 8) if let pullRequest { @@ -1198,6 +1200,11 @@ struct FeatureThreadRow: View { .foregroundStyle(statusColor) } + /// Same dimming the web sidebar uses while a title is being regenerated. + private var titleOpacity: Double { + thread.isRegeneratingTitle ? 0.55 : 1 + } + private var statusIcon: String? { switch thread.homeStatus { case .working: "circle.dotted" @@ -1375,6 +1382,9 @@ struct FeatureThreadRow: View { if isConnectionStale { values.append("last known state") } + if thread.isRegeneratingTitle { + values.append("Regenerating title") + } return values.joined(separator: ". ") } diff --git a/apps/swift-ios/Tests/CoreTests/WireFixtureContractTests.swift b/apps/swift-ios/Tests/CoreTests/WireFixtureContractTests.swift index 8680b04881d0..63852e0cec7a 100644 --- a/apps/swift-ios/Tests/CoreTests/WireFixtureContractTests.swift +++ b/apps/swift-ios/Tests/CoreTests/WireFixtureContractTests.swift @@ -128,6 +128,32 @@ final class WireFixtureContractTests: XCTestCase { XCTAssertEqual(threadSnapshot.thread.linkedPullRequest?.repository, "pingdotgg/t3code") } + func testThreadSnapshotsDecodeTitleRegenerationState() throws { + let regeneration: [String: Any] = [ + "requestId": "command-regenerate-title", + "startedAt": "2026-08-07T12:01:00.000Z", + ] + var shell = try XCTUnwrap(try fixtureObject("shell-snapshot") as? [String: Any]) + var shellThread = try XCTUnwrap((shell["threads"] as? [[String: Any]])?.first) + shellThread["titleRegeneration"] = regeneration + shell["threads"] = [shellThread] + let snapshot = try JSONDecoder.t3.decode( + OrchestrationShellSnapshot.self, + from: JSONSerialization.data(withJSONObject: shell) + ) + XCTAssertEqual(snapshot.threads.first?.titleRegeneration?.requestId, "command-regenerate-title") + + var detail = try XCTUnwrap(try fixtureObject("thread-detail-snapshot") as? [String: Any]) + var detailThread = try XCTUnwrap(detail["thread"] as? [String: Any]) + detailThread["titleRegeneration"] = regeneration + detail["thread"] = detailThread + let threadSnapshot = try JSONDecoder.t3.decode( + OrchestrationThreadDetailSnapshot.self, + from: JSONSerialization.data(withJSONObject: detail) + ) + XCTAssertEqual(threadSnapshot.thread.titleRegeneration?.startedAt, "2026-08-07T12:01:00.000Z") + } + func testReopenTimestampsRoundTripAndRemainOptionalForOlderServers() throws { var shell = try decodeFixture("shell-snapshot", as: OrchestrationShellSnapshot.self) var detail = try decodeFixture("thread-detail-snapshot", as: OrchestrationThreadDetailSnapshot.self) diff --git a/apps/swift-ios/Tests/FeatureTests/NativeMultiEnvironmentTests.swift b/apps/swift-ios/Tests/FeatureTests/NativeMultiEnvironmentTests.swift index 56fdcabb2227..7bd8c94c7c49 100644 --- a/apps/swift-ios/Tests/FeatureTests/NativeMultiEnvironmentTests.swift +++ b/apps/swift-ios/Tests/FeatureTests/NativeMultiEnvironmentTests.swift @@ -424,6 +424,42 @@ final class NativeMultiEnvironmentTests: XCTestCase { await fixture.client.disconnect() } + func testNewerShellTitleAndRegenerationStateBeatStaleDetail() async throws { + let fixture = try await Self.makeFixture() + defer { try? FileManager.default.removeItem(at: fixture.directory) } + await fixture.transport.setShell( + multiEnvironmentShell( + projectID: "project-two", + threadID: "thread-two", + title: "Regenerated title", + snapshotSequence: 100, + titleRegeneration: ThreadTitleRegeneration( + requestId: "command-regenerate", + startedAt: "2026-07-31T12:01:00.000Z" + ) + ), + host: "two.example" + ) + await fixture.transport.setDetail( + multiEnvironmentDetail( + projectID: "project-two", + threadID: "thread-two", + snapshotSequence: 90 + ), + host: "two.example" + ) + + let snapshot = try await fixture.client.initialSnapshot() + let thread = try XCTUnwrap(snapshot.threads.first { $0.environmentID == "two" }) + XCTAssertTrue(thread.isRegeneratingTitle) + + // The detail fixture still carries the pre-regeneration title. + let detail = try await fixture.client.loadThread(id: thread.id) + XCTAssertEqual(detail.thread.title, "Regenerated title") + XCTAssertTrue(detail.thread.isRegeneratingTitle) + await fixture.client.disconnect() + } + func testSnapshotKeepsRepositoryIdentityForCrossComputerProjectGrouping() async throws { let identity = RepositoryIdentity( canonicalKey: "github.com/t3/example", @@ -1772,7 +1808,8 @@ func multiEnvironmentShell( backgroundLiveness: OrchestrationBackgroundLiveness? = nil, snapshotSequence: Int = 1, settledOverride: String? = nil, - settledAt: String? = nil + settledAt: String? = nil, + titleRegeneration: ThreadTitleRegeneration? = nil ) -> OrchestrationShellSnapshot { let timestamp = "2026-07-31T12:00:00.000Z" let model = ModelSelection(instanceId: providerID, model: modelID) @@ -1810,6 +1847,7 @@ func multiEnvironmentShell( snoozedUntil: nil, snoozedAt: nil, pinnedAt: nil, + titleRegeneration: titleRegeneration, session: nil, latestUserMessageAt: nil, hasPendingApprovals: false, diff --git a/apps/swift-ios/Tests/FeatureTests/NativeThreadMetadataTests.swift b/apps/swift-ios/Tests/FeatureTests/NativeThreadMetadataTests.swift index 732840a4a8fe..6df43a6e136c 100644 --- a/apps/swift-ios/Tests/FeatureTests/NativeThreadMetadataTests.swift +++ b/apps/swift-ios/Tests/FeatureTests/NativeThreadMetadataTests.swift @@ -71,6 +71,26 @@ struct NativeThreadMetadataTests { #expect(settled.branchPullRequest == reference()) } + @Test + func settlingAndMetadataUpdatesKeepAnActiveTitleRegeneration() throws { + var source = thread() + source.titleRegeneration = ThreadTitleRegeneration( + requestId: "command-regenerate", startedAt: "2026-09-06T19:30:00Z" + ) + let settled = NativeThreadDetailReducer.apply(event( + type: "thread.settled", + payload: ["settledAt": .string("2026-09-06T20:00:00Z")] + ), to: source) + guard case let .updated(afterSettle) = settled.result else { + Issue.record("Expected settlement without a reload") + return + } + #expect(afterSettle.titleRegeneration == source.titleRegeneration) + + let reordered = try reduce(["activeOrderKey": .string("nm")], thread: afterSettle) + #expect(reordered.titleRegeneration == source.titleRegeneration) + } + @Test func ordinaryThreadEventsPreservePRAndManualOrder() throws { var source = thread()