Skip to content
Draft
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
9 changes: 9 additions & 0 deletions apps/swift-ios/App/NativeFeatureClient.swift
Original file line number Diff line number Diff line change
Expand Up @@ -1248,6 +1248,12 @@ final class NativeFeatureClient: FeatureClient, FeatureDeviceManaging,
try? await refresh(client: route.client)
}

func regenerateThreadTitle(id: String) async throws {
let route = try threadRoute(for: id)
_ = try await route.client.regenerateTitle(threadID: route.wireID)
try? await refresh(client: route.client)
}

func setThreadArchived(id: String, archived: Bool) async throws {
let route = try threadRoute(for: id)
let cached = cachedThread(id: route.uiID)
Expand Down Expand Up @@ -2210,6 +2216,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 @@ -3788,6 +3795,7 @@ final class NativeFeatureClient: FeatureClient, FeatureDeviceManaging,
supportsSnooze: environment.descriptor?.capabilities.threadSnooze,
supportsPinning: environment.descriptor?.capabilities.threadPinning,
supportsTitleRegeneration: environment.descriptor?.capabilities.threadTitleRegeneration,
isRegeneratingTitle: thread.titleRegeneration != nil,
attentionAt: failureDate(
latestTurn: thread.latestTurn,
session: thread.session
Expand Down Expand Up @@ -3857,6 +3865,7 @@ final class NativeFeatureClient: FeatureClient, FeatureDeviceManaging,
supportsSnooze: environment.descriptor?.capabilities.threadSnooze,
supportsPinning: environment.descriptor?.capabilities.threadPinning,
supportsTitleRegeneration: environment.descriptor?.capabilities.threadTitleRegeneration,
isRegeneratingTitle: thread.titleRegeneration != nil,
attentionAt: failureDate(
latestTurn: thread.latestTurn,
session: thread.session
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 @@ -359,6 +359,11 @@ public enum OrchestrationBackgroundLiveness: String, Codable, Equatable, Sendabl
case monitoring
}

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 @@ -377,6 +382,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 @@ -450,6 +456,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
17 changes: 17 additions & 0 deletions apps/swift-ios/Core/T3Client.swift
Original file line number Diff line number Diff line change
Expand Up @@ -326,6 +326,11 @@ public actor T3Client {
try await dispatch(OrchestrationCommands.rename(threadID: threadID, title: title))
}

@discardableResult
public func regenerateTitle(threadID: String) async throws -> DispatchResult {
try await dispatch(OrchestrationCommands.regenerateTitle(threadID: threadID))
}

@discardableResult
public func interrupt(threadID: String, turnID: String? = nil) async throws -> DispatchResult {
try await dispatch(
Expand Down Expand Up @@ -1434,6 +1439,18 @@ public enum OrchestrationCommands {
])
}

public static func regenerateTitle(
threadID: String,
commandID: String = UUID().uuidString
) -> JSONValue {
.object([
"type": .string("thread.meta.update"),
"commandId": .string(commandID),
"threadId": .string(threadID),
"regenerateTitle": .bool(true),
])
}

public static func interrupt(
threadID: String,
turnID: String?,
Expand Down
7 changes: 7 additions & 0 deletions apps/swift-ios/Features/Root/FeatureRootModel.swift
Original file line number Diff line number Diff line change
Expand Up @@ -331,6 +331,13 @@ public final class FeatureRootModel {
}
}

@discardableResult
public func regenerateThreadTitle(_ id: String) async -> Bool {
await perform {
try await client.regenerateThreadTitle(id: id)
}
}

public func setArchived(_ id: String, archived: Bool) async {
let environment = currentEnvironmentIdentity
await perform {
Expand Down
4 changes: 4 additions & 0 deletions apps/swift-ios/Features/Shared/FeatureClient.swift
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,7 @@ public protocol FeatureClient: AnyObject {
refresh: Bool
) async throws -> [FeatureWorkspaceBranch]
func renameThread(id: String, title: String) async throws
func regenerateThreadTitle(id: String) async throws
func setThreadArchived(id: String, archived: Bool) async throws
func setThreadSettled(id: String, settled: Bool) async throws
func setThreadSnoozed(id: String, until: Date?) async throws
Expand Down Expand Up @@ -162,6 +163,9 @@ public extension FeatureClient {
}
func releaseThread(id: String) {}
func resolveUserInput(id: String, answers: [String: FeatureInputAnswer]) async throws {}
func regenerateThreadTitle(id: String) async throws {
throw FeatureCapabilityUnavailable("Thread title regeneration")
}

/// Keeps simple text-only callers source-compatible while the typed API
/// preserves multi-select answers as arrays.
Expand Down
3 changes: 3 additions & 0 deletions apps/swift-ios/Features/Shared/FeatureModels.swift
Original file line number Diff line number Diff line change
Expand Up @@ -178,6 +178,7 @@ public struct FeatureThread: Identifiable, Sendable, Equatable, Hashable, Codabl
public var supportsSnooze: Bool?
public var supportsPinning: Bool?
public var supportsTitleRegeneration: Bool?
public var isRegeneratingTitle: Bool?
public var attentionAt: Date?
public var workingStartedAt: Date?
public var latestTurnCompletedAt: Date?
Expand Down Expand Up @@ -213,6 +214,7 @@ public struct FeatureThread: Identifiable, Sendable, Equatable, Hashable, Codabl
supportsSnooze: Bool? = nil,
supportsPinning: Bool? = nil,
supportsTitleRegeneration: Bool? = nil,
isRegeneratingTitle: Bool? = nil,
attentionAt: Date? = nil,
workingStartedAt: Date? = nil,
latestTurnCompletedAt: Date? = nil,
Expand Down Expand Up @@ -247,6 +249,7 @@ public struct FeatureThread: Identifiable, Sendable, Equatable, Hashable, Codabl
self.supportsSnooze = supportsSnooze
self.supportsPinning = supportsPinning
self.supportsTitleRegeneration = supportsTitleRegeneration
self.isRegeneratingTitle = isRegeneratingTitle
self.attentionAt = attentionAt
self.workingStartedAt = workingStartedAt
self.latestTurnCompletedAt = latestTurnCompletedAt
Expand Down
168 changes: 111 additions & 57 deletions apps/swift-ios/Features/Workspace/HomeThreadCollectionView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -18,11 +18,19 @@ struct HomeThreadCollectionView: UIViewRepresentable {
let onToggleSettled: () -> Void
let onToggleArchive: () -> Void
let onShowMoreSettled: () -> Void
let regeneratingThreadIDs: Set<String>
let canCreateThread: (FeatureThread) -> Bool
let onNewThreadOnBranch: (FeatureThread) -> Void
let onRename: (FeatureThread) -> Void
let onRegenerateTitle: (FeatureThread) -> Void
let onArchive: (FeatureThread, Bool) -> Void
let onSettle: (FeatureThread, Bool) -> Void
let onSnooze: (FeatureThread, Date?) -> Void
let onPin: (FeatureThread, Bool) -> Void
let canCopyPath: (FeatureThread) -> Bool
let onCopyPath: (FeatureThread) -> Void
let onCopyBranch: (FeatureThread) -> Void
let onCopyThreadID: (FeatureThread) -> Void
let onDelete: (FeatureThread) -> Void

func makeCoordinator() -> Coordinator {
Expand Down Expand Up @@ -380,70 +388,116 @@ struct HomeThreadCollectionView: UIViewRepresentable {
}

private func menuActions(for thread: FeatureThread, isArchived: Bool) -> [UIMenuElement] {
let rename = UIAction(title: "Rename", image: UIImage(systemName: "pencil")) { [weak self] _ in
self?.parent.onRename(thread)
}
let archive = UIAction(
title: isArchived ? "Restore" : "Archive",
image: UIImage(systemName: isArchived ? "arrow.uturn.backward" : "archivebox")
) { [weak self] _ in
self?.parent.onArchive(thread, !isArchived)
}

var actions: [UIMenuElement] = [rename, archive]
if !isArchived {
if thread.canTogglePin {
let isPinned = thread.pinnedAt != nil
actions.append(
UIAction(
title: isPinned ? "Unpin" : "Pin",
image: UIImage(systemName: isPinned ? "pin.slash" : "pin")
) { [weak self] _ in
self?.parent.onPin(thread, !isPinned)
}
)
}
if thread.canToggleSettlement {
let isSettled = thread.isEffectivelySettled(at: .now)
actions.append(
UIAction(
title: isSettled ? "Reopen" : "Settle",
image: UIImage(
systemName: isSettled ? "arrow.counterclockwise" : "checkmark"
)
) { [weak self] _ in
self?.parent.onSettle(thread, !isSettled)
ThreadContextMenuModel.items(
for: thread,
isArchived: isArchived,
canCreateThread: parent.canCreateThread(thread),
canCopyPath: parent.canCopyPath(thread)
).map { item in
switch item {
case let .newThread(branch):
return UIAction(
title: "New thread on \(branch)",
image: UIImage(systemName: "plus.bubble")
) { [weak self] _ in
self?.parent.onNewThreadOnBranch(thread)
}
case .rename:
return UIAction(
title: "Rename thread",
image: UIImage(systemName: "pencil")
) { [weak self] _ in
self?.parent.onRename(thread)
}
case .regenerateTitle:
let action = UIAction(
title: parent.regeneratingThreadIDs.contains(thread.id)
? "Regenerating…"
: "Regenerate title",
image: UIImage(systemName: "sparkles")
) { [weak self] _ in
self?.parent.onRegenerateTitle(thread)
}
if parent.regeneratingThreadIDs.contains(thread.id) {
action.attributes = .disabled
}
return action
case let .archive(archived):
return UIAction(
title: archived ? "Restore" : "Archive",
image: UIImage(
systemName: archived ? "arrow.uturn.backward" : "archivebox"
)
) { [weak self] _ in
self?.parent.onArchive(thread, !archived)
}
case let .pin(pinned):
return UIAction(
title: pinned ? "Unpin thread" : "Pin thread",
image: UIImage(systemName: pinned ? "pin.slash" : "pin")
) { [weak self] _ in
self?.parent.onPin(thread, !pinned)
}
case let .settle(settled):
return UIAction(
title: settled ? "Reopen thread" : "Settle thread",
image: UIImage(
systemName: settled ? "arrow.counterclockwise" : "checkmark"
)
) { [weak self] _ in
self?.parent.onSettle(thread, !settled)
}
case let .snooze(presets, enabled):
let actions = presets.map { preset in
let action = UIAction(title: preset.menuTitle) { [weak self] _ in
self?.parent.onSnooze(thread, preset.until)
}
if !enabled { action.attributes = .disabled }
return action
}
return UIMenu(
title: "Snooze",
image: UIImage(systemName: "clock"),
children: actions
)
}

if thread.canToggleSnooze {
let isSnoozed = thread.isEffectivelySnoozed(at: .now)
let snooze = UIAction(
title: isSnoozed ? "Unsnooze" : "Snooze 1 hour",
image: UIImage(systemName: isSnoozed ? "bell" : "clock")
case .unsnooze:
return UIAction(
title: "Wake thread",
image: UIImage(systemName: "bell")
) { [weak self] _ in
self?.parent.onSnooze(
thread,
isSnoozed ? nil : Date.now.addingTimeInterval(60 * 60)
)
self?.parent.onSnooze(thread, nil)
}
if thread.state == .queued
|| thread.state == .waitingForApproval
|| thread.state == .waitingForInput {
snooze.attributes = .disabled
case .copyPath:
return UIAction(
title: "Copy path",
image: UIImage(systemName: "doc.on.doc")
) { [weak self] _ in
self?.parent.onCopyPath(thread)
}
case .copyBranch:
return UIAction(
title: "Copy branch",
image: UIImage(systemName: "arrow.triangle.branch")
) { [weak self] _ in
self?.parent.onCopyBranch(thread)
}
case .copyThreadID:
return UIAction(
title: "Copy Thread ID",
image: UIImage(systemName: "number")
) { [weak self] _ in
self?.parent.onCopyThreadID(thread)
}
case .delete:
return UIAction(
title: "Delete",
image: UIImage(systemName: "trash"),
attributes: .destructive
) { [weak self] _ in
self?.parent.onDelete(thread)
}
actions.append(snooze)
}
}

actions.append(
UIAction(title: "Delete", image: UIImage(systemName: "trash"), attributes: .destructive) {
[weak self] _ in
self?.parent.onDelete(thread)
}
)
return actions
}

/// Working rows show a live per-second duration, so they need a 1 Hz
Expand Down
24 changes: 24 additions & 0 deletions apps/swift-ios/Features/Workspace/NewTaskWorkspaceModels.swift
Original file line number Diff line number Diff line change
Expand Up @@ -69,6 +69,30 @@ enum NewTaskWorkspaceDefaults {
?? branches.first
}

static func refreshedSelection(
_ selection: FeatureWorkspaceBranch?,
in branches: [FeatureWorkspaceBranch],
mode: FeatureWorkspaceMode,
preserveMissingSelection: Bool = false
) -> FeatureWorkspaceBranch? {
guard let selection else {
return switch mode {
case .local: localBranch(in: branches)
case .worktree: worktreeBase(in: branches)
}
}
if let refreshed = branches.first(where: { $0.name == selection.name }) {
return refreshed
}
guard preserveMissingSelection else {
return switch mode {
case .local: localBranch(in: branches)
case .worktree: worktreeBase(in: branches)
}
}
return selection
}

static func normalizedWorktreePath(
for branch: FeatureWorkspaceBranch?,
projectPath: String
Expand Down
Loading
Loading