Skip to content
Open
Original file line number Diff line number Diff line change
Expand Up @@ -71,8 +71,7 @@ public struct MobileSyncWorkspaceListResponse: Decodable, Sendable {
public let isCollapsed: Bool
/// Whether the group is pinned on the Mac.
public let isPinned: Bool
/// The anchor workspace that owns this group. It is represented by the
/// group header and never rendered as a separate row.
/// The anchor workspace that positions this group in the workspace order.
public let anchorWorkspaceID: String

// The Mac also emits `member_workspace_ids`, but membership is derived on
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,10 +4,11 @@ import Foundation
/// mobile shell.
///
/// Workspaces on the Mac can be organized into named, collapsible groups. An
/// anchor workspace owns each group; on the Mac sidebar the anchor renders as the
/// group header (no separate row), and collapsing the group hides its members but
/// keeps the header. The mobile shell mirrors those semantics. This is a pure
/// value model decoupled from any RPC or rendering concern.
/// anchor workspace positions each group; on the Mac sidebar the group header is
/// separate from the workspace rows while expanded, and collapsing the group
/// hides its members but keeps the header. The mobile shell mirrors those
/// semantics. This is a pure value model decoupled from any RPC or rendering
/// concern.
public struct MobileWorkspaceGroupPreview: Identifiable, Equatable, Sendable {
/// A stable, string-backed identifier for a ``MobileWorkspaceGroupPreview``.
public struct ID: RawRepresentable, Hashable, Codable, Sendable, ExpressibleByStringLiteral {
Expand Down Expand Up @@ -35,8 +36,7 @@ public struct MobileWorkspaceGroupPreview: Identifiable, Equatable, Sendable {
public var isCollapsed: Bool
/// Whether the group is pinned on the Mac.
public var isPinned: Bool
/// The anchor workspace that owns this group. It is represented by the group
/// header and never rendered as a separate row.
/// The anchor workspace that positions this group.
public var anchorWorkspaceID: MobileWorkspacePreview.ID

/// Creates a workspace group preview.
Expand All @@ -45,7 +45,7 @@ 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`.
/// - anchorWorkspaceID: The anchor workspace that owns the group.
/// - anchorWorkspaceID: The anchor workspace that positions the group.
public init(
id: ID,
name: String,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,24 +3,21 @@ import Foundation
/// One drawable item in the mobile workspace list.
///
/// The mobile list mirrors the Mac sidebar's group semantics: a group is shown as
/// a header (representing its anchor workspace) followed by its non-anchor members;
/// collapsing a group hides its members but keeps the header; ungrouped workspaces
/// a header followed by its member workspace rows while expanded; collapsing a
/// group hides its members but keeps the header; ungrouped workspaces
/// interleave inline by their position. This is a pure value type so the SwiftUI
/// `List` can consume an immutable snapshot with no store reference below the list
/// boundary.
public enum MobileWorkspaceListItem: Identifiable, Equatable, Sendable {
/// A collapsible group header. The associated group's anchor workspace is
/// represented by this header and is never emitted as a separate
/// ``workspace`` item.
/// A collapsible group header.
///
/// `hasUnread` is the header's aggregate unread state, mirroring the Mac
/// sidebar header badge: while the group is expanded it reflects only the
/// anchor workspace (visible member rows carry their own dots); while
/// collapsed it reflects the whole group, anchor included, so hidden
/// member activity is never silently swallowed.
/// sidebar header badge: expanded groups show unread state on visible
/// workspace rows; collapsed groups reflect the whole hidden group, anchor
/// included, so hidden member activity is never silently swallowed.
case groupHeader(MobileWorkspaceGroupPreview, hasUnread: Bool)
/// A workspace row. `indented` is `true` for non-anchor members nested under
/// a group header, so the view can inset them.
/// A workspace row. `indented` is `true` for members nested under a group
/// header, so the view can inset them.
case workspace(MobileWorkspacePreview, indented: Bool)

/// A stable, list-unique identity for SwiftUI diffing. Namespaced by item
Expand All @@ -40,7 +37,8 @@ public enum MobileWorkspaceListItem: Identifiable, Equatable, Sendable {
/// Mirrors `SidebarWorkspaceRenderItem.renderItems` on the Mac:
/// - Items follow `workspaces` order. A group header is emitted at the first
/// member's position.
/// - The anchor workspace is never a separate row (the header represents it).
/// - Expanded groups keep every workspace, including the anchor, as a
/// separate row under the header.
/// - When a group is collapsed, its members are skipped (header kept).
/// - Ungrouped workspaces interleave inline by position.
///
Expand All @@ -67,16 +65,12 @@ public enum MobileWorkspaceListItem: Identifiable, Equatable, Sendable {

// Aggregate unread state per group up front (membership can be
// non-contiguous, so this cannot be folded into the emit loop).
// Mirrors the Mac header badge: anchor-only while expanded, whole
// group (anchor included) while collapsed.
var anchorUnreadByGroupID: [MobileWorkspaceGroupPreview.ID: Bool] = [:]
// Mirrors the Mac header badge: no header badge while expanded because
// member rows are visible, whole group (anchor included) while collapsed.
var anyMemberUnreadByGroupID: [MobileWorkspaceGroupPreview.ID: Bool] = [:]
for workspace in workspaces {
guard let groupID = workspace.groupID, let group = groupsByID[groupID] else { continue }
guard let groupID = workspace.groupID, groupsByID[groupID] != nil else { continue }
anyMemberUnreadByGroupID[groupID, default: false] = anyMemberUnreadByGroupID[groupID, default: false] || workspace.hasUnread
if group.anchorWorkspaceID == workspace.id {
anchorUnreadByGroupID[groupID] = workspace.hasUnread
}
}

var items: [MobileWorkspaceListItem] = []
Expand All @@ -94,20 +88,13 @@ public enum MobileWorkspaceListItem: Identifiable, Equatable, Sendable {
if groupID != lastEmittedGroupID {
lastEmittedGroupID = groupID
if let groupID, let group = groupsByID[groupID], !emittedHeaders.contains(groupID) {
let hasUnread = group.isCollapsed
? anyMemberUnreadByGroupID[groupID, default: false]
: anchorUnreadByGroupID[groupID, default: false]
let hasUnread = group.isCollapsed && anyMemberUnreadByGroupID[groupID, default: false]
items.append(.groupHeader(group, hasUnread: hasUnread))
emittedHeaders.insert(groupID)
collapsedByGroupID[groupID] = group.isCollapsed
}
}

if let groupID, let group = groupsByID[groupID], group.anchorWorkspaceID == workspace.id {
// Anchor is represented exclusively by the group header.
continue
}

let isCollapsed = groupID.map { collapsedByGroupID[$0] ?? false } ?? false
if groupID == nil || !isCollapsed {
items.append(.workspace(workspace, indented: groupID != nil))
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -44,15 +44,16 @@ import Testing
])
}

@Test func anchorRendersAsHeaderNotARow() {
// Anchor "a" owns group "g"; member "b" is nested. The anchor must not
// also appear as a workspace row.
@Test func expandedGroupRendersHeaderAndAnchorRow() {
// Anchor "a" positions group "g"; member "b" is nested. While
// expanded, both workspaces remain visible rows under the header.
let items = MobileWorkspaceListItem.items(
workspaces: [workspace("a", group: "g"), workspace("b", group: "g")],
groups: [group("g", anchor: "a")]
)
#expect(items == [
.groupHeader(group("g", anchor: "a"), hasUnread: false),
.workspace(workspace("a", group: "g"), indented: true),
.workspace(workspace("b", group: "g"), indented: true),
])
}
Expand Down Expand Up @@ -80,6 +81,7 @@ import Testing
#expect(items == [
.workspace(workspace("top"), indented: false),
.groupHeader(group("g", anchor: "anchor"), hasUnread: false),
.workspace(workspace("anchor", group: "g"), indented: true),
.workspace(workspace("member", group: "g"), indented: true),
.workspace(workspace("bottom"), indented: false),
])
Expand All @@ -95,12 +97,15 @@ import Testing
#expect(items == [.workspace(workspace("a", group: "missing"), indented: false)])
}

@Test func anchorOnlyGroupRendersHeaderWithNoMembers() {
@Test func singleMemberGroupRendersHeaderAndWorkspace() {
let items = MobileWorkspaceListItem.items(
workspaces: [workspace("a", group: "g")],
groups: [group("g", anchor: "a")]
)
#expect(items == [.groupHeader(group("g", anchor: "a"), hasUnread: false)])
#expect(items == [
.groupHeader(group("g", anchor: "a"), hasUnread: false),
.workspace(workspace("a", group: "g"), indented: true),
])
}

// MARK: Header unread aggregation (mirrors the Mac sidebar header badge)
Expand Down Expand Up @@ -131,9 +136,9 @@ import Testing
#expect(items == [.groupHeader(group("g", anchor: "a", collapsed: true), hasUnread: true)])
}

@Test func expandedGroupHeaderReflectsOnlyTheAnchor() {
// While expanded, member rows are visible and carry their own dots;
// the header only represents the anchor (matching the Mac header).
@Test func expandedGroupHeaderDoesNotDuplicateVisibleUnread() {
// While expanded, every workspace row is visible and carries its own
// dot, so the header does not duplicate unread state.
let items = MobileWorkspaceListItem.items(
workspaces: [
workspace("a", group: "g"),
Expand All @@ -143,11 +148,12 @@ import Testing
)
#expect(items == [
.groupHeader(group("g", anchor: "a"), hasUnread: false),
.workspace(workspace("a", group: "g"), indented: true),
.workspace(workspace("b", group: "g", unread: true), indented: true),
])
}

@Test func expandedGroupHeaderCarriesUnreadAnchor() {
@Test func expandedGroupHeaderDoesNotCarryUnreadAnchor() {
let items = MobileWorkspaceListItem.items(
workspaces: [
workspace("a", group: "g", unread: true),
Expand All @@ -156,7 +162,8 @@ import Testing
groups: [group("g", anchor: "a")]
)
#expect(items == [
.groupHeader(group("g", anchor: "a"), hasUnread: true),
.groupHeader(group("g", anchor: "a"), hasUnread: false),
.workspace(workspace("a", group: "g", unread: true), indented: true),
.workspace(workspace("b", group: "g"), indented: true),
])
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,17 +4,16 @@ import SwiftUI

/// A collapsible group-section header in the mobile workspace list.
///
/// Mirrors the Mac sidebar group header, which doubles as the group's anchor
/// workspace row: the leading disclosure chevron toggles collapse, while tapping
/// the name/body selects (and, in push navigation, opens) the anchor workspace.
/// The anchor is represented by this header and never rendered as a separate row,
/// so this split is what keeps the anchor's terminals reachable from the phone.
/// Mirrors the Mac sidebar group header: the leading disclosure chevron toggles
/// collapse, while tapping the name/body selects (and, in push navigation,
/// opens) the anchor workspace.
/// Expanded groups also render the anchor as a normal workspace row.
struct WorkspaceGroupHeaderRow: View {
let group: MobileWorkspaceGroupPreview
/// Aggregate unread state for the header dot, computed by
/// `MobileWorkspaceListItem.items`: the anchor's unread while expanded,
/// the whole group's (anchor included) while collapsed, mirroring the Mac
/// sidebar header badge so collapsing a group never hides activity.
/// `MobileWorkspaceListItem.items`: no header badge while expanded because
/// workspace rows are visible, the whole group's unread while collapsed,
/// mirroring the Mac sidebar header badge so collapsing never hides activity.
let hasUnread: Bool
let navigationStyle: WorkspaceNavigationStyle
/// Whether the anchor workspace is the current selection (sidebar style only).
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -36,7 +36,7 @@ public protocol ControlWorkspaceGroupContext: AnyObject {

/// Creates a workspace group for `workspace.group.create`.
///
/// The coordinator has already parsed `name` / `cwd`, resolved the child
/// The coordinator has already parsed `name` / legacy `cwd`, resolved the child
/// handles to UUIDs, and surfaced the param-shape `invalid_params` failures;
/// this runs the live-state remainder (fallback child selection, the
/// target-window existence check, the all-children-are-anchors guard, and
Expand All @@ -46,7 +46,9 @@ public protocol ControlWorkspaceGroupContext: AnyObject {
/// - routing: The routing selectors used for TabManager resolution and the
/// caller-workspace fallback.
/// - name: The group name (already defaulted to "" when absent).
/// - cwd: The anchor working directory, if provided.
/// - cwd: Legacy create-time anchor working directory, if provided.
/// Group creation no longer creates a terminal workspace for the header,
/// so conformers may ignore it.
/// - childWorkspaceIDs: The resolved child workspace ids, in request order
/// (empty when none provided/resolved).
/// - childrenExplicit: Whether the caller explicitly listed
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,10 +3,11 @@ public import Foundation
/// The outcome of `workspace.group.create`, preserving the legacy body's
/// app-state failures and the created group it echoes back.
///
/// The coordinator parses `name` / `cwd` / `child_workspace_ids` (resolving each
/// child through the handle registry) and surfaces the param-shape failures
/// (`invalid_params` for a malformed `child_workspace_ids` or unresolved
/// handles) itself. The remaining resolution depends on live app state — the
/// The coordinator parses `name` / legacy `cwd` / `child_workspace_ids`
/// (resolving each child through the handle registry) and surfaces the
/// param-shape failures (`invalid_params` for a malformed
/// `child_workspace_ids` or unresolved handles) itself. The remaining
/// resolution depends on live app state — the
/// fallback selection when children are absent, the existence check against the
/// target window, the all-children-are-anchors eligibility guard, and the create
/// call — so it happens behind the seam and returns one of these cases.
Expand All @@ -19,9 +20,9 @@ public enum ControlWorkspaceGroupCreateResolution: Sendable, Equatable {
/// found in target window: …", `data: {"unknown_workspace_ids": …}`).
/// Carries the missing workspace id strings, in request order.
case childWorkspaceNotFound([String])
/// Every explicitly-listed child is already a group anchor, so only an
/// anchor-only group could be created (legacy `invalid_state` /
/// `workspaceGroup.error.allChildrenAreAnchors`, `data:
/// Every explicitly-listed child is already a group anchor, so there is
/// no eligible child to use as the new group's ordering anchor (legacy
/// `invalid_state` / `workspaceGroup.error.allChildrenAreAnchors`, `data:
/// {"ineligible_workspace_ids": …}`). Carries the ineligible workspace id
/// strings.
case allChildrenAreAnchors([String])
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ public import Foundation
/// The legacy body focused the owning window, made its TabManager active, then
/// selected the group anchor through `selectWorkspace` (so the selection side
/// effects fire). All of that is app state, so it runs behind the seam; the
/// coordinator mints the anchor workspace ref.
/// coordinator returns the existing anchor workspace ref.
public enum ControlWorkspaceGroupFocusResolution: Sendable, Equatable {
/// No TabManager resolved (legacy `unavailable` / "TabManager not
/// available").
Expand Down
Loading