From ab1488d2179aa18a80f56625d5051cda9f43282f Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Sat, 12 Sep 2026 15:59:18 +1000 Subject: [PATCH 01/10] feat(swift-ios): add Move up/down context-menu thread reordering The native client had no way to rearrange the thread list even though servers already support the fractional order-key protocol used by web drag and React Native mobile's Move up / Move down. Add both menu actions on pinned and active rows, planned against the canonical section across environments and written through thread.pin.reorder / thread.active.reorder, capability-gated and disabled at section edges. Pinning now also takes the top of the arranged run when the server supports it. Generated with Devin (swe-2-high, T3 Code/Cursor harness) Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- apps/swift-ios/App/NativeFeatureClient.swift | 113 +++++- apps/swift-ios/Core/Models.swift | 9 + apps/swift-ios/Core/T3Client.swift | 83 +++- .../Features/Root/FeatureRootModel.swift | 25 ++ .../Features/Shared/FeatureClient.swift | 12 + .../Features/Shared/FeatureModels.swift | 33 ++ .../Features/Workspace/DailyUXModels.swift | 111 ++++-- .../Workspace/HomeThreadCollectionView.swift | 24 ++ .../Workspace/ThreadOrderPlanner.swift | 199 ++++++++++ .../Features/Workspace/WorkspaceView.swift | 6 + .../Tests/CoreTests/CoreContractTests.swift | 50 +++ .../NativeContractExpansionTests.swift | 74 ++++ .../HomeThreadSwipeActionTests.swift | 2 + .../ThreadOrderPlannerTests.swift | 374 ++++++++++++++++++ 14 files changed, 1070 insertions(+), 45 deletions(-) create mode 100644 apps/swift-ios/Features/Workspace/ThreadOrderPlanner.swift create mode 100644 apps/swift-ios/Tests/FeatureTests/ThreadOrderPlannerTests.swift diff --git a/apps/swift-ios/App/NativeFeatureClient.swift b/apps/swift-ios/App/NativeFeatureClient.swift index 91277dec5e02..6585ebfd8dee 100644 --- a/apps/swift-ios/App/NativeFeatureClient.swift +++ b/apps/swift-ios/App/NativeFeatureClient.swift @@ -82,6 +82,9 @@ final class NativeFeatureClient: FeatureClient, FeatureDeviceManaging, private var latestServerConfig: ServerConfigSnapshot? private var serverConfigsByEnvironmentID: [String: ServerConfigSnapshot] = [:] private var latestSnapshot: FeatureSnapshot? + /// One reorder at a time: a second move planned from the same keys would + /// land a conflicting write (React Native holds a pending order for this). + private var threadMoveInFlight = false private var activeThreadID: String? private var activeThreadEnvironmentID: String? private var latestDetails: [String: FeatureThreadDetail] = [:] @@ -1687,10 +1690,90 @@ final class NativeFeatureClient: FeatureClient, FeatureDeviceManaging, func setThreadPinned(id: String, pinned: Bool) async throws { let route = try threadRoute(for: id) - _ = try await route.client.pin(threadID: route.wireID, pinned: pinned) + // Same placement as web and React Native: a fresh pin takes the top of + // the arranged run. Servers that predate reordering get the bare pin + // (keyless, sorted by creation order below keyed threads). + var orderKey: String? = nil + if pinned, cachedThread(id: route.uiID)?.supportsPinReorder == true { + let firstKey = latestSnapshot?.threads + .filter { $0.pinnedAt != nil } + .compactMap(\.pinOrderKey) + .min() + orderKey = ThreadOrderPlanner.orderKeyBetween(before: nil, after: firstKey) + } + _ = try await route.client.pin(threadID: route.wireID, pinned: pinned, orderKey: orderKey) try? await refresh(client: route.client) } + /// Executes a Move up / Move down from the thread context menu. Plans the + /// move against the canonical ordered section across every connected + /// environment (a spread rewrite can write keys on neighbors hosted by + /// other servers), then refreshes each environment it touched. Returns the + /// assignments the server confirmed so the caller can patch local state if + /// a refresh fell behind. + @discardableResult + func moveThread( + id: String, + direction: FeatureThreadMoveDirection + ) async throws -> [FeatureThreadOrderAssignment] { + guard !threadMoveInFlight else { return [] } + guard let snapshot = latestSnapshot, + let thread = snapshot.threads.first(where: { $0.id == id }) else { + throw NativeFeatureClientError.threadNotFound + } + let section: FeatureThreadOrderSection = thread.pinnedAt != nil ? .pinned : .active + let planner = ThreadOrderPlanner.movePlanner( + ordered: DailyUXSidebarIndex.orderedSection( + snapshot.threads, + section: section, + now: .now + ), + all: snapshot.threads, + section: section + ) + guard let assignments = planner(thread, direction), !assignments.isEmpty else { + return [] + } + + threadMoveInFlight = true + defer { threadMoveInFlight = false } + + var confirmed: [FeatureThreadOrderAssignment] = [] + var touchedEnvironmentIDs = Set() + var firstError: Error? + for assignment in assignments { + do { + let route = try threadRoute(for: assignment.threadID) + switch section { + case .pinned: + _ = try await route.client.reorderPinnedThread( + threadID: route.wireID, + orderKey: assignment.orderKey + ) + case .active: + _ = try await route.client.reorderActiveThread( + threadID: route.wireID, + orderKey: assignment.orderKey + ) + } + confirmed.append(assignment) + touchedEnvironmentIDs.insert(route.environmentID) + } catch { + // Confirmed writes stand: a later environment rejecting its + // write leaves the earlier arrangement in place. + firstError = error + break + } + } + for environmentID in touchedEnvironmentIDs { + if let client = environmentClients[environmentID] { + try? await refresh(client: client) + } + } + if let firstError { throw firstError } + return confirmed + } + func setRuntimeMode(id: String, mode: FeatureRuntimeMode) async throws { let route = try threadRoute(for: id) _ = try await route.client.setRuntimeMode( @@ -5215,6 +5298,8 @@ final class NativeFeatureClient: FeatureClient, FeatureDeviceManaging, supportsSettlement: capabilities?.threadSettlement, supportsSnooze: capabilities?.threadSnooze, supportsPinning: capabilities?.threadPinning, + supportsPinReorder: capabilities?.threadPinReorder, + supportsActiveReorder: capabilities?.threadActiveReorder, supportsTitleRegeneration: capabilities?.threadTitleRegeneration, supportsPullRequestLinking: capabilities?.threadPullRequestLinking, isRegeneratingTitle: thread.titleRegeneration != nil, @@ -5302,6 +5387,8 @@ final class NativeFeatureClient: FeatureClient, FeatureDeviceManaging, supportsSettlement: capabilities?.threadSettlement, supportsSnooze: capabilities?.threadSnooze, supportsPinning: capabilities?.threadPinning, + supportsPinReorder: capabilities?.threadPinReorder, + supportsActiveReorder: capabilities?.threadActiveReorder, supportsTitleRegeneration: capabilities?.threadTitleRegeneration, supportsPullRequestLinking: capabilities?.threadPullRequestLinking, isRegeneratingTitle: thread.titleRegeneration != nil, @@ -5611,6 +5698,7 @@ final class NativeFeatureClient: FeatureClient, FeatureDeviceManaging, snoozedUntil: loaded.snoozedUntil, snoozedAt: loaded.snoozedAt, pinnedAt: loaded.pinnedAt, + pinOrderKey: loaded.pinOrderKey, titleRegeneration: loaded.titleRegeneration, deletedAt: loaded.deletedAt, messages: prependByID(older.messages, loaded.messages), @@ -7133,6 +7221,12 @@ enum NativeThreadDetailReducer { result = reduceUnsettled(payload: payload, thread: thread) case "thread.meta-updated": result = reduceMetadata(payload: payload, occurredAt: occurredAt, thread: thread) + case "thread.pin-reordered": + result = reducePinReordered( + payload: payload, + occurredAt: occurredAt, + thread: thread + ) case "thread.message-sent": result = reduceMessage( payload: payload, @@ -7267,6 +7361,23 @@ enum NativeThreadDetailReducer { return .updated(updated) } + private static func reducePinReordered( + payload: JSONValue, + occurredAt: String, + thread: OrchestrationThread + ) -> NativeThreadDetailReductionResult { + guard let orderKey = payload["orderKey"]?.stringValue, + !orderKey.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else { + return .refresh + } + var updated = replacing( + thread, + updatedAt: payload["updatedAt"]?.stringValue ?? occurredAt + ) + updated.pinOrderKey = orderKey + return .updated(updated) + } + private static func reduceMessage( payload: JSONValue, occurredAt: String, diff --git a/apps/swift-ios/Core/Models.swift b/apps/swift-ios/Core/Models.swift index 6244809db5da..8b7e943fbffe 100644 --- a/apps/swift-ios/Core/Models.swift +++ b/apps/swift-ios/Core/Models.swift @@ -112,6 +112,8 @@ public struct EnvironmentDescriptor: Codable, Equatable, Sendable { public var threadRestartContinuation: Bool? = nil public let threadSnooze: Bool? public let threadPinning: Bool? + public let threadPinReorder: Bool? + public let threadActiveReorder: Bool? public let threadTitleRegeneration: Bool? public let threadPullRequestLinking: Bool? public let serverSelfUpdate: String? @@ -131,6 +133,8 @@ public struct EnvironmentDescriptor: Codable, Equatable, Sendable { case threadRestartContinuation case threadSnooze case threadPinning + case threadPinReorder + case threadActiveReorder case threadTitleRegeneration case threadPullRequestLinking case serverSelfUpdate @@ -165,6 +169,11 @@ public struct EnvironmentDescriptor: Codable, Equatable, Sendable { ) threadSnooze = try container.decodeIfPresent(Bool.self, forKey: .threadSnooze) threadPinning = try container.decodeIfPresent(Bool.self, forKey: .threadPinning) + threadPinReorder = try container.decodeIfPresent(Bool.self, forKey: .threadPinReorder) + threadActiveReorder = try container.decodeIfPresent( + Bool.self, + forKey: .threadActiveReorder + ) threadTitleRegeneration = try container.decodeIfPresent( Bool.self, forKey: .threadTitleRegeneration diff --git a/apps/swift-ios/Core/T3Client.swift b/apps/swift-ios/Core/T3Client.swift index f1a8c55340b8..f4314464b3d7 100644 --- a/apps/swift-ios/Core/T3Client.swift +++ b/apps/swift-ios/Core/T3Client.swift @@ -873,8 +873,34 @@ public actor T3Client { } @discardableResult - public func pin(threadID: String, pinned: Bool) async throws -> DispatchResult { - try await dispatch(OrchestrationCommands.pin(threadID: threadID, pinned: pinned)) + public func pin( + threadID: String, + pinned: Bool, + orderKey: String? = nil + ) async throws -> DispatchResult { + try await dispatch( + OrchestrationCommands.pin(threadID: threadID, pinned: pinned, orderKey: orderKey) + ) + } + + @discardableResult + public func reorderPinnedThread( + threadID: String, + orderKey: String + ) async throws -> DispatchResult { + try await dispatch( + OrchestrationCommands.reorderPinned(threadID: threadID, orderKey: orderKey) + ) + } + + @discardableResult + public func reorderActiveThread( + threadID: String, + orderKey: String + ) async throws -> DispatchResult { + try await dispatch( + OrchestrationCommands.reorderActive(threadID: threadID, orderKey: orderKey) + ) } @discardableResult @@ -2278,13 +2304,56 @@ public enum OrchestrationCommands { public static func pin( threadID: String, pinned: Bool, + orderKey: String? = nil, commandID: String = UUID().uuidString ) -> JSONValue { - basic( - type: pinned ? "thread.pin" : "thread.unpin", - threadID: threadID, - commandID: commandID - ) + guard pinned, let orderKey else { + return basic( + type: pinned ? "thread.pin" : "thread.unpin", + threadID: threadID, + commandID: commandID + ) + } + return .object([ + "type": .string("thread.pin"), + "commandId": .string(commandID), + "threadId": .string(threadID), + "orderKey": .string(orderKey), + ]) + } + + /// Fractional-index reorder: pinned threads sort by `pinOrderKey`, so a + /// move writes one key on one thread (see `thread.pin.reorder` in + /// packages/contracts). `thread.active.reorder` does the same for the + /// active section's `activeOrderKey`. + public static func reorderPinned( + threadID: String, + orderKey: String, + commandID: String = UUID().uuidString + ) -> JSONValue { + reorder(type: "thread.pin.reorder", threadID: threadID, orderKey: orderKey, commandID: commandID) + } + + public static func reorderActive( + threadID: String, + orderKey: String, + commandID: String = UUID().uuidString + ) -> JSONValue { + reorder(type: "thread.active.reorder", threadID: threadID, orderKey: orderKey, commandID: commandID) + } + + private static func reorder( + type: String, + threadID: String, + orderKey: String, + commandID: String + ) -> JSONValue { + .object([ + "type": .string(type), + "commandId": .string(commandID), + "threadId": .string(threadID), + "orderKey": .string(orderKey), + ]) } public static func setRuntimeMode( diff --git a/apps/swift-ios/Features/Root/FeatureRootModel.swift b/apps/swift-ios/Features/Root/FeatureRootModel.swift index ba0b7fd46233..0684023b73ec 100644 --- a/apps/swift-ios/Features/Root/FeatureRootModel.swift +++ b/apps/swift-ios/Features/Root/FeatureRootModel.swift @@ -561,6 +561,31 @@ public final class FeatureRootModel { } } + /// Move up / Move down availability for a thread row, planned on demand + /// against the canonical section (every connected environment). Nil when + /// the row's environment predates reordering. + public func moveOptions(for thread: FeatureThread) -> FeatureThreadMoveOptions? { + DailyUXSidebarIndex.moveOptions(for: thread, in: snapshot.threads, now: .now) + } + + public func moveThread(_ id: String, direction: FeatureThreadMoveDirection) async { + let environment = currentEnvironmentIdentity + await perform { + let assignments = try await client.moveThread(id: id, direction: direction) + guard currentEnvironmentIdentity == environment else { return } + let section: FeatureThreadOrderSection = snapshot.threads + .first(where: { $0.id == id })?.pinnedAt != nil ? .pinned : .active + for assignment in assignments { + mutateThread(id: assignment.threadID) { + switch section { + case .pinned: $0.pinOrderKey = assignment.orderKey + case .active: $0.activeOrderKey = assignment.orderKey + } + } + } + } + } + func updatePullRequest( _ pullRequest: HomeThreadPullRequestPresentation?, threadID: String, diff --git a/apps/swift-ios/Features/Shared/FeatureClient.swift b/apps/swift-ios/Features/Shared/FeatureClient.swift index c014f560de38..db1628e3eb2b 100644 --- a/apps/swift-ios/Features/Shared/FeatureClient.swift +++ b/apps/swift-ios/Features/Shared/FeatureClient.swift @@ -52,6 +52,14 @@ public protocol FeatureClient: AnyObject { func setThreadSettled(id: String, settled: Bool) async throws func setThreadSnoozed(id: String, until: Date?) async throws func setThreadPinned(id: String, pinned: Bool) async throws + /// Executes a Move up / Move down on the thread's list section. Returns + /// the order-key assignments the server confirmed; an empty result means + /// the move was unavailable (capability or section edge). + @discardableResult + func moveThread( + id: String, + direction: FeatureThreadMoveDirection + ) async throws -> [FeatureThreadOrderAssignment] func setRuntimeMode(id: String, mode: FeatureRuntimeMode) async throws func setInteractionMode(id: String, mode: FeatureInteractionMode) async throws func deleteThread(id: String) async throws @@ -398,6 +406,10 @@ public extension FeatureClient { func setThreadSettled(id: String, settled: Bool) async throws {} func setThreadSnoozed(id: String, until: Date?) async throws {} func setThreadPinned(id: String, pinned: Bool) async throws {} + func moveThread( + id: String, + direction: FeatureThreadMoveDirection + ) async throws -> [FeatureThreadOrderAssignment] { [] } func setRuntimeMode(id: String, mode: FeatureRuntimeMode) async throws {} func setInteractionMode(id: String, mode: FeatureInteractionMode) async throws {} func loadReviewFileContents( diff --git a/apps/swift-ios/Features/Shared/FeatureModels.swift b/apps/swift-ios/Features/Shared/FeatureModels.swift index 848bd2f66806..37125fb69cc6 100644 --- a/apps/swift-ios/Features/Shared/FeatureModels.swift +++ b/apps/swift-ios/Features/Shared/FeatureModels.swift @@ -297,6 +297,8 @@ public struct FeatureThread: Identifiable, Sendable, Equatable, Hashable, Codabl public var supportsSettlement: Bool? public var supportsSnooze: Bool? public var supportsPinning: Bool? + public var supportsPinReorder: Bool? + public var supportsActiveReorder: Bool? public var supportsTitleRegeneration: Bool? public var supportsPullRequestLinking: Bool? /// True while the server is generating a new title. Derived from the wire @@ -343,6 +345,8 @@ public struct FeatureThread: Identifiable, Sendable, Equatable, Hashable, Codabl supportsSettlement: Bool? = nil, supportsSnooze: Bool? = nil, supportsPinning: Bool? = nil, + supportsPinReorder: Bool? = nil, + supportsActiveReorder: Bool? = nil, supportsTitleRegeneration: Bool? = nil, supportsPullRequestLinking: Bool? = nil, isRegeneratingTitle: Bool = false, @@ -386,6 +390,8 @@ public struct FeatureThread: Identifiable, Sendable, Equatable, Hashable, Codabl self.supportsSettlement = supportsSettlement self.supportsSnooze = supportsSnooze self.supportsPinning = supportsPinning + self.supportsPinReorder = supportsPinReorder + self.supportsActiveReorder = supportsActiveReorder self.supportsTitleRegeneration = supportsTitleRegeneration self.supportsPullRequestLinking = supportsPullRequestLinking self.isRegeneratingTitle = isRegeneratingTitle @@ -437,6 +443,33 @@ public struct FeatureThread: Identifiable, Sendable, Equatable, Hashable, Codabl } +/// The thread-list section a context-menu move arranges. Pinned threads write +/// `pinOrderKey`; active threads write `activeOrderKey`. +public enum FeatureThreadOrderSection: String, Sendable, Equatable { + case pinned + case active +} + +public enum FeatureThreadMoveDirection: String, Sendable, Equatable { + case up + case down +} + +/// One `orderKey` write needed to realize a move. A move between keyed +/// neighbors produces a single assignment on the moved thread; a move next to +/// keyless threads rewrites the whole section (see ThreadOrderPlanner). +public struct FeatureThreadOrderAssignment: Sendable, Equatable { + /// Feature-scoped thread id (`FeatureScopedID.thread`), so assignments can + /// span environments. + public let threadID: String + public let orderKey: String + + public init(threadID: String, orderKey: String) { + self.threadID = threadID + self.orderKey = orderKey + } +} + public enum FeatureMessageRole: String, Sendable, Codable { case user case assistant diff --git a/apps/swift-ios/Features/Workspace/DailyUXModels.swift b/apps/swift-ios/Features/Workspace/DailyUXModels.swift index 7e3d8e1b3395..2a6516d68bee 100644 --- a/apps/swift-ios/Features/Workspace/DailyUXModels.swift +++ b/apps/swift-ios/Features/Workspace/DailyUXModels.swift @@ -710,32 +710,9 @@ struct DailyUXSidebarIndex { } let available = visible.filter { !$0.isEffectivelySnoozed(at: now) } - pinned = available - .filter { - $0.pinnedAt != nil - && !($0.supportsSettlement == true && $0.isEffectivelySettled()) - } - .sorted(by: Self.pinnedOrder) + pinned = Self.orderedSection(visible, section: .pinned, now: now) - active = available - .filter { - $0.pinnedAt == nil - && !($0.supportsSettlement == true && $0.isEffectivelySettled()) - } - .sorted { lhs, rhs in - switch (lhs.activeOrderKey, rhs.activeOrderKey) { - case (.none, .some): return true - case (.some, .none): return false - case let (.some(left), .some(right)): - return left == right ? Self.activeIdentityOrder(lhs, rhs) : left < right - case (.none, .none): break - } - let leftAnchor = max(lhs.createdAt, lhs.unsettledAt ?? lhs.createdAt) - let rightAnchor = max(rhs.createdAt, rhs.unsettledAt ?? rhs.createdAt) - return leftAnchor == rightAnchor - ? Self.activeIdentityOrder(lhs, rhs) - : leftAnchor > rightAnchor - } + active = Self.orderedSection(visible, section: .active, now: now) snoozed = visible .filter { $0.isEffectivelySnoozed(at: now) } @@ -767,28 +744,88 @@ struct DailyUXSidebarIndex { ) } - /// Same rule as client-runtime `sortPinnedThreadsByOrderKey`: user-arranged - /// keys first by string compare, then keyless pins newest-created first, so - /// every client renders one pinned order. + /// Move up / Move down availability for one row, planned against the + /// canonical section (every environment, before project filtering and + /// search). Nil when the thread's environment predates reordering. + /// Built on demand when a context menu opens — the same per-row check + /// React Native runs — rather than once per row per index rebuild. + static func moveOptions( + for thread: FeatureThread, + in threads: [FeatureThread], + now: Date + ) -> FeatureThreadMoveOptions? { + let section: FeatureThreadOrderSection = thread.pinnedAt != nil ? .pinned : .active + guard ThreadOrderPlanner.isWritable(thread, section: section) else { return nil } + let planner = ThreadOrderPlanner.movePlanner( + ordered: orderedSection(threads, section: section, now: now), + all: threads, + section: section + ) + return FeatureThreadMoveOptions( + canMoveUp: planner(thread, .up) != nil, + canMoveDown: planner(thread, .down) != nil + ) + } + + /// The pinned or active list in display order, independent of project + /// filtering and search — the same canonical section React Native plans + /// `thread.pin.reorder` / `thread.active.reorder` against, so a menu move + /// means the same thing no matter which rows are on screen. + static func orderedSection( + _ threads: [FeatureThread], + section: FeatureThreadOrderSection, + now: Date + ) -> [FeatureThread] { + threads + .filter { thread in + !thread.isArchived + && !thread.isEffectivelySnoozed(at: now) + && !(thread.supportsSettlement == true && thread.isEffectivelySettled()) + && (thread.pinnedAt != nil) == (section == .pinned) + } + .sorted(by: section == .pinned ? pinnedOrder : activeOrder) + } + + /// Keyed rows hold their user-arranged order first; threads pinned by + /// clients that predate reordering keep static creation order below them + /// (`sortPinnedThreadsByOrderKey` in client-runtime). private static func pinnedOrder(_ lhs: FeatureThread, _ rhs: FeatureThread) -> Bool { switch (lhs.pinOrderKey, rhs.pinOrderKey) { - case (.some, .none): return true - case (.none, .some): return false case let (.some(left), .some(right)): - return left == right ? activeIdentityOrder(lhs, rhs) : left < right + return left == right ? identityOrder(lhs, rhs) : left < right + case (.some, .none): + return true + case (.none, .some): + return false case (.none, .none): - return creationOrder(lhs, rhs) + return lhs.createdAt == rhs.createdAt + ? identityOrder(lhs, rhs) + : lhs.createdAt > rhs.createdAt } } - private static func creationOrder(_ lhs: FeatureThread, _ rhs: FeatureThread) -> Bool { - if lhs.createdAt != rhs.createdAt { - return lhs.createdAt > rhs.createdAt + /// New and reopened threads lead the active list. Arranged threads follow + /// their saved keys; activity leaves both groups in place + /// (`sortActiveThreadsByOrderKey` in client-runtime). + private static func activeOrder(_ lhs: FeatureThread, _ rhs: FeatureThread) -> Bool { + switch (lhs.activeOrderKey, rhs.activeOrderKey) { + case (.none, .some): return true + case (.some, .none): return false + case let (.some(left), .some(right)): + return left == right ? identityOrder(lhs, rhs) : left < right + case (.none, .none): break } - return lhs.id < rhs.id + let leftAnchor = max(lhs.createdAt, lhs.unsettledAt ?? lhs.createdAt) + let rightAnchor = max(rhs.createdAt, rhs.unsettledAt ?? rhs.createdAt) + return leftAnchor == rightAnchor + ? identityOrder(lhs, rhs) + : leftAnchor > rightAnchor } - private static func activeIdentityOrder(_ lhs: FeatureThread, _ rhs: FeatureThread) -> Bool { + /// Wire id first, then environment: thread ids are only unique within an + /// environment, and merged sections need both parts or two clients could + /// render equal-key threads in stream-arrival order. + private static func identityOrder(_ lhs: FeatureThread, _ rhs: FeatureThread) -> Bool { let leftID = lhs.wireID ?? lhs.id let rightID = rhs.wireID ?? rhs.id if leftID != rightID { return leftID < rightID } diff --git a/apps/swift-ios/Features/Workspace/HomeThreadCollectionView.swift b/apps/swift-ios/Features/Workspace/HomeThreadCollectionView.swift index e5cf1b3c807f..2ee0ea8348d9 100644 --- a/apps/swift-ios/Features/Workspace/HomeThreadCollectionView.swift +++ b/apps/swift-ios/Features/Workspace/HomeThreadCollectionView.swift @@ -25,6 +25,8 @@ struct HomeThreadCollectionView: UIViewRepresentable { let onSettle: (FeatureThread, Bool, @escaping (Bool) -> Void) -> Void let onSnooze: (FeatureThread, Date?) -> Void let onPin: (FeatureThread, Bool) -> Void + let onMove: (FeatureThread, FeatureThreadMoveDirection) -> Void + let onMoveOptions: (FeatureThread) -> FeatureThreadMoveOptions? let onDelete: (FeatureThread) -> Void let onPullRequestChange: (String, String, HomeThreadPullRequestPresentation?) -> Void @@ -711,6 +713,28 @@ struct HomeThreadCollectionView: UIViewRepresentable { var statusActions: [UIMenuElement] = [] if !isArchived { + // Planned against the canonical section when the menu opens; + // nil means the row's environment predates reordering, and a + // false direction means the move has no valid plan (edge of + // the section or an unwritable neighbor). + if let moveOptions = parent.onMoveOptions(thread) { + for direction in [FeatureThreadMoveDirection.up, .down] { + let enabled = direction == .up + ? moveOptions.canMoveUp + : moveOptions.canMoveDown + statusActions.append( + UIAction( + title: direction == .up ? "Move up" : "Move down", + image: UIImage( + systemName: direction == .up ? "arrow.up" : "arrow.down" + ), + attributes: enabled ? [] : .disabled + ) { [weak self] _ in + self?.parent.onMove(thread, direction) + } + ) + } + } if thread.canTogglePin { let isPinned = thread.pinnedAt != nil statusActions.append( diff --git a/apps/swift-ios/Features/Workspace/ThreadOrderPlanner.swift b/apps/swift-ios/Features/Workspace/ThreadOrderPlanner.swift new file mode 100644 index 000000000000..7f31e148e6f0 --- /dev/null +++ b/apps/swift-ios/Features/Workspace/ThreadOrderPlanner.swift @@ -0,0 +1,199 @@ +import Foundation + +/// Swift port of the order-key math shared by web and React Native +/// (`packages/client-runtime/src/state/threadSort.ts`). Order keys are +/// base-26 fractional indices: a move writes one key that sorts between the +/// moved thread's new neighbors, so threads hosted on other servers are never +/// touched and every client converges on the same order. +enum ThreadOrderPlanner { + private static let digits = Array("abcdefghijklmnopqrstuvwxyz") + + /// Whether the thread's environment accepts `thread.pin.reorder` / + /// `thread.active.reorder` writes for this section. + static func isWritable(_ thread: FeatureThread, section: FeatureThreadOrderSection) -> Bool { + switch section { + case .pinned: thread.supportsPinReorder == true + case .active: thread.supportsActiveReorder == true + } + } + + /// Key that sorts strictly between two neighbors; nil bounds mean "top of + /// the arranged run" / "bottom of the keyed run". Returns nil instead of + /// trapping when existing keys are corrupt or out of order — callers fall + /// back to rewriting the section. + static func orderKeyBetween(before: String?, after: String?) -> String? { + let a = before ?? "" + let b = after ?? "" + if !a.isEmpty, !isValidKey(a) { return nil } + if !b.isEmpty, !isValidKey(b) { return nil } + if !b.isEmpty, a >= b { return nil } + return midpoint(a, b) + } + + /// Evenly spaced keys for materializing an order. Wider keys keep a + /// large section from exhausting the space between two-digit keys + /// (`generateSpreadPinOrderKeys` in client-runtime). + static func spreadKeys(count: Int) -> [String] { + guard count > 0 else { return [] } + var width = 2 + var space = digits.count * digits.count + while space <= (count + 1) * 2 { + width += 1 + space *= digits.count + } + let step = Double(space) / Double(count + 1) + var keys: [String] = [] + for index in 0..