Skip to content
Merged
Show file tree
Hide file tree
Changes from 6 commits
Commits
Show all changes
20 commits
Select commit Hold shift + click to select a range
43f1fb0
iOS: remove workspace group indent guides from the workspace list
azooz2003-bit Jul 9, 2026
e9b5f6d
iOS: long-press the workspace-list + button to create a workspace group
azooz2003-bit Jul 9, 2026
f74fe31
iOS: collapse the dead gap after workspace groups
azooz2003-bit Jul 9, 2026
3fc5b51
iOS: principled drag-drop policy with Mac parity + haptic churn fix
azooz2003-bit Jul 9, 2026
3638a5b
Merge remote-tracking branch 'origin/main' into feat-remove-workspace…
azooz2003-bit Jul 9, 2026
bcf751e
iOS: serialize pipelined workspace move sends
azooz2003-bit Jul 9, 2026
126ba6b
iOS: make the optimistic-order reconciler an instance type
azooz2003-bit Jul 10, 2026
64d4d0f
Remove stray workspace Package.resolved generated by a local build
azooz2003-bit Jul 10, 2026
ea84e41
iOS: bound the move pipeline and abort sends after a rejected move
azooz2003-bit Jul 10, 2026
6c4d072
iOS: direct end-of-group drop slot for populated groups only
azooz2003-bit Jul 10, 2026
bf163ca
Remove stray workspace Package.resolved from local verification builds
azooz2003-bit Jul 10, 2026
96c0abd
iOS: reset the move chain tail after a failed move
azooz2003-bit Jul 10, 2026
e564d5b
iOS: fix mistargeted identity-drop test and stale items() doc
azooz2003-bit Jul 10, 2026
32ce33b
iOS: chain-aware optimistic reconciliation with live content overlay
azooz2003-bit Jul 10, 2026
8959002
iOS: pin-tier staleness in optimistic matching + cached policy lookups
azooz2003-bit Jul 10, 2026
9d12fb2
iOS: epoch-guard queued moves, 44pt header targets, tighter pipeline cap
azooz2003-bit Jul 10, 2026
b61b1cb
Merge remote-tracking branch 'origin/main' into feat-remove-workspace…
azooz2003-bit Jul 10, 2026
78cbf1f
iOS: point-aware drag & drop replaces native list reorder
azooz2003-bit Jul 10, 2026
99694e4
Revert "iOS: point-aware drag & drop replaces native list reorder"
azooz2003-bit Jul 10, 2026
3281bc9
Merge remote-tracking branch 'origin/main' into feat-remove-workspace…
azooz2003-bit Jul 11, 2026
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
Original file line number Diff line number Diff line change
Expand Up @@ -317,7 +317,7 @@ public final class MobileCoreRPCClient: MobileSyncing, Sendable {
ticket: ticket,
workspaceSelection: workspaceSelection.value
)
case "workspace.move", "workspace.group.action":
case "workspace.move", "workspace.group.action", "workspace.group.create":
// These mutations are Mac-scoped. Always preserve the attach-ticket
// context when one exists so the host can reject workspace-scoped
// tickets instead of receiving a Stack-only request.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -274,6 +274,21 @@ import Testing
#expect(params["title"] as? String == "Project Alpha")
}

@Test func workspaceGroupCreateRequestEncodesTitle() throws {
let data = try MobileCoreRPCClient.requestData(
method: "workspace.group.create",
params: [
"title": "Ops",
],
id: "group-create-request"
)
let request = try #require(JSONSerialization.jsonObject(with: data) as? [String: Any])
let params = try #require(request["params"] as? [String: Any])
#expect(request["id"] as? String == "group-create-request")
#expect(request["method"] as? String == "workspace.group.create")
#expect(params["title"] as? String == "Ops")
}

