Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
31 commits
Select commit Hold shift + click to select a range
a863d2e
test(ios): cover workspace group row actions
azooz2003-bit Jul 29, 2026
5852a4e
fix(ios): restore workspace group row actions
azooz2003-bit Jul 29, 2026
dcbe222
Merge remote-tracking branch 'origin/main' into feat-ios-workspace-gr…
azooz2003-bit Jul 29, 2026
383a002
test(ios): cover group destructive confirmations
azooz2003-bit Jul 29, 2026
c91a69f
test(ios): cover group read-state action refresh
azooz2003-bit Jul 30, 2026
6495e8b
fix(ios): refresh group native action state
azooz2003-bit Jul 30, 2026
79482c7
Merge remote-tracking branch 'origin/main' into feat-ios-workspace-gr…
azooz2003-bit Jul 30, 2026
7a3b692
test(ios): cover group native action inputs
azooz2003-bit Jul 30, 2026
1cd33d2
fix(ios): refresh group native action inputs
azooz2003-bit Jul 30, 2026
5bf4239
test(ios): cover group swipe completion and rename alert
azooz2003-bit Jul 30, 2026
98d8202
fix(ios): restore workspace preview compilation
azooz2003-bit Jul 30, 2026
5a17d72
test(ios): target visible group rename fixture
azooz2003-bit Jul 30, 2026
4562ce9
fix(ios): preserve group swipe completion and compact rename
azooz2003-bit Jul 30, 2026
2923db0
test(ios): exercise group action presentation lifecycles
azooz2003-bit Jul 30, 2026
6428040
test(ios): preserve workspace actions on group menus
azooz2003-bit Jul 30, 2026
ba6d8e3
fix(ios): preserve workspace actions on group menus
azooz2003-bit Jul 30, 2026
30a6069
test(ios): exercise full group read swipe
azooz2003-bit Jul 30, 2026
c3b4ed8
test(ios): isolate native group menu assertions
azooz2003-bit Jul 30, 2026
4a02f7a
test(ios): cover preserved group actions
azooz2003-bit Jul 30, 2026
e8c1907
test(ios): keep preview fixture state owned
azooz2003-bit Jul 30, 2026
70dda5f
test(ios): cover preserved group create actions
azooz2003-bit Jul 30, 2026
75a220c
fix(ios): preserve group creation entrypoints
azooz2003-bit Jul 30, 2026
f389789
fix(ios): make destructive group requests atomic
azooz2003-bit Jul 30, 2026
6aa95fb
test(ios): cover configured group icons
azooz2003-bit Jul 30, 2026
b6b3db6
fix(ios): sync effective group icons
azooz2003-bit Jul 30, 2026
ad14f05
test(ios): target live group row swipe
azooz2003-bit Jul 30, 2026
dbe530f
test(ios): disambiguate group workspace rename
azooz2003-bit Jul 31, 2026
508e8d4
fix(ios): disambiguate group workspace rename
azooz2003-bit Jul 31, 2026
c83cb78
Merge origin/main into feat-ios-workspace-group-actions
azooz2003-bit Jul 31, 2026
4063190
fix(ios): keep list probe state coordinator-owned
azooz2003-bit Jul 31, 2026
5e3896e
test: disambiguate group rename alert save
azooz2003-bit Jul 31, 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 @@ -203,6 +203,8 @@ public struct GroupSyncRecord: MobileSyncRecord {
public let isCollapsed: Bool
/// Whether the group is pinned on the Mac.
public let isPinned: Bool
/// SF Symbol rendered by the corresponding group row on the Mac.
public let iconSymbol: String?
/// The anchor workspace that owns this group.
public let anchorWorkspaceID: String
/// Position in the Mac's presented section order.
Expand All @@ -219,13 +221,15 @@ public struct GroupSyncRecord: MobileSyncRecord {
name: String,
isCollapsed: Bool,
isPinned: Bool,
iconSymbol: String? = nil,
anchorWorkspaceID: String,
sortIndex: Int
) {
self.id = id
self.name = name
self.isCollapsed = isCollapsed
self.isPinned = isPinned
self.iconSymbol = iconSymbol
self.anchorWorkspaceID = anchorWorkspaceID
self.sortIndex = sortIndex
}
Expand All @@ -235,6 +239,7 @@ public struct GroupSyncRecord: MobileSyncRecord {
case name
case isCollapsed = "is_collapsed"
case isPinned = "is_pinned"
case iconSymbol = "icon_symbol"
case anchorWorkspaceID = "anchor_workspace_id"
case sortIndex = "sort_index"
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -80,6 +80,35 @@ struct MobileStateSyncFrameCodingTests {
#expect(!decoded.customDescriptionIsTruncated)
}

@Test func groupRecordCarriesIconAndDecodesOlderFrames() throws {
let group = GroupSyncRecord(
id: "group-1",
name: "Release",
isCollapsed: false,
isPinned: true,
iconSymbol: "shippingbox.fill",
anchorWorkspaceID: "workspace-1",
sortIndex: 0
)
let object = try MobileSyncFrameCoder().jsonObject(from: group)
#expect(object["icon_symbol"] as? String == "shippingbox.fill")

let decodedOlder = try MobileSyncFrameCoder().decode(
GroupSyncRecord.self,
fromJSONString: """
{
"id": "group-older",
"name": "Older Mac",
"is_collapsed": false,
"is_pinned": false,
"anchor_workspace_id": "workspace-older",
"sort_index": 0
}
"""
)
#expect(decodedOlder.iconSymbol == nil)
}

@Test func deltaEventRoundTripsThroughJSONObject() throws {
let event = MobileSyncDeltaEvent(
epoch: "e1",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -105,7 +105,7 @@ public struct MobileSyncWorkspaceListResponse: Decodable, Sendable {
}

/// A workspace group section in the list response. Mirrors the iOS-facing
/// subset the Mac emits (no v2 handle refs, color, or icon). Members are
/// subset the Mac emits (no v2 handle refs or color). Members are
/// listed in the Mac's spatial (`tabs`) order. Absent on Macs old enough not
/// to emit groups.
public struct Group: Decodable, Sendable {
Expand All @@ -117,6 +117,8 @@ public struct MobileSyncWorkspaceListResponse: Decodable, Sendable {
public let isCollapsed: Bool
/// Whether the group is pinned on the Mac.
public let isPinned: Bool
/// SF Symbol rendered by the corresponding group row on the Mac.
public let iconSymbol: String?
/// The anchor workspace that owns this group. It is represented by the
/// group header and never rendered as a separate row.
public let anchorWorkspaceID: String
Expand All @@ -130,6 +132,7 @@ public struct MobileSyncWorkspaceListResponse: Decodable, Sendable {
case name
case isCollapsed = "is_collapsed"
case isPinned = "is_pinned"
case iconSymbol = "icon_symbol"
case anchorWorkspaceID = "anchor_workspace_id"
}

Expand All @@ -139,12 +142,14 @@ public struct MobileSyncWorkspaceListResponse: Decodable, Sendable {
name: String,
isCollapsed: Bool,
isPinned: Bool,
iconSymbol: String? = nil,
anchorWorkspaceID: String
) {
self.id = id
self.name = name
self.isCollapsed = isCollapsed
self.isPinned = isPinned
self.iconSymbol = iconSymbol
self.anchorWorkspaceID = anchorWorkspaceID
}
}
Expand Down Expand Up @@ -243,4 +248,3 @@ extension MobileSyncWorkspaceListResponse {
self.createdTerminalID = createdTerminalID
}
}

Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,7 @@ extension MobileWorkspaceGroupPreview {
name: remote.name,
isCollapsed: remote.isCollapsed,
isPinned: remote.isPinned,
iconSymbol: remote.iconSymbol,
anchorWorkspaceID: MobileWorkspacePreview.ID(rawValue: remote.anchorWorkspaceID)
)
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -270,6 +270,51 @@ import Testing
#expect(mapped.customColorHex == nil)
}

@Test func workspaceListResponseCarriesWorkspaceGroupIcon() throws {
let json = Data("""
{
"workspaces": [],
"groups": [
{
"id": "group-1",
"name": "Release",
"is_collapsed": false,
"is_pinned": true,
"icon_symbol": "shippingbox.fill",
"anchor_workspace_id": "workspace-1"
}
]
}
""".utf8)

let response = try MobileSyncWorkspaceListResponse.decode(json)
let remoteGroup = try #require(response.groups.first)
#expect(remoteGroup.iconSymbol == "shippingbox.fill")

let mappedGroup = MobileWorkspaceGroupPreview(remote: remoteGroup)
#expect(mappedGroup.iconSymbol == "shippingbox.fill")
}

@Test func workspaceListResponseDefaultsMissingWorkspaceGroupIconToNil() throws {
let json = Data("""
{
"workspaces": [],
"groups": [
{
"id": "group-older",
"name": "Older Mac",
"is_collapsed": false,
"is_pinned": false,
"anchor_workspace_id": "workspace-older"
}
]
}
""".utf8)

let response = try MobileSyncWorkspaceListResponse.decode(json)
#expect(response.groups.first?.iconSymbol == nil)
}

/// The Mac emits an optional per-workspace `preview` + `preview_at` (latest
/// notification text + epoch seconds) for the iMessage-style row preview.
/// Both must decode when present and stay `nil` when an older Mac omits them.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -38,9 +38,15 @@ extension MobileShellComposite {
/// Whether the Mac supports workspace group mutation requests.
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 }
public var supportsWorkspaceCreateInGroup: Bool {
supportedHostCapabilities.contains(Self.workspaceCreateInGroupCapability)
&& hasMacScopedWorkspaceMutationTicketScope
}
/// Whether the Mac supports creating workspace groups from iOS.
public var supportsWorkspaceGroupCreate: Bool { supportedHostCapabilities.contains(Self.workspaceGroupCreateCapability) && allowsMacScopedWorkspaceMutations }
public var supportsWorkspaceGroupCreate: Bool {
supportedHostCapabilities.contains(Self.workspaceGroupCreateCapability)
&& hasMacScopedWorkspaceMutationTicketScope
}
/// Whether the Mac supports dogfood feedback submission.
public var supportsDogfoodFeedback: Bool { supportedHostCapabilities.contains(Self.dogfoodFeedbackCapability) }
/// Whether the Mac supports chat artifact stat/fetch/thumbnail/list RPCs.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -374,6 +374,7 @@ extension MobileShellComposite {
name: record.name,
isCollapsed: record.isCollapsed,
isPinned: record.isPinned,
iconSymbol: record.iconSymbol,
anchorWorkspaceID: record.anchorWorkspaceID
)
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,19 @@ internal import CmuxMobileRPC
internal import Foundation

extension MobileShellComposite {
/// Whether the active ticket was issued with Mac-wide mutation scope.
///
/// Menu discovery depends on stable scope, not the ticket's short-lived
/// expiry. The mutation methods still call
/// ``allowsMacScopedWorkspaceMutations`` immediately before sending, so an
/// expired credential fails closed without silently removing supported
/// actions from an already-visible workspace list.
var hasMacScopedWorkspaceMutationTicketScope: Bool {
let ticket = activeTicket ?? remoteClient?.attachTicket
return MobileShellWorkspaceMutationTicketPolicy(now: runtime?.now() ?? Date())
.hasMacScopedWorkspaceMutationScope(ticket)
}

var allowsMacScopedWorkspaceMutations: Bool {
allowsMacScopedWorkspaceMutations(targetClient: nil)
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,12 +5,20 @@ internal import Foundation
struct MobileShellWorkspaceMutationTicketPolicy {
let now: Date

func hasMacScopedWorkspaceMutationScope(_ ticket: CmxAttachTicket?) -> Bool {
guard let ticket,
ticket.authToken?.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty == false else {
return false
}
return ticket.workspaceID.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
}

func allowsMacScopedWorkspaceMutations(_ ticket: CmxAttachTicket?) -> Bool {
guard let ticket,
ticket.authToken?.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty == false,
hasMacScopedWorkspaceMutationScope(ticket),
!ticket.isExpired(at: now) else {
return false
}
return ticket.workspaceID.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
return true
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -139,6 +139,26 @@ import Testing
#expect(await router.count(of: "workspace.group.create") == 0)
}

@Test func expiredMacWideTicketKeepsAdvertisedCreateActionsDiscoverable() async throws {
let connected = try await connectedStore(
capabilities: [
"events.v1",
"terminal.render_grid.v1",
"terminal.replay.v1",
"workspace.create_in_group.v1",
"workspace.group_create.v1",
],
ticketWorkspaceID: "",
ticketTerminalID: nil,
ticketLifetime: 1
)

connected.clock.advance(by: 2)

#expect(connected.store.supportsWorkspaceCreateInGroup)
#expect(connected.store.supportsWorkspaceGroupCreate)
}

private func connectedStore(
capabilities: [String],
ticketWorkspaceID: String = "live-workspace",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,8 @@ public struct MobileWorkspaceGroupPreview: Identifiable, Equatable, Sendable {
public var isCollapsed: Bool
/// Whether the group is pinned on the Mac.
public var isPinned: Bool
/// SF Symbol rendered by the corresponding group row on the Mac.
public var iconSymbol: String?
/// The anchor workspace that owns this group. It is represented by the group
/// header and never rendered as a separate row.
public var anchorWorkspaceID: MobileWorkspacePreview.ID
Expand All @@ -45,18 +47,21 @@ public struct MobileWorkspaceGroupPreview: Identifiable, Equatable, Sendable {
/// - name: The group's user-facing name.
/// - isCollapsed: Whether the group is collapsed. Defaults to `false`.
/// - isPinned: Whether the group is pinned. Defaults to `false`.
/// - iconSymbol: The Mac row's SF Symbol name, when customized.
/// - anchorWorkspaceID: The anchor workspace that owns the group.
public init(
id: ID,
name: String,
isCollapsed: Bool = false,
isPinned: Bool = false,
iconSymbol: String? = nil,
anchorWorkspaceID: MobileWorkspacePreview.ID
) {
self.id = id
self.name = name
self.isCollapsed = isCollapsed
self.isPinned = isPinned
self.iconSymbol = iconSymbol
self.anchorWorkspaceID = anchorWorkspaceID
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
import CmuxMobileShellModel
import Observation

@Observable
final class WorkspaceGroupDestructiveRequestState {
struct Request {
let groupID: MobileWorkspaceGroupPreview.ID
let action: WorkspaceGroupHeaderPendingDestructiveAction
}

private var pending: Request?

var groupID: MobileWorkspaceGroupPreview.ID? { pending?.groupID }
var action: WorkspaceGroupHeaderPendingDestructiveAction? { pending?.action }

func enqueue(
groupID: MobileWorkspaceGroupPreview.ID,
action: WorkspaceGroupHeaderPendingDestructiveAction
) {
pending = Request(groupID: groupID, action: action)
}

func consume() -> Request? {
defer { pending = nil }
return pending
}

func clear() {
pending = nil
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@ struct WorkspaceGroupHeaderRow: View, Equatable {
private var isAnchorSelected: Bool { value.isAnchorSelected }

@State private var isRenaming = false
@State private var renameDraft = ""
@State private var pendingDestructiveAction: WorkspaceGroupHeaderPendingDestructiveAction?

/// The leading disclosure chevron. Its own hit target, so tapping it only
Expand Down Expand Up @@ -68,7 +69,7 @@ struct WorkspaceGroupHeaderRow: View, Equatable {
/// the desktop header whose body focuses the anchor.
private var nameLabel: some View {
HStack(spacing: 6) {
Image(systemName: "folder.fill")
Image(systemName: group.iconSymbol ?? "folder.fill")
.font(.system(size: 13, weight: .semibold))
.foregroundStyle(.secondary)
.accessibilityHidden(true)
Expand Down Expand Up @@ -133,11 +134,12 @@ struct WorkspaceGroupHeaderRow: View, Equatable {
: Color.clear
)
.clipShape(RoundedRectangle(cornerRadius: 6, style: .continuous))
.contextMenu { contextMenu }
.sheet(isPresented: $isRenaming) {
WorkspaceGroupRenameSheet(currentName: group.name) { newName in
actions.renameGroup?(group.id, newName)
}
.workspaceGroupRowContextMenu { contextMenu }
.workspaceGroupRenameDialog(
isPresented: $isRenaming,
text: $renameDraft
) { newName in
actions.renameGroup?(group.id, newName)
}
.confirmationDialog(
destructiveDialogTitle,
Expand Down Expand Up @@ -196,6 +198,7 @@ struct WorkspaceGroupHeaderRow: View, Equatable {
}
if value.canRenameGroup {
Button {
renameDraft = group.name
isRenaming = true
} label: {
Label(L10n.string("mobile.workspaceGroup.rename.action", defaultValue: "Rename Group"), systemImage: "pencil")
Expand Down Expand Up @@ -285,3 +288,21 @@ struct WorkspaceGroupHeaderRow: View, Equatable {
)
}
}

private extension View {
/// The iOS workspace list is backed by `UITableView`, whose delegate owns
/// the complete group-plus-anchor context menu. Attaching a second SwiftUI
/// menu to the hosted row intercepts the long press and hides the anchor's
/// workspace actions. The non-iOS list has no UIKit delegate, so it keeps
/// this row-local menu.
@ViewBuilder
func workspaceGroupRowContextMenu<MenuContent: View>(
@ViewBuilder content: () -> MenuContent
) -> some View {
#if os(iOS)
self
#else
contextMenu(menuItems: content)
#endif
}
}
Loading