iOS: render workspace groups as collapsible sections - #5625
lawrencecchen wants to merge 4 commits into
Conversation
|
The latest updates on your projects. Learn more about Vercel for GitHub.
|
📝 WalkthroughWalkthroughAdds Mac-style collapsible workspace groups to mobile: RPC decoding and mapping, preview/list models, grouped UI and header row, shell capability/state and collapse RPC, observer hashing for updates, backend payload routing, tests, localization, and design docs. ChangesiOS Workspace Groups Feature
🎯 4 (Complex) | ⏱️ ~45 minutes Possibly Related PRs
Suggested Reviewers
Poem
Important Pre-merge checks failedPlease resolve all errors before merging. Addressing warnings is optional. ❌ Failed checks (5 errors, 1 warning)
✅ Passed checks (15 passed)
✨ Finishing Touches📝 Generate docstrings
🧪 Generate unit tests (beta)
Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out. Comment |
Greptile SummaryThis PR surfaces Mac workspace groups on iOS: the Mac now emits
Confidence Score: 4/5Safe to merge with one known gap to watch: the end-to-end collapse round-trip (phone → Mac RPC → observer re-emit → phone refresh) requires a physical device paired to a Mac and is not yet covered by automated tests. The Mac-side payload changes, observer subscriptions, and iOS rendering logic are all sound and well-tested in isolation (unit tests cover render-item grouping and observer hash fidelity). The multi-window group aggregation correctly appends groups in window order and derives membership from per-workspace group_id. The fire-and-forget + authoritative re-fetch design avoids optimistic state drift. The one open area is that a group header can be emitted with an anchorWorkspaceID absent from the workspace list during transient payload skew, potentially leaving a blank detail pane until the next refresh. TerminalController+MobileWorkspaceList.swift (mobileWorkspaceGroupPayloads) — consider filtering out groups whose anchor workspace is not present in the workspace list to avoid a tappable header that navigates to a non-existent workspace view. Important Files Changed
Sequence DiagramsequenceDiagram
participant iOS as iOS App
participant Store as MobileShellComposite
participant Mac as Mac TerminalController
participant Obs as MobileWorkspaceListObserver
iOS->>Mac: workspace.list (initial sync)
Mac-->>iOS: workspaces + groups array
Store->>Store: "workspaceGroups = response.groups"
iOS->>Mac: mobile.host.status
Mac-->>iOS: capabilities including workspace.groups.v1
Store->>Store: "supportsWorkspaceGroups = true"
Note over iOS: User taps chevron
iOS->>Mac: workspace.group.collapse with group_id
Mac->>Mac: v2WorkspaceGroupSetCollapsed
Obs->>Obs: workspaceGroups fires, emitIfNeeded
Mac-->>iOS: workspace.updated event
iOS->>Mac: workspace.list re-fetch
Mac-->>iOS: workspaces + groups with isCollapsed true
Store->>Store: workspaceGroups updated
Reviews (4): Last reviewed commit: "Split mobile list payloads and actions o..." | Re-trigger Greptile |
| return Group { | ||
| if let toggleCollapsed { | ||
| Button { | ||
| toggleCollapsed(group.id, !group.isCollapsed) | ||
| } label: { | ||
| image | ||
| } | ||
| .buttonStyle(.plain) | ||
| } else { | ||
| image | ||
| } | ||
| } | ||
| .accessibilityAddTraits(.isButton) | ||
| .accessibilityLabel( | ||
| group.isCollapsed | ||
| ? L10n.string("mobile.workspaceGroup.expand.a11y", defaultValue: "Expand group") | ||
| : L10n.string("mobile.workspaceGroup.collapse.a11y", defaultValue: "Collapse group") | ||
| ) | ||
| .accessibilityIdentifier("MobileWorkspaceGroupDisclosure-\(group.id.rawValue)") |
There was a problem hiding this comment.
.accessibilityAddTraits(.isButton) is applied unconditionally to the Group, so when toggleCollapsed is nil the chevron image carries the isButton trait but has no activation action — VoiceOver will announce "Expand/Collapse group, button" and then nothing happens. The trait (and the label) should only be present on the interactive branch; the non-interactive chevron should be hidden from accessibility entirely since it carries no actionable meaning.
| return Group { | |
| if let toggleCollapsed { | |
| Button { | |
| toggleCollapsed(group.id, !group.isCollapsed) | |
| } label: { | |
| image | |
| } | |
| .buttonStyle(.plain) | |
| } else { | |
| image | |
| } | |
| } | |
| .accessibilityAddTraits(.isButton) | |
| .accessibilityLabel( | |
| group.isCollapsed | |
| ? L10n.string("mobile.workspaceGroup.expand.a11y", defaultValue: "Expand group") | |
| : L10n.string("mobile.workspaceGroup.collapse.a11y", defaultValue: "Collapse group") | |
| ) | |
| .accessibilityIdentifier("MobileWorkspaceGroupDisclosure-\(group.id.rawValue)") | |
| return Group { | |
| if let toggleCollapsed { | |
| Button { | |
| toggleCollapsed(group.id, !group.isCollapsed) | |
| } label: { | |
| image | |
| } | |
| .buttonStyle(.plain) | |
| .accessibilityAddTraits(.isButton) | |
| .accessibilityLabel( | |
| group.isCollapsed | |
| ? L10n.string("mobile.workspaceGroup.expand.a11y", defaultValue: "Expand group") | |
| : L10n.string("mobile.workspaceGroup.collapse.a11y", defaultValue: "Collapse group") | |
| ) | |
| .accessibilityIdentifier("MobileWorkspaceGroupDisclosure-\(group.id.rawValue)") | |
| } else { | |
| image | |
| .accessibilityHidden(true) | |
| } | |
| } |
| var lastEmittedGroupID: MobileWorkspaceGroupPreview.ID? | ||
| var emittedHeaders: Set<MobileWorkspaceGroupPreview.ID> = [] | ||
| var collapsedByGroupID: [MobileWorkspaceGroupPreview.ID: Bool] = [:] | ||
|
|
||
| for workspace in workspaces { | ||
| // Resolve the membership only when the referenced group actually | ||
| // exists; otherwise treat the workspace as ungrouped. | ||
| let groupID: MobileWorkspaceGroupPreview.ID? = workspace.groupID | ||
| .flatMap { groupsByID[$0] != nil ? $0 : nil } | ||
|
|
||
| if groupID != lastEmittedGroupID { | ||
| lastEmittedGroupID = groupID | ||
| if let groupID, let group = groupsByID[groupID], !emittedHeaders.contains(groupID) { | ||
| items.append(.groupHeader(group)) | ||
| 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)) | ||
| } |
There was a problem hiding this comment.
Non-contiguous same-group members render with a dangling indent
lastEmittedGroupID prevents emitting a second header for the same group, but the algorithm doesn't document what happens when group members are non-contiguous (e.g., [A(g1/anchor), B(ungrouped), C(g1)]). In that case C renders as indented: true after the ungrouped B, with no visible parent header nearby. Collapse still works correctly (because collapsedByGroupID was set at the first header), so this is a visual-only issue — but it's an undocumented edge case worth a comment since the graceful-degradation note in the doc-comment only covers the unknown-groupID case, not the non-contiguous same-group case. Mac groups are always contiguous today, but a brief payload skew could produce this ordering transiently.
Note: If this suggestion doesn't match your team's coding style, reply to this and let me know. I'll remember it for next time!
There was a problem hiding this comment.
Actionable comments posted: 1
Caution
Some comments are outside the diff and can’t be posted inline due to platform limitations.
⚠️ Outside diff range comments (1)
Sources/Mobile/MobileHostService.swift (1)
304-317: 🛠️ Refactor suggestion | 🟠 Major | 🏗️ Heavy liftExtract new capability/auth policy logic out of this oversized file.
This production file is already far beyond the repo’s Swift size/responsibility budget; adding more feature branches here increases coupling and makes future changes riskier. Please move the mobile capability/ticket-policy surface touched by this PR into a focused helper/type and keep
MobileHostServiceas orchestration glue.As per coding guidelines:
{Sources,CLI,Packages,cmuxTests,cmuxUITests}/**/*.swiftshould be flagged when production Swift files exceed the size thresholds and mix responsibilities.🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@Sources/Mobile/MobileHostService.swift` around lines 304 - 317, The MobileHostService nonisolated static var mobileHostCapabilities is implementing capability and auth policy logic inside a large production file; extract this surface into a focused helper type (e.g., MobileCapabilities or MobileHostPolicy) that owns the capability list and any ticket/policy logic, then update MobileHostService to call that helper (replace direct access to mobileHostCapabilities with MobileCapabilities.shared.capabilities or similar). Move all capability strings and any related ticket-policy decision code out of MobileHostService into the new type, keep MobileHostService as orchestration glue only, and ensure the new helper is small, well-named, and covered by existing tests or add lightweight unit tests for its policy decisions.Source: Coding guidelines
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
Inline comments:
In `@Sources/TerminalController.swift`:
- Around line 21284-21286: mobileWorkspaceGroupPayload currently does a full
scan of the tabs array per group (memberIds = tabs.compactMap { $0.groupId ==
group.id ? ... }), causing O(groups × workspaces) work; instead build a single
index mapping groupId -> [workspaceId] once for the tabs list and reuse it when
serializing each group. Modify the call site that iterates groups to first
compute let workspacesByGroup = Dictionary(grouping: tabs, by: { $0.groupId })
(or a helper function that returns [UUID: [String]]), then change
mobileWorkspaceGroupPayload to accept that precomputed map (or add an overload)
and use workspacesByGroup[group.id] to create memberIds, avoiding repeated scans
of tabs.
---
Outside diff comments:
In `@Sources/Mobile/MobileHostService.swift`:
- Around line 304-317: The MobileHostService nonisolated static var
mobileHostCapabilities is implementing capability and auth policy logic inside a
large production file; extract this surface into a focused helper type (e.g.,
MobileCapabilities or MobileHostPolicy) that owns the capability list and any
ticket/policy logic, then update MobileHostService to call that helper (replace
direct access to mobileHostCapabilities with
MobileCapabilities.shared.capabilities or similar). Move all capability strings
and any related ticket-policy decision code out of MobileHostService into the
new type, keep MobileHostService as orchestration glue only, and ensure the new
helper is small, well-named, and covered by existing tests or add lightweight
unit tests for its policy decisions.
🪄 Autofix (Beta)
Fix all unresolved CodeRabbit comments on this PR:
- Push a commit to this branch (recommended)
- Create a new PR with the fixes
ℹ️ Review info
⚙️ Run configuration
Configuration used: Path: .coderabbit.yaml
Review profile: ASSERTIVE
Plan: Pro
Run ID: 8d84a773-57a6-4dad-9bab-7639dc021339
📒 Files selected for processing (15)
Packages/CmuxMobileRPC/Sources/CmuxMobileRPC/MobileSyncWorkspaceListResponse.swiftPackages/CmuxMobileRPC/Sources/CmuxMobileRPC/MobileWorkspacePreview+RemoteMapping.swiftPackages/CmuxMobileShell/Sources/CmuxMobileShell/MobileShellComposite.swiftPackages/CmuxMobileShellModel/Sources/CmuxMobileShellModel/MobileWorkspaceGroupPreview.swiftPackages/CmuxMobileShellModel/Sources/CmuxMobileShellModel/MobileWorkspaceListItem.swiftPackages/CmuxMobileShellModel/Sources/CmuxMobileShellModel/MobileWorkspacePreview.swiftPackages/CmuxMobileShellModel/Tests/CmuxMobileShellModelTests/MobileWorkspaceListItemTests.swiftPackages/CmuxMobileShellUI/Sources/CmuxMobileShellUI/WorkspaceGroupHeaderRow.swiftPackages/CmuxMobileShellUI/Sources/CmuxMobileShellUI/WorkspaceListView.swiftPackages/CmuxMobileShellUI/Sources/CmuxMobileShellUI/WorkspaceShellView.swiftSources/Mobile/MobileHostService.swiftSources/Mobile/MobileWorkspaceListObserver.swiftSources/TerminalController.swiftios/cmux/Resources/Localizable.xcstringsplans/feat-ios-groups-mobile/DESIGN.md
bb79d1f to
5ad822b
Compare
|
Refreshed this branch: rebased onto current main (was 33 commits behind), fixed a real bug autoreview caught, and added a regression test. Why the user saw nothing: this PR was never merged, so the groups code is simply not in their build. There is no payload-gating or capability-check bug that would hide groups when the code is present. The iOS gate chain is sound ( Bug fixed during refresh: the mobile workspace-list summary hash includes each workspace's Build-verified: iOS simulator ( |
There was a problem hiding this comment.
Cursor Bugbot has reviewed your changes and found 1 potential issue.
❌ Bugbot Autofix is OFF. To automatically fix reported issues with cloud agents, enable autofix in the Cursor dashboard.
Reviewed by Cursor Bugbot for commit 5ad822b. Configure here.
5ad822b to
c1982e1
Compare
|
Rebased onto current main and addressed all review feedback in two follow-up commits:
Build-verified iOS simulator (arm64) and macOS, package tests pass, autoreview clean. |
The iOS workspace list flattened workspaces and ignored the desktop's named, collapsible groups. The mobile workspace-list payload did not carry any group structure, so surface it on the Mac host, then render it on iOS. Mac host: - mobileWorkspacePayload now emits group_id per workspace; the list adds a top-level groups array (id, name, is_collapsed, is_pinned, anchor_workspace_id, member_workspace_ids). The all-windows branch aggregates each window's groups in window-iteration order. - MobileWorkspaceListObserver now subscribes to $workspaceGroups and folds group order/name/collapse/pin/anchor + per-workspace groupId into its summary hash, so a collapse/expand (or rename/move) re-emits workspace.updated to the phone. Without this the phone's disclosure would look frozen. - Expose workspace.group.collapse/expand to mobile (mobileHostHandleRPC + the ticket-auth model), gated by the same same-account Stack auth as the rest of the data plane. Advertise a workspace.groups.v1 capability. iOS: - Decode optional group_id per workspace + an optional groups array (backward compatible with older Macs). - MobileWorkspaceGroupPreview value model; MobileWorkspaceListItem builds the ordered render items mirroring SidebarWorkspaceRenderItem (anchor renders as the header, no separate row; collapsed hides members; ungrouped interleave by position). - WorkspaceGroupHeaderRow: chevron toggles collapse; tapping the name selects/opens the anchor workspace (so the anchor stays reachable), mirroring the desktop header. - Store carries workspaceGroups + supportsWorkspaceGroups and a fire-and-forget setWorkspaceGroupCollapsed RPC (authoritative re-fetch, no optimistic state). The grouped section renders only when the Mac advertises the capability and the user is not searching; otherwise the list stays flat (pinned-first), preserving member contiguity. P1 is display + expand/collapse. Phone-side group create/rename/restructure is deferred. New strings localized (en + ja). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The mobile workspace-list summary hash includes each workspace's groupId, but the observer never subscribed to workspace.$groupId. Moving a workspace into or out of an existing group mutates only that workspace's groupId (not the tab set, workspaceGroups, panels, title, or pin state), so emitIfNeeded never fired and paired phones kept rendering stale group membership until some unrelated change woke the observer. Add workspace.$groupId to the per-workspace publisher set and cover a pure membership move in MobileWorkspaceListFidelityTests. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Review feedback (Cursor, Greptile): grouped sections rendered only once mobile.host.status returned and advertised workspace.groups.v1, so a slow or failed status fetch kept the list flat even though the workspace list already carried groups. A Mac that emits groups also handles workspace.group.collapse/expand (they shipped together), so render from the payload and derive the toggle closure from capability-or-payload. The passive chevron (no toggle) no longer carries an .isButton accessibility trait it cannot honor, and the render-item builder documents the non-contiguous-member degradation. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The groups payload pushed TerminalController.swift (+66), MobileShellComposite.swift (+49), and MobileHostService.swift (+11) over the Swift file length budget. Move the mobile workspace-list payload builders and the mobile group collapse handler into TerminalController+MobileWorkspaceList.swift, the fire-and-forget workspace mutations into MobileShellComposite+WorkspaceActions.swift, and the advertised capability list into MobileHostService+Capabilities.swift, then tighten the three budget entries to the new actuals. Also resolves group membership with a single pass over tabs instead of one scan per group (review feedback) via mobileWorkspaceGroupPayloads. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
c1982e1 to
660cd47
Compare
|
Verdict on "groups don't show on the phone": both causes were real, and both are handled now.
Branch refreshed onto current origin/main (72812f3) and force-pushed; the rebase was conflict-free. |
There was a problem hiding this comment.
Actionable comments posted: 1
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
Inline comments:
In `@Sources/TerminalController.swift`:
- Around line 20826-20829: The collapse/expand RPC handlers call
v2MobileWorkspaceGroupSetCollapsed (which delegates to
v2WorkspaceGroupSetCollapsed) but neither function verifies that the caller's
MobileHostService.mobileHostCapabilities includes "workspace.groups.v1"; add a
server-side capability check at the start of v2MobileWorkspaceGroupSetCollapsed
(or in v2WorkspaceGroupSetCollapsed) that uses the request's
MobileHostService/authorization context and, if the capability is missing,
immediately return a standardized "missing required capability" error (same
style used elsewhere) before performing any authorization checks or mutating the
group collapsed state; keep MobileHostService.requiresAuthorization behavior
intact for same-account enforcement, but gate the feature first.
🪄 Autofix (Beta)
Fix all unresolved CodeRabbit comments on this PR:
- Push a commit to this branch (recommended)
- Create a new PR with the fixes
ℹ️ Review info
⚙️ Run configuration
Configuration used: Path: .coderabbit.yaml
Review profile: ASSERTIVE
Plan: Pro
Run ID: 6305efa2-5744-4284-bbe7-a4620bb202c3
⛔ Files ignored due to path filters (1)
.github/swift-file-length-budget.tsvis excluded by!**/*.tsv
📒 Files selected for processing (20)
Packages/CmuxMobileRPC/Sources/CmuxMobileRPC/MobileSyncWorkspaceListResponse.swiftPackages/CmuxMobileRPC/Sources/CmuxMobileRPC/MobileWorkspacePreview+RemoteMapping.swiftPackages/CmuxMobileShell/Sources/CmuxMobileShell/MobileShellComposite+WorkspaceActions.swiftPackages/CmuxMobileShell/Sources/CmuxMobileShell/MobileShellComposite.swiftPackages/CmuxMobileShellModel/Sources/CmuxMobileShellModel/MobileWorkspaceGroupPreview.swiftPackages/CmuxMobileShellModel/Sources/CmuxMobileShellModel/MobileWorkspaceListItem.swiftPackages/CmuxMobileShellModel/Sources/CmuxMobileShellModel/MobileWorkspacePreview.swiftPackages/CmuxMobileShellModel/Tests/CmuxMobileShellModelTests/MobileWorkspaceListItemTests.swiftPackages/CmuxMobileShellUI/Sources/CmuxMobileShellUI/WorkspaceGroupHeaderRow.swiftPackages/CmuxMobileShellUI/Sources/CmuxMobileShellUI/WorkspaceListView.swiftPackages/CmuxMobileShellUI/Sources/CmuxMobileShellUI/WorkspaceShellView.swiftSources/Mobile/MobileHostService+Capabilities.swiftSources/Mobile/MobileHostService.swiftSources/Mobile/MobileWorkspaceListObserver.swiftSources/TerminalController+MobileWorkspaceList.swiftSources/TerminalController.swiftcmux.xcodeproj/project.pbxprojcmuxTests/MobileWorkspaceListFidelityTests.swiftios/cmux/Resources/Localizable.xcstringsplans/feat-ios-groups-mobile/DESIGN.md
| case "workspace.group.collapse": | ||
| result = v2MobileWorkspaceGroupSetCollapsed(params: request.params, isCollapsed: true) | ||
| case "workspace.group.expand": | ||
| result = v2MobileWorkspaceGroupSetCollapsed(params: request.params, isCollapsed: false) |
There was a problem hiding this comment.
🧩 Analysis chain
🏁 Script executed:
#!/bin/bash
# Description: Locate v2MobileWorkspaceGroupSetCollapsed and verify capability/auth gating
# Find the function definition
echo "=== Searching for v2MobileWorkspaceGroupSetCollapsed definition ==="
rg -n -A 30 'func v2MobileWorkspaceGroupSetCollapsed' Sources/
# Verify capability gating for workspace.groups.v1
echo ""
echo "=== Checking for workspace.groups.v1 capability validation ==="
rg -n -C 5 'workspace\.groups\.v1' Sources/TerminalController.swift Sources/Mobile/
# Verify auth validation in mobile RPC context
echo ""
echo "=== Checking for mobile auth validation pattern ==="
rg -n -C 3 'mobileHostTicketAccount|MobileHostService.*auth' Sources/Mobile/Repository: manaflow-ai/cmux
Length of output: 5905
🏁 Script executed:
#!/bin/bash
set -euo pipefail
echo "=== v2WorkspaceGroupSetCollapsed definition ==="
rg -n -A 80 'func v2WorkspaceGroupSetCollapsed' Sources/
echo
echo "=== Capability gating helpers usage in mobile workspace group handlers ==="
rg -n -C 3 'workspace\.groups\.v1|groups\.v1|v2HasCapability|hasCapability|capabilit' Sources/TerminalController+MobileWorkspaceList.swift Sources/TerminalController.swift Sources/Mobile/
echo
echo "=== Same-account auth / authorization usage near group collapse/expand ==="
rg -n -C 3 'same-account|sameAccount|account.*id|authorize|authorization|requiresAuthorization|authorized' Sources/TerminalController+MobileWorkspaceList.swift Sources/TerminalController.swift Sources/Mobile/
echo
echo "=== Dispatcher routes for workspace.group.collapse/expand ==="
rg -n -C 5 'workspace\.group\.collapse|workspace\.group\.expand' Sources/TerminalController.swiftRepository: manaflow-ai/cmux
Length of output: 38675
🏁 Script executed:
#!/bin/bash
set -euo pipefail
echo "=== occurrences of workspace.group.collapse / expand in Sources/Mobile and TerminalController ==="
rg -n 'workspace\.group\.(collapse|expand)' Sources/Mobile Sources/TerminalController.swift Sources/TerminalController+*.swift
echo
echo "=== occurrences of workspace.groups.v1 across Sources ==="
rg -n 'workspace\.groups\.v1' Sources/
echo
echo
echo "=== check authorization gating: requiresAuthorization(method:) cases ==="
rg -n 'requiresAuthorization\(method:|switch method' Sources/Mobile/MobileHostService.swift
rg -n -A 200 'nonisolated private static func requiresAuthorization\(method: String\)' Sources/Mobile/MobileHostService.swift
echo
echo
echo "=== check if capability gating exists in mobile host request authorization path ==="
rg -n '(capabilit|capability|missing required capability|requiresCapability|required capability)' Sources/Mobile/MobileHostService.swift Sources/Mobile/Repository: manaflow-ai/cmux
Length of output: 11139
🏁 Script executed:
#!/bin/bash
set -euo pipefail
echo "=== Search for server-side capability enforcement (missing required capability / requiresCapability) ==="
rg -n 'missing required capability|required capability|requiresCapability|hasCapability|capabilit' Sources/TerminalController.swift Sources/Mobile Sources/TerminalController+*.swift
echo
echo "=== Look specifically for capability checks around workspace.group.collapse/expand handlers ==="
rg -n -C 5 'workspace\.group\.(collapse|expand)' Sources/TerminalController.swiftRepository: manaflow-ai/cmux
Length of output: 3848
🏁 Script executed:
#!/bin/bash
set -euo pipefail
echo "=== TerminalController+MobileWorkspaceList.swift: v2MobileWorkspaceGroupSetCollapsed context ==="
sed -n '1,120p' Sources/TerminalController+MobileWorkspaceList.swift
echo
echo "=== TerminalController.swift: mobile RPC dispatcher switch context around workspace.group.collapse/expand ==="
sed -n '20780,20860p' Sources/TerminalController.swift
echo
echo "=== TerminalController.swift: v2WorkspaceGroupSetCollapsed full context ==="
sed -n '5060,5130p' Sources/TerminalController.swiftRepository: manaflow-ai/cmux
Length of output: 14237
🏁 Script executed:
#!/bin/bash
set -euo pipefail
echo "=== Find any v2RequiresCapability / v2HasCapability helpers ==="
rg -n 'v2(Requires|Has)Capability|required capability|missing required capability' Sources/
echo
echo "=== Find where errors mentioning 'missing required capability' are constructed ==="
rg -n 'missing required capability' Sources/
echo
echo "=== Search for capability string 'workspace.groups.v1' usage beyond capability advertisement ==="
rg -n 'workspace\.groups\.v1' Sources/
echo
echo "=== Search for required-capability checks near RPC method dispatcher ==="
rg -n 'workspace\.group\.collapse|workspace\.group\.expand' -C 20 Sources/TerminalController.swiftRepository: manaflow-ai/cmux
Length of output: 8550
Enforce workspace.groups.v1 capability gating for workspace.group.collapse/expand RPCs
- The routes call
v2MobileWorkspaceGroupSetCollapsed(...)→v2WorkspaceGroupSetCollapsed(...), but neither function checks whetherworkspace.groups.v1is supported; the capability only appears inMobileHostService.mobileHostCapabilitiesadvertisement. - Same-account auth is enforced at the mobile RPC transport layer via
MobileHostService.requiresAuthorization(...)+ Stack same-account authorization; onlymobile.host.statusis exempt. - Add a server-side
workspace.groups.v1check (returning a “missing required capability” style error) before mutating group collapsed state to match the PR objective and advertised capability.
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@Sources/TerminalController.swift` around lines 20826 - 20829, The
collapse/expand RPC handlers call v2MobileWorkspaceGroupSetCollapsed (which
delegates to v2WorkspaceGroupSetCollapsed) but neither function verifies that
the caller's MobileHostService.mobileHostCapabilities includes
"workspace.groups.v1"; add a server-side capability check at the start of
v2MobileWorkspaceGroupSetCollapsed (or in v2WorkspaceGroupSetCollapsed) that
uses the request's MobileHostService/authorization context and, if the
capability is missing, immediately return a standardized "missing required
capability" error (same style used elsewhere) before performing any
authorization checks or mutating the group collapsed state; keep
MobileHostService.requiresAuthorization behavior intact for same-account
enforcement, but gate the feature first.
…ps, #5625) Conflict resolutions: - MobileShellComposite: union of dog (notifications store, paste/dogfood capabilities, feedback submitter) and groups (workspaceGroups, supportsWorkspaceGroups); clientID made internal for the new +WorkspaceActions extension; dropped dog's inline rename/pin copies (moved to MobileShellComposite+WorkspaceActions.swift); kept un-gated privileged Send Feedback - MobileHostService: capability list now lives only in +Capabilities.swift, unioned dog entries (notification.dismiss.v1, terminal.paste.v1, unconditional dogfood.v1, DEBUG checklist/feedback) with workspace.groups.v1 - TerminalController: kept both notification and workspace.group RPC cases; preserved un-gated dogfood.feedback.submit - WorkspaceListView/ShellView: grouped/flat row structure from groups branch with dog's unread counts, mute, device tree threaded through workspaceRow
Reset to origin/main (composer #5876 landed), then merge old dog HEAD a300868 to preserve every feature not yet on main: notifications dismiss-sync (#5568), multi-Mac switcher hardening (#5545), foreground repaint (#5571), image paste too-large toast (#5572), hidden native input (#5596), workspace groups (#5625), wslist round-10 snapshot, scroll-to-bottom hysteresis, DEV dogfood pane, attachments button, arrow toolbar keys, terminal.paste capability gating. Conflict policy: main's reviewed composer-land form wins for composer core (keyed focus handshake, draft FIFO coalescing, paste submit partial-success), dog wins for unlanded feature surface. ghostty pinned to dog 34cbf18 (descendant of main's e5c962a). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Carries unlanded: multi-Mac #5545, notif-sync #5568, foreground-repaint #5571, image-paste toast #5572, hidden-input #5596, groups (iOS side) #5625, scroll-hysteresis, dogfood pane, capabilities superset. Main's reviewed forms win: TerminalController decomposition (Control*Context), notif-tap-deeplink #5927, mobile.terminal.* routing.
…#5596/#5625/#5628) over current main Beta queue (#5876/#5872/#5869/#5875/#5927/#5912/#5726/#5776/#5916) is now on main; conflicts resolved by taking main as authoritative for the merged workspace-list/notifications/read-state/close surface, while preserving the carry-set: terminal.paste capability (#5572), hidden-input strings (#5596), smooth-scroll/scroll-to-bottom (#5628), and the live notifications feed (notificationsStore + mobile.notifications.list/mark_read dispatch). Dropped the superseded mute design. Capability flags unified onto main's computed supportedHostCapabilities set (added computed supportsTerminalPaste + DEBUG supportsDogfoodChecklist). xcstrings merged (HEAD-precedence union, mute keys dropped). pbxproj took HEAD consistently; budget regenerated. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

The iOS app flattened workspaces and ignored the desktop's named, collapsible groups. This renders groups on the phone: collapsible named sections in group order, members nested under the group header, mirroring desktop semantics (the anchor workspace renders as the header, not a separate row; collapsing hides members but keeps the header).
P1 is display plus expand/collapse from the phone. Phone-side group create/rename/restructure is deferred.
Did the mobile payload already carry group info?
No.
mobileWorkspacePayloademitted only id/title/current_directory/is_selected/is_pinned/terminals, andv2MobileWorkspaceListhad no groups. The first step was to surface it on the Mac host, then render it.Mac host (surface group structure)
mobileWorkspacePayloadnow emitsgroup_idper workspace (nil for ungrouped).v2MobileWorkspaceListadds a top-levelgroupsarray (id,name,is_collapsed,is_pinned,anchor_workspace_id,member_workspace_ids). The all-windows branch aggregates each window's groups in window-iteration order (groups are per-TabManager).MobileWorkspaceListObservernow subscribes to$workspaceGroupsand folds group order/name/collapse/pin/anchor plus each workspace'sgroupIdinto its summary hash. Without this a phone collapse togglesisCollapsedon the Mac but the observer never re-emitsworkspace.updated, so the disclosure would look frozen.workspace.group.collapse/workspace.group.expandare exposed to mobile (added tomobileHostHandleRPCand the ticket-auth model inMobileHostService), gated by the same same-account Stack auth as the rest of the data plane. Routing is bygroup_id, whichv2ResolveTabManageralready resolves to the owning window across all windows. Newworkspace.groups.v1capability so iOS feature-detects.iOS (decode + render + collapse)
MobileSyncWorkspaceListResponsedecodes an optionalgroup_idper workspace and an optionalgroupsarray (backward compatible: both absent on older Macs).MobileWorkspaceGroupPreviewvalue model;MobileWorkspaceListItem.itemsbuilds the ordered render items mirroringSidebarWorkspaceRenderItem(anchor as header, collapsed hides members, ungrouped interleave by position, unknown group degrades to an ungrouped row).WorkspaceGroupHeaderRow: the chevron toggles collapse; tapping the name selects/opens the anchor workspace, so the anchor's terminals stay reachable (mirrors the desktop header, whose chevron collapses and whose body focuses the anchor).workspaceGroups+supportsWorkspaceGroupsand a fire-and-forgetsetWorkspaceGroupCollapsedRPC (authoritative re-fetch via the observer, no local optimistic state). The grouped section renders only when the Mac advertises the capability and the user is not searching; otherwise the list stays flat (pinned-first), preserving member contiguity. Search deliberately flattens so members are findable across groups.Where the list renders groups
Packages/CmuxMobileShellModel/Sources/CmuxMobileShellModel/MobileWorkspaceListItem.swift(render-item builder)Packages/CmuxMobileShellUI/Sources/CmuxMobileShellUI/WorkspaceListView.swiftgroupedRows(the grouped List section)Packages/CmuxMobileShellUI/Sources/CmuxMobileShellUI/WorkspaceGroupHeaderRow.swift(the collapsible header)Verification
MobileWorkspaceListItemTests) pass.New user-facing strings localized (en + ja).
🤖 Generated with Claude Code
Need help on this PR? Tag
/codesmithwith what you need. Autofix is disabled.Note
Medium Risk
Touches mobile RPC payloads, multi-window list aggregation, and sync observer hashing; display-only mutations but incorrect merge/refresh logic could flatten or freeze group UI on iOS.
Overview
The iOS workspace list no longer ignores Mac sidebar groups: it can show collapsible named sections (anchor as header, indented members) and collapse/expand from the phone, with search still using a flat pinned-first list.
Mac host extends the mobile
workspace.listpayload with per-workspacegroup_idand a top-levelgroupsarray (including multi-window aggregation). It adds mobile RPC handlers forworkspace.group.collapse/expand, advertisesworkspace.groups.v1, and moves list serialization intoTerminalController+MobileWorkspaceList.swift.MobileWorkspaceListObservernow watches$workspaceGroupsand each workspace’sgroupIdin its summary hash so collapse and membership changes pushworkspace.updated.iOS decodes the new fields (backward compatible), maps them to
MobileWorkspaceGroupPreview/groupIDon previews, and builds rows viaMobileWorkspaceListItemplusWorkspaceGroupHeaderRow. The shell store keepsworkspaceGroups, gates on capability (or emitted groups), and sends fire-and-forget collapse RPCs without optimistic local state; full-list refreshes only update groups so merge paths do not clear sections.Reviewed by Cursor Bugbot for commit 660cd47. Bugbot is set up for automated code reviews on this repo. Configure here.
Summary by cubic
Adds collapsible workspace groups to the iOS workspace list to mirror desktop: the anchor renders as the header, members nest beneath, and collapsing hides members. The Mac now emits group data in the mobile payload and supports collapse/expand over mobile RPC.
New Features
group_idper workspace and a top-levelgroupsarray inv2MobileWorkspaceList(aggregated across windows in order); exposesworkspace.group.collapse/workspace.group.expand; advertisesworkspace.groups.v1.group_idandgroups(defaultsgroupsto empty for older Macs); maps toMobileWorkspaceGroupPreview;MobileWorkspaceListItembuilds ordered items (anchor-as-header, collapsed hides members, unknown groups render ungrouped);WorkspaceGroupHeaderRowtoggles collapse and opens the anchor, and the chevron is passive when toggle isn’t available; grouped layout renders when the payload includes groups and search is empty, otherwise the list stays flat (pinned-first); groups update only on full-list refresh (merge responses omitgroups); adds a11y labels (en, ja).Bug Fixes
MobileWorkspaceListObservernow watches$workspaceGroupsand per-workspace$groupId, so collapse/rename/membership moves re-emitworkspace.updated; adds tests (including a pure membership-move hash check).Written for commit 660cd47. Summary will update on new commits.
Summary by CodeRabbit
New Features
UI Changes
Accessibility
Tests
Documentation