@Test func attachTicketInputDecodesAttachURL() throws {
let route = try CmxAttachRoute(
id: "tailscale",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -128,4 +128,44 @@ import Testing
#expect(frame.stackAccessToken == "test-stack-token")
#expect(frame.hasAuth)
}

@Test func workspaceGroupCreateCarriesWorkspaceScopedAttachTicketContext() async throws {
let route = try hostPortRoute(kind: .tailscale, host: "100.64.0.5", port: 58465)
let transport = QueuedCancellationProbeTransport()
let runtime = TestMobileSyncRuntime(
transportFactory: QueuedCancellationProbeTransportFactory(transport: transport),
stackAccessToken: "test-stack-token"
)
let ticket = try CmxAttachTicket(
workspaceID: "workspace-main",
terminalID: nil,
macDeviceID: "test-mac",
macDisplayName: "Test Mac",
routes: [route],
expiresAt: Date().addingTimeInterval(60),
authToken: "ticket-secret"
)
let client = MobileCoreRPCClient(
runtime: runtime,
route: route,
ticket: ticket,
allowsStackAuthFallback: true
)
let request = try MobileCoreRPCClient.requestData(
method: "workspace.group.create",
params: [
"title": "Ops",
]
)
let task = Task { try await client.sendRequest(request) }
let sent = try await transport.waitForSentRequestCount(1)
task.cancel()
_ = try? await task.value

let frame = try #require(sent.first)
#expect(frame.method == "workspace.group.create")
#expect(frame.attachToken == "ticket-secret")
#expect(frame.stackAccessToken == "test-stack-token")
#expect(frame.hasAuth)
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,8 @@ extension MobileShellComposite {
supportsReadStateActions: supportedHostCapabilities.contains("workspace.read_state.v1"),
supportsCloseActions: supportedHostCapabilities.contains("workspace.close.v1"),
supportsMoveActions: supportedHostCapabilities.contains("workspace.move.v1") && allowsMacScopedMutations,
supportsGroupActions: supportedHostCapabilities.contains("workspace.group_actions.v1") && allowsMacScopedMutations
supportsGroupActions: supportedHostCapabilities.contains("workspace.group_actions.v1") && allowsMacScopedMutations,
supportsGroupCreate: supportedHostCapabilities.contains("workspace.group_create.v1") && allowsMacScopedMutations
)
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -230,6 +230,41 @@ extension MobileShellComposite {
await sendWorkspaceGroupMutation(id: id, action: "delete", title: nil, actionName: "delete_group")
}

/// Create a workspace group on the foreground Mac.
/// - Parameter title: Optional group title. Whitespace-only titles use the Mac's default auto-name.
/// - Returns: `success` when the Mac accepted the request, otherwise the
/// failure the UI should surface.
@discardableResult
public func createWorkspaceGroup(
title: String? = nil
) async -> Result<Void, MobileWorkspaceMutationFailure> {
let target = WorkspaceMutationTarget(
client: remoteClient,
isForeground: true,
macDeviceID: foregroundMacDeviceID
)
let hostDisplayName = workspaceMutationHostDisplayName(target: target, fallback: nil)
guard supportedHostCapabilities.contains("workspace.group_create.v1") else {
return .failure(.unsupported(hostDisplayName: hostDisplayName))
}
guard macScopedWorkspaceMutationIsAuthorized(target: target) else {
return .failure(.authorizationFailed(hostDisplayName: hostDisplayName))
}
var params: [String: Any] = [:]
if let trimmed = title?.trimmingCharacters(in: .whitespacesAndNewlines),
!trimmed.isEmpty {
params["title"] = trimmed
}
return await sendWorkspaceMutation(
method: "workspace.group.create",
params: params,
target: target,
hostDisplayName: hostDisplayName,
logID: "foreground",
actionName: "create_group"
)
Comment thread
cursor[bot] marked this conversation as resolved.
Resolved
}

private func workspaceActionCapabilities(for id: MobileWorkspacePreview.ID) -> MobileWorkspaceActionCapabilities {
workspaces.first { $0.id == id }?.actionCapabilities ?? .none
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -93,11 +93,10 @@ public final class MobileShellComposite: MobileTerminalOutputSinking {
private static let workspaceCloseCapability = "workspace.close.v1"
private static let workspaceMoveCapability = "workspace.move.v1"
private static let workspaceGroupActionsCapability = "workspace.group_actions.v1"
private static let workspaceCreateInGroupCapability = "workspace.create_in_group.v1"
private static let workspaceCreateInGroupCapability = "workspace.create_in_group.v1", workspaceGroupCreateCapability = "workspace.group_create.v1"
private static let dogfoodFeedbackCapability = "dogfood.v1"
private static let workspaceGroupsCapability = "workspace.groups.v1"
private static let terminalOutputCapabilityTimeoutNanoseconds: UInt64 = 750_000_000

/// How long the render-grid stream may stay silent (no event of any topic)
/// before the liveness watchdog suspects the push subscription is dead and
/// runs a bounded host probe; only a failed probe forces the
Expand Down Expand Up @@ -322,6 +321,8 @@ public final class MobileShellComposite: MobileTerminalOutputSinking {
public var supportsWorkspaceGroupActions: Bool { supportedHostCapabilities.contains(Self.workspaceGroupActionsCapability) && allowsMacScopedWorkspaceMutations }
/// Whether the Mac supports creating a workspace directly inside a group.
public var supportsWorkspaceCreateInGroup: Bool { supportedHostCapabilities.contains(Self.workspaceCreateInGroupCapability) && allowsMacScopedWorkspaceMutations }
/// Whether the Mac supports creating workspace groups from iOS.
public var supportsWorkspaceGroupCreate: Bool { supportedHostCapabilities.contains(Self.workspaceGroupCreateCapability) && allowsMacScopedWorkspaceMutations }
/// Whether the Mac supports dogfood feedback submission.
public var supportsDogfoodFeedback: Bool { supportedHostCapabilities.contains(Self.dogfoodFeedbackCapability) }
/// Bumped whenever the applied terminal theme actually changes (a connect
Expand All @@ -334,7 +335,6 @@ public final class MobileShellComposite: MobileTerminalOutputSinking {
/// preserved across a theme change. The counter only advances on a real
/// value change, so an unchanged theme on reconnect does no work.
public private(set) var terminalThemeGeneration: UInt64 = 0

/// Applies the Mac's reported terminal theme to the process-wide
/// ``TerminalThemeStore`` and, when the resolved value actually changes,
/// bumps ``terminalThemeGeneration`` so the mounted terminal surface (and
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ import Testing
#expect(!oldMac.store.supportsWorkspaceReadStateActions && !oldMac.store.supportsWorkspaceCloseActions)
#expect(!oldMac.store.supportsWorkspaceMoveActions && !oldMac.store.supportsWorkspaceGroupActions)
#expect(!oldMac.store.supportsWorkspaceCreateInGroup)
#expect(!oldMac.store.supportsWorkspaceGroupCreate)

let currentCapabilities = [
"events.v1",
Expand All @@ -28,11 +29,13 @@ import Testing
"workspace.move.v1",
"workspace.group_actions.v1",
"workspace.create_in_group.v1",
"workspace.group_create.v1",
]
let scoped = try await connectedStore(capabilities: currentCapabilities)
#expect(scoped.store.supportsWorkspaceReadStateActions && scoped.store.supportsWorkspaceCloseActions)
#expect(!scoped.store.supportsWorkspaceMoveActions && !scoped.store.supportsWorkspaceGroupActions)
#expect(!scoped.store.supportsWorkspaceCreateInGroup)
#expect(!scoped.store.supportsWorkspaceGroupCreate)

let macWide = try await connectedStore(
capabilities: currentCapabilities,
Expand All @@ -41,6 +44,7 @@ import Testing
)
#expect(macWide.store.supportsWorkspaceMoveActions && macWide.store.supportsWorkspaceGroupActions)
#expect(macWide.store.supportsWorkspaceCreateInGroup)
#expect(macWide.store.supportsWorkspaceGroupCreate)
}

@Test func staleMacScopedMutationCapabilitiesFailClosedAfterTicketExpires() async throws {
Expand All @@ -52,6 +56,7 @@ import Testing
"workspace.move.v1",
"workspace.group_actions.v1",
"workspace.create_in_group.v1",
"workspace.group_create.v1",
],
ticketWorkspaceID: "",
ticketTerminalID: nil,
Expand Down Expand Up @@ -81,9 +86,13 @@ import Testing
guard case .failure(.authorizationFailed) = await store.createWorkspaceRequest(inGroup: "group-a") else {
return #expect(Bool(false), "expired ticket should fail create-in-group before sending")
}
guard case .failure(.authorizationFailed) = await store.createWorkspaceGroup() else {
return #expect(Bool(false), "expired ticket should fail group create before sending")
}
#expect(await router.count(of: "workspace.move") == 0)
#expect(await router.count(of: "workspace.group.action") == 0)
#expect(await router.count(of: "workspace.create") == 0)
#expect(await router.count(of: "workspace.group.create") == 0)
}

private func connectedStore(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,8 @@ public struct MobileWorkspaceActionCapabilities: Equatable, Sendable {
public var supportsMoveActions: Bool
/// Whether workspace group mutation requests are supported.
public var supportsGroupActions: Bool
/// Whether workspace group creation requests are supported.
public var supportsGroupCreate: Bool

/// No workspace actions are supported.
public static let none = MobileWorkspaceActionCapabilities()
Expand All @@ -20,12 +22,14 @@ public struct MobileWorkspaceActionCapabilities: Equatable, Sendable {
supportsReadStateActions: Bool = false,
supportsCloseActions: Bool = false,
supportsMoveActions: Bool = false,
supportsGroupActions: Bool = false
supportsGroupActions: Bool = false,
supportsGroupCreate: Bool = false
) {
self.supportsWorkspaceActions = supportsWorkspaceActions
self.supportsReadStateActions = supportsReadStateActions
self.supportsCloseActions = supportsCloseActions
self.supportsMoveActions = supportsMoveActions
self.supportsGroupActions = supportsGroupActions
self.supportsGroupCreate = supportsGroupCreate
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -36,11 +36,14 @@ extension Array where Element == MobileWorkspacePreview {
/// - Parameters:
/// - intent: The move intent derived from the rendered list snapshot.
/// - movedWorkspaceID: The dragged workspace, or a moved group's anchor workspace.
/// - groups: The group snapshots used to preserve group membership and ordering.
/// - Returns: A workspace snapshot with the move applied.
public func applyingWorkspaceMoveIntent(
_ intent: MobileWorkspaceMoveIntent,
movedWorkspaceID: MobileWorkspacePreview.ID
movedWorkspaceID: MobileWorkspacePreview.ID,
groups: [MobileWorkspaceGroupPreview]
) -> [MobileWorkspacePreview] {
MobileWorkspaceOrderMoveApplier(workspaces: self).applying(intent, movedWorkspaceID: movedWorkspaceID)
MobileWorkspaceOrderMoveApplier(workspaces: self, groups: groups)
.applying(intent, movedWorkspaceID: movedWorkspaceID)
}
}
Original file line number Diff line number Diff line change
@@ -1,6 +1,24 @@
import Foundation

/// Resolves rendered iOS workspace list drags into Mac-facing move intents.
///
/// Policy table, mirroring the Mac sidebar/host write path:
/// - Group headers move the whole group as a top-level row only; drops that
/// visually land inside another group normalize to that group's anchor
/// boundary. See `SidebarWorkspaceReorderDropResolver` group-header routing
/// and `TerminalController.mobileWorkspaceMoveTopLevelBeforeID`.
/// - Group anchors are rendered only as headers. Dragging the header moves the
/// group; workspace-row drags never change an anchor's membership.
/// - Workspace rows join a group only from the explicit in-group slots:
/// header/member gaps, mid-group gaps, and the synthetic footer-before gap.
/// The footer-after gap is the touch equivalent of the Mac root lane.
/// - Collapsed group headers expose only a top-level root slot.
/// - Pinned top-level rows form a leading tier. A moved workspace uses its own
/// pin state; a moved group uses the group's pin state. In-group member order
/// is anchor first, pinned members, then unpinned members.
/// - Unknown groups or before-workspace IDs are rejected before an RPC can be
/// emitted, matching the host's `not_found`/invalid-request behavior without
/// producing a doomed optimistic order.
struct MobileWorkspaceListMoveIntentResolver {
let items: [MobileWorkspaceListItem]
let workspaces: [MobileWorkspacePreview]
Expand Down Expand Up @@ -38,7 +56,6 @@ struct MobileWorkspaceListMoveIntentResolver {
remainingItems.endIndex
)

let currentGroupID = validGroupID(movedWorkspace.groupID)
let orderedWithoutMoved = workspaces.filter { $0.id != movedWorkspace.id }
let previousItem = insertionIndex > remainingItems.startIndex
? remainingItems[remainingItems.index(before: insertionIndex)]
Expand All @@ -56,26 +73,10 @@ struct MobileWorkspaceListMoveIntentResolver {
workspaces: orderedWithoutMoved
)

guard let intent = proposed else { return nil }
let changesWorkspaceOrder = if movesGroup {
currentGroupID.map {
changesGroupOrder(
movedGroupID: $0,
beforeWorkspaceID: intent.beforeWorkspaceID
)
} ?? false
} else {
intent.groupID != currentGroupID || changesOrder(
draggedWorkspaceID: movedWorkspace.id,
beforeWorkspaceID: intent.beforeWorkspaceID
)
}
guard changesWorkspaceOrder else { return nil }
return MobileWorkspaceMoveIntent(
groupID: intent.groupID,
beforeWorkspaceID: intent.beforeWorkspaceID,
movesGroup: movesGroup
)
guard var proposed else { return nil }
proposed.movesGroup = movesGroup
return MobileWorkspaceMovePolicy(workspaces: workspaces, groups: groups)
.normalizedIntent(proposed, movedWorkspaceID: movedWorkspace.id)
}

private func movedWorkspace(for item: MobileWorkspaceListItem) -> MobileWorkspacePreview? {
Expand Down Expand Up @@ -215,25 +216,4 @@ struct MobileWorkspaceListMoveIntentResolver {
guard nextIndex < workspaces.endIndex else { return nil }
return workspaces[nextIndex].id
}

private func changesOrder(
draggedWorkspaceID: MobileWorkspacePreview.ID,
beforeWorkspaceID: MobileWorkspacePreview.ID?
) -> Bool {
var ids = workspaces.map(\.id)
guard let currentIndex = ids.firstIndex(of: draggedWorkspaceID) else { return false }
ids.remove(at: currentIndex)
let targetIndex = beforeWorkspaceID.flatMap { ids.firstIndex(of: $0) } ?? ids.endIndex
ids.insert(draggedWorkspaceID, at: targetIndex)
return ids != workspaces.map(\.id)
}

private func changesGroupOrder(
movedGroupID: MobileWorkspaceGroupPreview.ID,
beforeWorkspaceID: MobileWorkspacePreview.ID?
) -> Bool {
MobileWorkspaceOrderMoveApplier(workspaces: workspaces)
.applyingGroupMove(movedGroupID: movedGroupID, beforeWorkspaceID: beforeWorkspaceID)
.map(\.id) != workspaces.map(\.id)
}
}
Loading
Loading