Skip to content
Closed
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
72 changes: 68 additions & 4 deletions apps/swift-ios/App/NativeFeatureClient.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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,
Expand All @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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),
Expand Down Expand Up @@ -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,
Expand Down
7 changes: 7 additions & 0 deletions apps/swift-ios/Core/Models.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand Down Expand Up @@ -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]
Expand Down
9 changes: 7 additions & 2 deletions apps/swift-ios/Core/T3Client.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
18 changes: 15 additions & 3 deletions apps/swift-ios/Features/Chat/ThreadDetailView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -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 {
Expand Down
Loading
Loading