diff --git a/apps/swift-ios/App/NativeFeatureClient.swift b/apps/swift-ios/App/NativeFeatureClient.swift index 91277dec5e02..707f09cdd4f3 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,133 @@ 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) } + /// Saves a full-section order. Cross-section moves first clear the source + /// lifecycle state. Every key write goes to the thread's owning server. + @discardableResult + func reorderThread( + id: String, + section: FeatureThreadOrderSection, + orderedIDs: [String] + ) async throws -> [FeatureThreadOrderAssignment] { + guard !threadMoveInFlight else { return [] } + guard let snapshot = latestSnapshot else { + throw NativeFeatureClientError.threadNotFound + } + // Only currently-connected environments are writable; a disconnected + // server's rows keep their stale keys as anchors but never receive + // writes, so a spread rewrite cannot half-land on a dead client. + let connectedEnvironmentIDs = Set( + snapshot.environments + .filter { $0.isEnabled && environmentConnectionStates[$0.id] == .connected } + .map(\.id) + ) + let threadsByID = Dictionary( + snapshot.threads.map { ($0.id, $0) }, + uniquingKeysWith: { first, _ in first } + ) + let now = Date.now + let canonical = DailyUXSidebarIndex.orderedSection(snapshot.threads, section: section, now: now) + guard let moved = threadsByID[id], + ThreadArrangementPlanner.canEnter(moved, section: section, now: now), + Set(orderedIDs).count == orderedIDs.count, + Set(orderedIDs) == Set(canonical.map(\.id)).union([id]), + let assignments = ThreadOrderPlanner.planDrop( + ordered: orderedIDs.compactMap { threadsByID[$0] }, + all: snapshot.threads, + section: section, + connectedEnvironmentIDs: connectedEnvironmentIDs, + movedID: id + ) else { + return [] + } + + threadMoveInFlight = true + defer { threadMoveInFlight = false } + + var confirmed: [FeatureThreadOrderAssignment] = [] + var touchedEnvironmentIDs = Set() + var firstError: Error? + let crossesSection = !canonical.contains(where: { $0.id == id }) + if crossesSection { + do { + let route = try threadRoute(for: id) + touchedEnvironmentIDs.insert(route.environmentID) + for action in ThreadArrangementPlanner.lifecycle(moved, section: section, now: now) { + switch action { + case .pin: + let key = assignments.first(where: { $0.threadID == id })?.orderKey + _ = try await route.client.pin(threadID: route.wireID, pinned: true, orderKey: key) + case .unpin: + _ = try await route.client.pin(threadID: route.wireID, pinned: false) + case .unsettle: + _ = try await route.client.settle(threadID: route.wireID, settled: false) + case .unsnooze: + _ = try await route.client.snooze(threadID: route.wireID, until: nil) + } + } + } catch { + firstError = error + } + } + for assignment in assignments { + guard firstError == nil else { break } + do { + let route = try threadRoute(for: assignment.threadID) + // Refresh even when the transport lost a receipt after the + // server accepted the write. + touchedEnvironmentIDs.insert(route.environmentID) + 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) + } 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 { + guard confirmed.isEmpty else { + throw FeatureThreadMovePartialError( + confirmed: confirmed, + underlying: 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 +5341,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 +5430,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 +5741,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 +7264,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 +7404,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..1ed729d71183 100644 --- a/apps/swift-ios/Features/Root/FeatureRootModel.swift +++ b/apps/swift-ios/Features/Root/FeatureRootModel.swift @@ -69,6 +69,7 @@ public final class FeatureRootModel { private(set) var detailRenderUpdates: [String: FeatureDetailRenderUpdate] = [:] public private(set) var isLoading = true public private(set) var isPerformingAction = false + private(set) var isArrangingThreads = false /// Approval and question IDs with a response in flight. Views disable /// only that request, not every request in every thread. public private(set) var resolvingRequestIDs: Set = [] @@ -561,6 +562,97 @@ public final class FeatureRootModel { } } + func arrangementPlan( + id: String, + destination: ThreadArrangementDestination + ) -> ThreadArrangementPlanner.Plan? { + guard !isArrangingThreads else { return nil } + return ThreadArrangementPlanner.plan( + id: id, + destination: destination, + threads: snapshot.threads, + connectedEnvironmentIDs: Set(snapshot.environments.filter { + $0.isEnabled && $0.connectionState == .connected + }.map(\.id)), + now: .now + ) + } + + @discardableResult + func arrangeThread(_ id: String, destination: ThreadArrangementDestination) async -> Bool { + guard let plan = arrangementPlan(id: id, destination: destination), + let previous = snapshot.threads.first(where: { $0.id == id }) else { return false } + isArrangingThreads = true + defer { isArrangingThreads = false } + guard let section = plan.section else { return await setSettled(id, settled: true) } + let environment = currentEnvironmentIdentity + let succeeded = await reorderThread(id, section: section, orderedIDs: plan.orderedIDs) + guard succeeded, currentEnvironmentIdentity == environment else { return false } + if ThreadArrangementPlanner.section(of: previous, now: .now).orderSection != section { + mutateThread(id: id) { + $0.pinnedAt = section == .pinned ? .now : nil + $0.snoozedUntil = nil + $0.snoozedAt = nil + if section == .active { $0.pinOrderKey = nil } + if previous.isEffectivelySettled() { + $0.isSettled = false + $0.keepsActive = true + $0.settledAt = nil + $0.unsettledAt = .now + $0.settlementFacts?.settlementOverride = .active + } + } + } + return true + } + + /// Commits a dropped order: `orderedIDs` is the section's displayed order + /// with the dragged row already in its new slot. Returns false when the + /// write was rejected or failed so the collection view can snap the row + /// back. + @discardableResult + public func reorderThread( + _ id: String, + section: FeatureThreadOrderSection, + orderedIDs: [String] + ) async -> Bool { + let environment = currentEnvironmentIdentity + do { + let assignments = try await client.reorderThread( + id: id, + section: section, + orderedIDs: orderedIDs + ) + applyMoveAssignments(assignments, section: section, environment: environment) + return !assignments.isEmpty + } catch let partial as FeatureThreadMovePartialError { + applyMoveAssignments(partial.confirmed, section: section, environment: environment) + await perform { throw partial.underlying } + // Confirmed keys are in the model; snapping back renders the true + // arrangement instead of holding a full order that never landed. + return false + } catch { + await perform { throw error } + return false + } + } + + private func applyMoveAssignments( + _ assignments: [FeatureThreadOrderAssignment], + section: FeatureThreadOrderSection, + environment: String + ) { + guard currentEnvironmentIdentity == environment else { return } + 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..72ff399618e1 100644 --- a/apps/swift-ios/Features/Shared/FeatureClient.swift +++ b/apps/swift-ios/Features/Shared/FeatureClient.swift @@ -52,6 +52,16 @@ 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 + /// Commits a drag reorder: `orderedIDs` is the thread's displayed section + /// order after the drop. Returns the order-key assignments the server + /// confirmed; an empty result means the move was unavailable (capability, + /// connectivity, or an unwritable neighbor). + @discardableResult + func reorderThread( + id: String, + section: FeatureThreadOrderSection, + orderedIDs: [String] + ) 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 +408,11 @@ 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 reorderThread( + id: String, + section: FeatureThreadOrderSection, + orderedIDs: [String] + ) 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..9c358f9713cd 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,41 @@ public struct FeatureThread: Identifiable, Sendable, Equatable, Hashable, Codabl } +/// The thread-list section a drag reorder arranges. Pinned threads write +/// `pinOrderKey`; active threads write `activeOrderKey`. +public enum FeatureThreadOrderSection: String, Sendable, Equatable { + case pinned + case active +} + +/// 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 + } +} + +/// A spread rewrite that wrote some assignments before a later environment +/// rejected its write. Callers apply `confirmed` — those rows are arranged on +/// their servers — and surface `underlying` as the move's failure. +public struct FeatureThreadMovePartialError: Error, Sendable { + public let confirmed: [FeatureThreadOrderAssignment] + public let underlying: Error + + public init(confirmed: [FeatureThreadOrderAssignment], underlying: Error) { + self.confirmed = confirmed + self.underlying = underlying + } +} + 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..059047939769 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,65 @@ 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. + /// 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 reorder + /// 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..73a8624c1b87 100644 --- a/apps/swift-ios/Features/Workspace/HomeThreadCollectionView.swift +++ b/apps/swift-ios/Features/Workspace/HomeThreadCollectionView.swift @@ -25,6 +25,7 @@ struct HomeThreadCollectionView: UIViewRepresentable { let onSettle: (FeatureThread, Bool, @escaping (Bool) -> Void) -> Void let onSnooze: (FeatureThread, Date?) -> Void let onPin: (FeatureThread, Bool) -> Void + let onArrange: () -> Void let onDelete: (FeatureThread) -> Void let onPullRequestChange: (String, String, HomeThreadPullRequestPresentation?) -> Void @@ -565,6 +566,9 @@ struct HomeThreadCollectionView: UIViewRepresentable { } if !isArchived { + actions.append(accessibilityAction("Arrange threads", systemImage: "line.3.horizontal") { coordinator in + coordinator.parent.onArrange() + }) if thread.canTogglePin { let isPinned = thread.pinnedAt != nil actions.append(accessibilityAction( @@ -711,6 +715,12 @@ struct HomeThreadCollectionView: UIViewRepresentable { var statusActions: [UIMenuElement] = [] if !isArchived { + statusActions.append(UIAction( + title: "Arrange threads", + image: UIImage(systemName: "line.3.horizontal") + ) { [weak self] _ in + self?.parent.onArrange() + }) if thread.canTogglePin { let isPinned = thread.pinnedAt != nil statusActions.append( diff --git a/apps/swift-ios/Features/Workspace/ThreadArrangementPlanner.swift b/apps/swift-ios/Features/Workspace/ThreadArrangementPlanner.swift new file mode 100644 index 000000000000..1a2e3e2a1641 --- /dev/null +++ b/apps/swift-ios/Features/Workspace/ThreadArrangementPlanner.swift @@ -0,0 +1,137 @@ +import Foundation + +enum ThreadArrangementSection: String, CaseIterable, Hashable { + case pinned, active, snoozed, settled + + var title: String { rawValue.capitalized } + + var orderSection: FeatureThreadOrderSection? { + switch self { + case .pinned: .pinned + case .active: .active + case .snoozed, .settled: nil + } + } +} + +struct ThreadArrangementDestination: Equatable { + let section: ThreadArrangementSection + var targetID: String? = nil + var after = false +} + +struct ThreadArrangementRow { + let section: ThreadArrangementSection + var thread: FeatureThread? + var id: String { thread.map { "thread:\($0.id)" } ?? "section:\(section.rawValue)" } +} + +/// Resolves drops against all environments, without the Home project filter. +/// A destination identifies a row rather than an index so a stale drag cannot +/// silently move next to a different thread. +enum ThreadArrangementPlanner { + enum LifecycleAction: Equatable { + case pin, unpin, unsettle, unsnooze + } + + struct Plan { + let section: FeatureThreadOrderSection? + let orderedIDs: [String] + let assignments: [FeatureThreadOrderAssignment] + } + + static func destination( + rows: [ThreadArrangementRow], + insertionIndex: Int, + isBeforeHeader: Bool + ) -> ThreadArrangementDestination? { + if insertionIndex == rows.count, let last = rows.last { + return .init(section: last.section, targetID: last.thread?.id, after: true) + } + guard rows.indices.contains(insertionIndex) else { return nil } + let row = rows[insertionIndex] + // UIKit uses the next header's index for the preceding section's end. + // A pointer on the header itself still targets that section, including + // empty sections and the collapsed Settled shelf. + if row.thread == nil, isBeforeHeader, insertionIndex > 0 { + let previous = rows[insertionIndex - 1] + return .init(section: previous.section, targetID: previous.thread?.id, after: true) + } + return .init(section: row.section, targetID: row.thread?.id) + } + + static func lifecycle( + _ thread: FeatureThread, + section: FeatureThreadOrderSection, + now: Date + ) -> [LifecycleAction] { + if section == .pinned { return [.pin] } + var actions: [LifecycleAction] = [] + if thread.pinnedAt != nil { actions.append(.unpin) } + if thread.isEffectivelySettled() { actions.append(.unsettle) } + if thread.isEffectivelySnoozed(at: now) { actions.append(.unsnooze) } + return actions + } + + static func section(of thread: FeatureThread, now: Date) -> ThreadArrangementSection { + if thread.isEffectivelySnoozed(at: now) { return .snoozed } + if thread.supportsSettlement == true, thread.isEffectivelySettled() { return .settled } + return thread.pinnedAt == nil ? .active : .pinned + } + + static func canEnter( + _ thread: FeatureThread, + section: FeatureThreadOrderSection, + now: Date + ) -> Bool { + guard !thread.isArchived, ThreadOrderPlanner.isWritable(thread, section: section) else { + return false + } + if thread.isEffectivelySettled(), thread.supportsSettlement != true { return false } + let source = self.section(of: thread, now: now) + if source.orderSection == section { return true } + if (section == .pinned || thread.pinnedAt != nil), thread.supportsPinning != true { + return false + } + if thread.isEffectivelySnoozed(at: now), thread.supportsSnooze != true { return false } + return true + } + + static func plan( + id: String, + destination: ThreadArrangementDestination, + threads: [FeatureThread], + connectedEnvironmentIDs: Set, + now: Date + ) -> Plan? { + guard let moved = threads.first(where: { $0.id == id }), + !moved.isArchived, + connectedEnvironmentIDs.contains(moved.environmentID ?? "") else { return nil } + if destination.section == .settled { + guard moved.supportsSettlement == true, !moved.isEffectivelySettled(), + moved.canSettleNow(at: now) else { return nil } + return Plan(section: nil, orderedIDs: [], assignments: []) + } + guard let section = destination.section.orderSection, + canEnter(moved, section: section, now: now) else { return nil } + let current = DailyUXSidebarIndex.orderedSection(threads, section: section, now: now) + var ordered = current.filter { $0.id != id } + let index: Int + if let target = destination.targetID { + guard let targetIndex = ordered.firstIndex(where: { $0.id == target }) else { return nil } + index = targetIndex + (destination.after ? 1 : 0) + } else { + index = destination.after ? ordered.count : 0 + } + ordered.insert(moved, at: index) + guard ordered.map(\.id) != current.map(\.id), + let assignments = ThreadOrderPlanner.planDrop( + ordered: ordered, + all: threads, + section: section, + connectedEnvironmentIDs: connectedEnvironmentIDs, + movedID: id + ) else { return nil } + return Plan(section: section, orderedIDs: ordered.map(\.id), assignments: assignments) + } +} diff --git a/apps/swift-ios/Features/Workspace/ThreadArrangementView.swift b/apps/swift-ios/Features/Workspace/ThreadArrangementView.swift new file mode 100644 index 000000000000..fbe14cb1b14d --- /dev/null +++ b/apps/swift-ios/Features/Workspace/ThreadArrangementView.swift @@ -0,0 +1,353 @@ +import SwiftUI +import UIKit + +/// Lives on the workspace, not a row, so moving the source to another shelf +/// does not dismiss the sheet. +struct ThreadArrangementView: View { + @SwiftUI.Environment(\.dismiss) private var dismiss + @Bindable var model: FeatureRootModel + @State private var action: String? + @State private var now = Date.now + + var body: some View { + NavigationStack { + ThreadArrangementCollection( + model: model, + revision: model.homePresentationRevision, + busy: model.isArrangingThreads, + now: now, + onAction: { action = $0 } + ) + .background(.black) + .navigationTitle(action ?? "Arrange threads") + .navigationBarTitleDisplayMode(.inline) + .toolbar { + ToolbarItem(placement: .confirmationAction) { + Button("Done") { dismiss() } + .disabled(model.isArrangingThreads) + } + } + } + .preferredColorScheme(.dark) + .interactiveDismissDisabled(model.isArrangingThreads) + .alert("Could not arrange threads", isPresented: Binding( + get: { model.errorMessage != nil }, + set: { if !$0 { model.errorMessage = nil } } + )) { + Button("OK") { model.errorMessage = nil } + } message: { + Text(model.errorMessage ?? "The move failed.") + } + .task(id: nextWake) { + guard let wake = nextWake else { return } + do { + try await Task.sleep(for: .seconds(max(0, wake.timeIntervalSinceNow))) + now = max(.now, wake) + } catch {} + } + } + + private var nextWake: Date? { + model.snapshot.threads.compactMap(\.snoozedUntil).filter { $0 > now }.min() + } +} + +private struct ThreadArrangementCollection: UIViewRepresentable { + let model: FeatureRootModel + let revision: UInt64 + let busy: Bool + let now: Date + let onAction: (String?) -> Void + + func makeCoordinator() -> Coordinator { Coordinator(parent: self) } + + func makeUIView(context: Context) -> UICollectionView { + var layout = UICollectionLayoutListConfiguration(appearance: .plain) + layout.backgroundColor = .black + layout.showsSeparators = false + let view = UICollectionView( + frame: .zero, + collectionViewLayout: UICollectionViewCompositionalLayout.list(using: layout) + ) + view.backgroundColor = .black + view.alwaysBounceVertical = true + view.dragInteractionEnabled = true + view.delegate = context.coordinator + view.dragDelegate = context.coordinator + view.dropDelegate = context.coordinator + context.coordinator.configure(view) + return view + } + + func updateUIView(_ view: UICollectionView, context: Context) { + context.coordinator.parent = self + context.coordinator.update() + } + + static func dismantleUIView(_ view: UICollectionView, coordinator: Coordinator) { + view.delegate = nil + view.dragDelegate = nil + view.dropDelegate = nil + } + + @MainActor + final class Coordinator: NSObject, UICollectionViewDelegate, + UICollectionViewDragDelegate, UICollectionViewDropDelegate { + typealias Row = ThreadArrangementRow + + var parent: ThreadArrangementCollection + private weak var view: UICollectionView? + private var dataSource: UICollectionViewDiffableDataSource? + private var rows: [Row] = [] + private var expanded = Set() + private var draggedID: String? + private var pendingRows: [Row]? + + init(parent: ThreadArrangementCollection) { self.parent = parent } + + func configure(_ view: UICollectionView) { + self.view = view + let registration = UICollectionView.CellRegistration { + [weak self] cell, _, id in + self?.configure(cell, id: id) + } + dataSource = UICollectionViewDiffableDataSource(collectionView: view) { + collection, indexPath, id in + collection.dequeueConfiguredReusableCell(using: registration, for: indexPath, item: id) + } + update() + } + + func update(completion: (() -> Void)? = nil) { + guard draggedID == nil || pendingRows != nil else { return } + if let pendingRows { + rows = pendingRows + } else { + let index = DailyUXSidebarIndex(snapshot: parent.model.snapshot, query: "", now: parent.now) + rows = ThreadArrangementSection.allCases.flatMap { section -> [Row] in + let threads: [FeatureThread] + switch section { + case .pinned: threads = index.pinned + case .active: threads = index.active + case .snoozed: threads = index.snoozed + case .settled: threads = index.settled + } + if section == .snoozed, threads.isEmpty { return [] } + let visible = section.orderSection != nil || expanded.contains(section) + return [Row(section: section)] + (visible ? threads.map { Row(section: section, thread: $0) } : []) + } + } + var snapshot = NSDiffableDataSourceSnapshot() + snapshot.appendSections([0]) + snapshot.appendItems(rows.map(\.id)) + if let existing = dataSource?.snapshot().itemIdentifiers { + let previousIDs = Set(existing) + snapshot.reconfigureItems(rows.map(\.id).filter { previousIDs.contains($0) }) + } + dataSource?.apply(snapshot, animatingDifferences: !UIAccessibility.isReduceMotionEnabled, completion: completion) + } + + private func configure(_ cell: UICollectionViewListCell, id: String) { + guard let row = rows.first(where: { $0.id == id }) else { return } + cell.backgroundConfiguration = .clear() + cell.accessories = [] + if let thread = row.thread { + let enabled = !parent.model.isArrangingThreads && canLift(thread) + cell.contentConfiguration = UIHostingConfiguration { + HStack(spacing: 12) { + VStack(alignment: .leading, spacing: 2) { + Text(thread.title).font(.system(size: 15)).foregroundStyle(.white).lineLimit(1) + if let name = thread.environmentName { + Text(name).font(.system(size: 11)).foregroundStyle(.gray).lineLimit(1) + } + } + Spacer(minLength: 0) + Image(systemName: "line.3.horizontal") + .foregroundStyle(enabled ? Color.white : Color.gray) + .frame(width: 48, height: 48) + } + .frame(height: 56) + .padding(.horizontal, 16) + .background(.black) + }.margins(.all, 0) + cell.isAccessibilityElement = true + cell.accessibilityLabel = thread.title + cell.accessibilityValue = row.section.title + cell.accessibilityHint = "Use actions to change order or section." + cell.accessibilityTraits = .button + cell.accessibilityCustomActions = accessibilityActions(thread, section: row.section) + } else { + let expandable = row.section.orderSection == nil + let isExpanded = expanded.contains(row.section) + cell.contentConfiguration = UIHostingConfiguration { + HStack { + Text(row.section.title).font(.system(size: 14, weight: .semibold)) + Spacer() + if expandable { + Image(systemName: isExpanded ? "chevron.down" : "chevron.right") + } + } + .foregroundStyle(.white) + .padding(.horizontal, 16) + .frame(height: 48) + .background(.black) + }.margins(.all, 0) + cell.isAccessibilityElement = true + cell.accessibilityLabel = row.section.title + cell.accessibilityValue = expandable ? (expanded.contains(row.section) ? "Expanded" : "Collapsed") : nil + cell.accessibilityHint = nil + cell.accessibilityTraits = expandable ? [.header, .button] : .header + cell.accessibilityCustomActions = [] + } + } + + private func canLift(_ thread: FeatureThread) -> Bool { + guard !thread.isArchived, + parent.model.snapshot.environments.contains(where: { + $0.id == thread.environmentID && $0.isEnabled && $0.connectionState == .connected + }) else { return false } + return ThreadArrangementPlanner.canEnter(thread, section: .pinned, now: .now) + || ThreadArrangementPlanner.canEnter(thread, section: .active, now: .now) + || (thread.supportsSettlement == true && !thread.isEffectivelySettled() && thread.canSettleNow()) + } + + func collectionView(_ collectionView: UICollectionView, didSelectItemAt indexPath: IndexPath) { + guard rows.indices.contains(indexPath.item), rows[indexPath.item].thread == nil, + !parent.model.isArrangingThreads else { return } + let section = rows[indexPath.item].section + guard section.orderSection == nil else { return } + if expanded.contains(section) { expanded.remove(section) } else { expanded.insert(section) } + update() + } + + func collectionView( + _ collectionView: UICollectionView, + itemsForBeginning session: UIDragSession, + at indexPath: IndexPath + ) -> [UIDragItem] { + guard !parent.model.isArrangingThreads, pendingRows == nil, rows.indices.contains(indexPath.item), + let thread = rows[indexPath.item].thread, canLift(thread), + let cell = collectionView.cellForItem(at: indexPath), + session.location(in: cell).x >= cell.bounds.maxX - 64 else { return [] } + draggedID = thread.id + let item = UIDragItem(itemProvider: NSItemProvider(object: thread.id as NSString)) + item.localObject = thread.id + return [item] + } + + func collectionView( + _ collectionView: UICollectionView, + dragSessionIsRestrictedToDraggingApplication session: UIDragSession + ) -> Bool { true } + + func collectionView( + _ collectionView: UICollectionView, + dragSessionAllowsMoveOperation session: UIDragSession + ) -> Bool { true } + + func collectionView(_ collectionView: UICollectionView, dragSessionDidEnd session: UIDragSession) { + draggedID = nil + parent.onAction(nil) + update() + } + + private func destination(at indexPath: IndexPath?, location: CGPoint) -> ThreadArrangementDestination? { + guard let indexPath, indexPath.section == 0 else { return nil } + let frame = view?.layoutAttributesForItem(at: indexPath)?.frame + return ThreadArrangementPlanner.destination( + rows: rows, + insertionIndex: indexPath.item, + isBeforeHeader: frame.map { location.y < $0.minY } ?? false + ) + } + + func collectionView( + _ collectionView: UICollectionView, + dropSessionDidUpdate session: UIDropSession, + withDestinationIndexPath indexPath: IndexPath? + ) -> UICollectionViewDropProposal { + guard session.localDragSession != nil, let id = draggedID, + let destination = destination(at: indexPath, location: session.location(in: collectionView)), + parent.model.arrangementPlan(id: id, destination: destination) != nil else { + parent.onAction(nil) + return UICollectionViewDropProposal(operation: .forbidden) + } + if let source = rows.first(where: { $0.thread?.id == id }) { + parent.onAction(action(source: source.section, destination: destination.section)) + } + return UICollectionViewDropProposal( + operation: .move, + intent: destination.targetID == nil ? .insertIntoDestinationIndexPath : .insertAtDestinationIndexPath + ) + } + + func collectionView(_ collectionView: UICollectionView, performDropWith coordinator: UICollectionViewDropCoordinator) { + guard let id = draggedID, let item = coordinator.items.first, + let destination = destination( + at: coordinator.destinationIndexPath, + location: coordinator.session.location(in: collectionView) + ), + parent.model.arrangementPlan(id: id, destination: destination) != nil, + let moved = rows.first(where: { $0.thread?.id == id })?.thread else { return } + var next = rows.filter { $0.thread?.id != id } + let target = destination.targetID.flatMap { id in next.firstIndex { $0.thread?.id == id } } + ?? next.firstIndex { $0.thread == nil && $0.section == destination.section } + guard let target else { return } + let insertAt = target + (destination.targetID == nil || destination.after ? 1 : 0) + if destination.section.orderSection != nil || expanded.contains(destination.section) { + next.insert(Row(section: destination.section, thread: moved), at: insertAt) + } + pendingRows = next + let dropPath = IndexPath(item: min(insertAt, next.count - 1), section: 0) + update { + coordinator.drop(item.dragItem, toItemAt: dropPath) + } + commit(id, destination: destination) + } + + private func commit(_ id: String, destination: ThreadArrangementDestination) { + Task { [weak self] in + guard let self else { return } + _ = await parent.model.arrangeThread(id, destination: destination) + pendingRows = nil + draggedID = nil + parent.onAction(nil) + update() + } + } + + private func action(source: ThreadArrangementSection, destination: ThreadArrangementSection) -> String { + if destination == .settled { return "Settle" } + if source == destination { return "Reorder" } + if destination == .pinned { return "Pin" } + if source == .pinned { return "Unpin" } + return source == .settled ? "Unsettle" : "Unsnooze" + } + + private func accessibilityActions(_ thread: FeatureThread, section: ThreadArrangementSection) -> [UIAccessibilityCustomAction] { + var choices: [(String, ThreadArrangementDestination)] = [] + if let orderSection = section.orderSection { + let ordered = DailyUXSidebarIndex.orderedSection(parent.model.snapshot.threads, section: orderSection, now: .now) + if let index = ordered.firstIndex(where: { $0.id == thread.id }) { + if index > 0 { + choices.append(("Move up", .init(section: section, targetID: ordered[index - 1].id))) + } + if index + 1 < ordered.count { + choices.append(("Move down", .init(section: section, targetID: ordered[index + 1].id, after: true))) + } + } + } + for destination in [ThreadArrangementSection.pinned, .active, .settled] where destination != section { + choices.append((action(source: section, destination: destination), .init(section: destination))) + } + return choices.compactMap { name, destination in + guard parent.model.arrangementPlan(id: thread.id, destination: destination) != nil else { return nil } + return UIAccessibilityCustomAction(name: name) { [weak self] _ in + guard let self, self.parent.model.arrangementPlan(id: thread.id, destination: destination) != nil else { return false } + self.commit(thread.id, destination: destination) + return true + } + } + } + } +} diff --git a/apps/swift-ios/Features/Workspace/ThreadOrderPlanner.swift b/apps/swift-ios/Features/Workspace/ThreadOrderPlanner.swift new file mode 100644 index 000000000000..164918873d60 --- /dev/null +++ b/apps/swift-ios/Features/Workspace/ThreadOrderPlanner.swift @@ -0,0 +1,196 @@ +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..