Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions apps/swift-ios/App/NativeFeatureClient.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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,
Comment thread
macroscopeapp[bot] marked this conversation as resolved.
attentionAt: failureDate(
latestTurn: thread.latestTurn,
session: thread.session
Expand Down Expand Up @@ -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),
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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,
Expand Down
9 changes: 9 additions & 0 deletions apps/swift-ios/Core/Models.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand Down Expand Up @@ -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]
Expand Down
6 changes: 5 additions & 1 deletion apps/swift-ios/Features/Chat/ThreadDetailView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down
5 changes: 5 additions & 0 deletions apps/swift-ios/Features/Shared/FeatureModels.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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?
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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
Expand Down
18 changes: 9 additions & 9 deletions apps/swift-ios/Features/Workspace/HomeThreadCollectionView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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)
})
Expand Down Expand Up @@ -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,
Expand Down
10 changes: 10 additions & 0 deletions apps/swift-ios/Features/Workspace/WorkspaceView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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)

Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -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"
Expand Down Expand Up @@ -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: ". ")
}

Expand Down
26 changes: 26 additions & 0 deletions apps/swift-ios/Tests/CoreTests/WireFixtureContractTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -1810,6 +1847,7 @@ func multiEnvironmentShell(
snoozedUntil: nil,
snoozedAt: nil,
pinnedAt: nil,
titleRegeneration: titleRegeneration,
session: nil,
latestUserMessageAt: nil,
hasPendingApprovals: false,
Expand Down
20 changes: 20 additions & 0 deletions apps/swift-ios/Tests/FeatureTests/NativeThreadMetadataTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down
Loading