From d309a52f407f02f389c4e10b6e507bbf0cad5166 Mon Sep 17 00:00:00 2001 From: Theo Browne Date: Tue, 8 Sep 2026 04:18:05 -0700 Subject: [PATCH 1/2] feat(swift-ios): show title regeneration state from the server snapshot The server marks a thread with titleRegeneration while it generates a new title, and web and React Native dim the row and disable the menu item from that field. The SwiftUI client did not decode it, so a tap on Regenerate title gave no feedback and could be repeated. A cached detail could also republish the pre-regeneration title because shell merges did not copy the title. Decode titleRegeneration on shell and detail threads, expose it as isRegeneratingTitle on FeatureThread, dim the row title and disable the Regenerate title actions while it is set, and copy the shell title into the cached detail when the shell is at least as new. Co-Authored-By: Claude Fable 5.1 --- apps/swift-ios/App/NativeFeatureClient.swift | 6 +++ apps/swift-ios/Core/Models.swift | 9 +++++ .../Features/Chat/ThreadDetailView.swift | 6 ++- .../Features/Shared/FeatureModels.swift | 5 +++ .../Workspace/HomeThreadCollectionView.swift | 18 ++++----- .../Features/Workspace/WorkspaceView.swift | 10 +++++ .../CoreTests/WireFixtureContractTests.swift | 26 ++++++++++++ .../NativeMultiEnvironmentTests.swift | 40 ++++++++++++++++++- 8 files changed, 109 insertions(+), 11 deletions(-) diff --git a/apps/swift-ios/App/NativeFeatureClient.swift b/apps/swift-ios/App/NativeFeatureClient.swift index e3822f8798ec..ab018bbe684e 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 @@ -6127,6 +6129,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) 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, From e295851494403c2fe304f35dcd315108f491087a Mon Sep 17 00:00:00 2001 From: Theo Browne Date: Tue, 8 Sep 2026 04:59:20 -0700 Subject: [PATCH 2/2] fix(swift-ios): keep title regeneration state through local thread rebuilds mergingOlderHistory and the settle/metadata reducers rebuild OrchestrationThread field by field and left titleRegeneration at its nil default, so loading an older page or settling a thread cleared the pending state and re-enabled Regenerate title while the server was still working. Co-Authored-By: Claude Fable 5.1 --- apps/swift-ios/App/NativeFeatureClient.swift | 2 ++ .../NativeThreadMetadataTests.swift | 20 +++++++++++++++++++ 2 files changed, 22 insertions(+) diff --git a/apps/swift-ios/App/NativeFeatureClient.swift b/apps/swift-ios/App/NativeFeatureClient.swift index ab018bbe684e..0c91bf03aaae 100644 --- a/apps/swift-ios/App/NativeFeatureClient.swift +++ b/apps/swift-ios/App/NativeFeatureClient.swift @@ -5674,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), @@ -7528,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/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()