Skip to content

ios: unread-workspace count badge on the back button - #6350

Merged
lawrencecchen merged 6 commits into
mainfrom
feat-ios-back-unread-badge
Jun 18, 2026
Merged

lawrencecchen merged 6 commits into
mainfrom
feat-ios-back-unread-badge

Conversation

@lawrencecchen

@lawrencecchen lawrencecchen commented Jun 17, 2026 •

Copy link
Copy Markdown
Contributor

What

On the pushed workspace detail screen (compact / iPhone), show how many other workspaces have unread activity as a small accent pill right next to the system back button, so you can see at a glance what's waiting back in the list.

How

  • New WorkspaceBackUnreadBadge view: an iMessage-style Color.accentColor pill with the count, reusing the same accent as WorkspaceUnreadDot. Hidden at zero; caps the glyphs at 99+ while VoiceOver hears the exact count.
  • Added the badge only in the compact .navigationDestination closure, not in workspaceDestination itself (the iPad split layout reuses that and has no back button). It's a topBarLeading item that coexists with the system back button, so the back chevron and the swipe-back gesture are preserved.
  • Count = store.workspaces with hasUnread, excluding the workspace you're currently viewing.
  • Localized accessibility label mobile.workspace.backUnreadCountFormat (en + ja) added to the app string catalog.

Split out from the liquid-glass-header work (#6300) per request, as its own PR. Independent files (WorkspaceShellView, new badge view, catalog), no overlap with that PR.

Verification

  • iOS device build (dev.cmux.ios.unread) compiled clean and installed to a physical iPhone via the cloud builder; macOS app rebuilt on the same tag.
  • Dogfood: with one or more other workspaces unread, the pill shows the count beside the back button; goes away when all are read; swipe-back still works.

Localization audit: one new user-facing string (the accessibility label) added with en + ja; the badge glyphs are a number (not localizable). No other user-facing strings changed.


View with Codesmith Autofix with Codesmith
Need help on this PR? Tag /codesmith with what you need. Autofix is disabled.


Note

Low Risk
iOS-only navigation and read-state UX; Mac sync is gated on existing capability flags and only fires for unread workspaces.

Overview
On iPhone compact navigation, the workspace detail screen replaces the system back button with a custom WorkspaceBackButton that shows a chevron plus how many other workspaces are unread (e.g. “‹ 3”), capped at 99+ with full counts in VoiceOver. The count excludes the workspace you’re viewing; iPad split layout is unchanged.

InteractiveSwipeBackEnabler restores edge swipe-back after hiding the system back control. Opening a workspace from the list now marks it read on the Mac when read-state actions are supported and it was unread, so the badge and unread list stay in sync.

New en/ja strings for Back and the unread-count accessibility label.

Reviewed by Cursor Bugbot for commit c244013. Bugbot is set up for automated code reviews on this repo. Configure here.


Summary by cubic

Replaces the system back button on iPhone workspace detail with a custom back button that shows how many other workspaces have unread activity (e.g., "‹ 3"). Keeps edge swipe-back; iPad split view unchanged.

  • New Features

    • WorkspaceBackButton: chevron + count in one control; count on a small monochrome circle with caption2 semibold monospaced digits; hidden at 0; caps at "99+" while VoiceOver reads the exact count.
    • Applied only in the compact .navigationDestination; replaces the system back button and pops via popCompactStack; InteractiveSwipeBackEnabler restores swipe-back.
    • Count excludes the current workspace. Opening a workspace now marks it read on supported Macs, so it drops from the back-button count. Added mobile.workspace.back and mobile.workspace.backUnreadCountFormat localizations (en, ja).
  • Bug Fixes

    • Fixed misplaced @ViewBuilder that attached to the Int helper; now correctly applied to workspaceDestination.
    • Bumped Swift file-length budget for MobileShellComposite.swift (+8) to accommodate the read-on-open block and keep CI green.

Written for commit c244013. Summary will update on new commits.

Review in cubic

Summary by CodeRabbit

Release Notes

  • New Features
    • Back navigation now includes an unread-workspaces indicator, showing a numeric badge and “99+” for large counts.
    • Unread counts are tailored to the currently displayed destination.
    • Added localized, pluralized text for the unread-workspaces back button message.
  • Bug Fixes
    • When supported, opening an unread workspace clears its unread status on the paired desktop immediately.
  • Accessibility
    • Improved accessibility labels for the back button to reflect unread-workspaces context.
  • Platform Enhancements
    • Restored iOS interactive swipe-back behavior.

On the pushed workspace detail (compact stack), show how many OTHER workspaces
have unread activity as an iMessage-style accent pill right beside the system
back button, so you can see at a glance what's waiting back in the list. Hidden
at zero; caps the glyphs at 99+ while VoiceOver still hears the exact count.

- New WorkspaceBackUnreadBadge view (reuses Color.accentColor, like
  WorkspaceUnreadDot).
- Added only in the compact `.navigationDestination` (not workspaceDestination
  itself, which the iPad split layout also uses and has no back button); the
  badge coexists with the system back button, so swipe-back is preserved.
- Count = workspaces with hasUnread, excluding the one you're viewing.
- Localized accessibility label (en + ja) in the app string catalog.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@vercel

vercel Bot commented Jun 17, 2026 •

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated (UTC)
cmux Ready Ready Preview, Comment Jun 17, 2026 11:48pm
cmux-staging Building Building Preview, Comment Jun 17, 2026 11:48pm

@coderabbitai

coderabbitai Bot commented Jun 17, 2026 •

Copy link
Copy Markdown

Review Change Stack

Note

Reviews paused

It looks like this branch is under active development. To avoid overwhelming you with review comments due to an influx of new commits, CodeRabbit has automatically paused this review. You can configure this behavior by changing the reviews.auto_review.auto_pause_after_reviewed_commits setting.

Use the following commands to manage reviews:

  • @coderabbitai resume to resume automatic reviews.
  • @coderabbitai review to trigger a single review.

Use the checkboxes below for quick actions:

  • ▶️ Resume reviews
  • 🔍 Trigger review
📝 Walkthrough

Walkthrough

Adds WorkspaceBackButton, a SwiftUI chevron button with an optional capped unread count badge and localized accessibility labels. WorkspaceShellView replaces the system back button on pushed compact-stack destinations with this component, backed by a new unreadWorkspaceCount(excluding:) helper and popCompactStack() action. An InteractiveSwipeBackEnabler UIViewControllerRepresentable restores the interactive swipe-back gesture. When a workspace is opened, its unread state is cleared on the paired Mac if the host supports workspace read-state actions. A new mobile.workspace.backUnreadCountFormat localization key is added for English and Japanese.

Changes

Workspace Back Button and Unread State

Layer / File(s) Summary
WorkspaceBackButton component and localization
Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/WorkspaceBackButton.swift, ios/cmux/Resources/Localizable.xcstrings
WorkspaceBackButton renders a chevron button with a conditionally visible unread count (capped at 99+), primary foreground styling, and a localized accessibility label. The mobile.workspace.backUnreadCountFormat key is added with English and Japanese pluralized format strings.
Toolbar wiring, unread count helpers, and swipe-back enabler
Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/WorkspaceShellView.swift
On pushed compact-stack destinations, the system back button is hidden and WorkspaceBackButton is injected as a leading toolbar item wired to popCompactStack(). unreadWorkspaceCount(excluding:) filters store.workspaces by hasUnread while excluding the current workspace ID. InteractiveSwipeBackEnabler (iOS-only UIViewControllerRepresentable) reinstates the interactive pop gesture when more than one view controller is in the stack.
Workspace open action with unread state clearing
Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/MobileShellComposite.swift
When a workspace is opened, if the Mac advertises workspace.read_state.v1 support and the workspace is currently unread, setWorkspaceUnread(id:, false) is called to clear the unread state immediately.

Estimated code review effort

🎯 2 (Simple) | ⏱️ ~12 minutes

Possibly related PRs

  • manaflow-ai/cmux#6022: This PR calls setWorkspaceUnread, the workspace read-state mutation introduced in that PR, to clear unread state when a workspace is opened.
  • manaflow-ai/cmux#6124: Both PRs modify WorkspaceShellView's compactNavigationPath handling—one synchronizes state on path changes, the other introduces popCompactStack() that mutates the same path.

Poem

🐰 A chevron appears, small and bright,
With unread badges glowing in sight,
Swipe back with ease, the gesture restored,
"99+" — too many to be ignored!
Tap to open, the count falls away,
Tidy workspaces brighten the day. 🌟


Important

Pre-merge checks failed

Please resolve all errors before merging. Addressing warnings is optional.

❌ Failed checks (3 errors, 1 warning)

Check name Status Explanation Resolution
Cmux Cache Substitution Correctness ❌ Error The openWorkspace method gates a persistence action (network RPC to mark workspace read) on stale cached value workspace?.hasUnread == true without handling staleness. Should only gate on `supp... Remove the workspace?.hasUnread == true condition: if supportsWorkspaceReadStateActions { await setWorkspaceUnread(id: id, false) }
Cmux Algorithmic Complexity ❌ Error The PR introduces unreadWorkspaceCount(excluding:) that performs store.workspaces.filter in SwiftUI navigationDestination (a computed var stackLayout property), re-evaluating on every store.workspa... Cache the unread count in @State or compute it in the store model; or add a performance benchmark/measurement showing acceptability within UI budget expectations.
Cmux Architecture Rethink ❌ Error MobileShellComposite.openWorkspace() gates authoritative read-receipt write on cached hasUnread state without handling staleness, violating the architectural rule against swapping fresh reads for c... Remove the workspace?.hasUnread == true condition; send setWorkspaceUnread(id, false) whenever supportsWorkspaceReadStateActions is true, allowing the backend to handle idempotency.
Docstring Coverage ⚠️ Warning Docstring coverage is 20.00% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (18 passed)
Check name Status Explanation
Title check ✅ Passed The title clearly and concisely summarizes the main change: adding an unread workspace count badge to the back button on iOS.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
Cmux Swift Actor Isolation ✅ Passed All new Swift declarations properly respect actor isolation: SwiftUI View types intentionally on MainActor, value types immutable, UIViewController subclasses have implicit MainActor isolation, no...
Cmux Swift Blocking Runtime ✅ Passed No blocking or timing-based synchronization patterns (semaphores, Task.sleep, locks, main-queue sync, polling) introduced. Code uses cooperative async/await and standard synchronous operations only.
Cmux Expensive Synchronous Load ✅ Passed The PR adds UI components (WorkspaceBackButton, InteractiveSwipeBackEnabler) and simple in-memory operations (filtering workspaces array, popping navigation path). No expensive synchronous loaders...
Cmux No Hacky Sleeps ✅ Passed This check applies only to TypeScript, JavaScript, shell, and build/runtime scripts. All changes are in Swift files or localization resources, which are out of scope per the rule.
Cmux Swift Concurrency ✅ Passed All async patterns introduced use Swift concurrency (async/await, @MainActor, proper Task usage) with no legacy patterns. Fire-and-forget Tasks are confined to allowed SwiftUI callback boundaries a...
Cmux Swift @Concurrent ✅ Passed All async/concurrent patterns in the PR comply with the rule. No @concurrent violations found. openWorkspace() correctly remains async (not @concurrent) as it's a @MainActor method that coordinates...
Cmux Swift File And Package Boundaries ✅ Passed PR complies with Swift file/package boundary rules: new WorkspaceBackButton.swift is 58 lines (pure UI, under 400-line threshold), WorkspaceShellView additions (~52 lines to 332-line file) add cohe...
Cmux Swiftpm Lockfiles ✅ Passed PR modifies only Swift source files and localization resources; no SwiftPM packages, Xcode projects, .gitignore, workflows, or dependency changes—check is not applicable.
Cmux Swift Logging ✅ Passed No logging violations detected. PR adds WorkspaceBackButton UI, navigation helpers, and unread count logic with no print/NSLog/debugPrint calls or improper Logger usage.
Cmux User-Facing Error Privacy ✅ Passed No user-facing error messages, alerts, or sensitive information in PR. New strings are safe (e.g., "Back", "%d unread workspaces" with en/ja localization). All code additions are implementation det...
Cmux Full Internationalization ✅ Passed All user-facing Swift text uses L10n.string() API with localization keys; new Localizable.xcstrings entries for mobile.workspace.back and mobile.workspace.backUnreadCountFormat include complete en+...
Cmux Swiftui State Layout ✅ Passed PR adheres to SwiftUI state layout rules: uses @Observable store with @State for local navigation, proper closure-based store snapshots in lists, and AppKit-bridge UIViewControllerRepresentable for...
Cmux Swift Auxiliary Window Close Shortcuts ✅ Passed PR adds SwiftUI View and UIViewControllerRepresentable (gesture handler), not NSWindow/NSPanel/NSWindowController/Window/WindowGroup; no standalone cmux-owned windows introduced or changed.
Cmux Source Artifacts ✅ Passed All four changed files are intentional source files (Swift code and localization catalogs) in standard package/resource directories with no build artifacts, caches, or generated files.
Description check ✅ Passed The pull request description follows the required template with all major sections completed: Summary (What/Why), Testing/Verification, and a comprehensive Checklist.
✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch feat-ios-back-unread-badge

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.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

The badge insert landed between the @ViewBuilder attribute and
workspaceDestination, attaching it to the Int-returning unreadWorkspaceCount
(error: 'Int' does not conform to 'View'). Move @ViewBuilder back onto
workspaceDestination.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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
`@Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/WorkspaceBackUnreadBadge.swift`:
- Around line 32-39: The accessibilityLabel property uses a single localization
key that does not handle singular versus plural forms, causing grammatically
incorrect output when count equals 1 (e.g., "1 unread workspaces"). Modify the
accessibilityLabel computed property to conditionally select localization keys
based on the count value: when count == 1 use a singular key like
"mobile.workspace.backUnreadCountFormat.one" with default value "1 unread
workspace", and otherwise use the plural key
"mobile.workspace.backUnreadCountFormat.other" with the existing default value
"%d unread workspaces". Then add both the .one and .other entries to the
Localizable.xcstrings file in ios/cmux/Resources/ with appropriate singular and
plural strings.
🪄 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: fd686226-766a-455f-97d3-e674c7fb5158

📥 Commits

Reviewing files that changed from the base of the PR and between fceee9a and 000b7d0.

📒 Files selected for processing (3)
  • Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/WorkspaceBackUnreadBadge.swift
  • Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/WorkspaceShellView.swift
  • ios/cmux/Resources/Localizable.xcstrings

@greptile-apps

greptile-apps Bot commented Jun 17, 2026 •

Copy link
Copy Markdown
Contributor

Greptile Summary

Adds an unread-workspace count to the custom back button on the iPhone compact navigation stack. When other workspaces have unread activity the back button renders as ‹ N (capped at 99+); without unread it shows only the chevron. Opening a workspace also acts as a read receipt — clearing unread on the Mac when the capability is present.

  • WorkspaceBackButton: new SwiftUI view composing a chevron and a small circle badge; uses caption2.semibold monospaced digits, adapts to light/dark via .primary background; accessible label sourced from localized string catalog.
  • InteractiveSwipeBackEnabler: UIViewControllerRepresentable that hijacks interactivePopGestureRecognizer.delegate to re-enable swipe-back after navigationBarBackButtonHidden(true). The delegate is set on didMove(toParent:) but not restored on removal, leaving the gesture without a guard on the root list.
  • openWorkspace read-receipt: guarded by supportsWorkspaceReadStateActions and workspace?.hasUnread == true, keeping older Macs and already-read workspaces unaffected.

Confidence Score: 4/5

Safe to merge once the gesture-delegate teardown is addressed; all other changes are additive UI and a narrow Mac capability-gated network call.

The InteractiveSwipeBackEnabler replaces UIKit's internal delegate on the pop gesture recognizer but never restores it. After the user visits a workspace detail and returns to the root list, the gesture recognizer's delegate is nil. A nil delegate means gestureRecognizerShouldBegin defaults to true, so a swipe attempt on the root list will start the pop gesture with nothing to pop — producing a visual glitch or undefined navigation state.

WorkspaceShellView.swift — specifically the InteractiveSwipeBackEnabler.GestureHostController tear-down path.

Important Files Changed

Filename Overview
Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/WorkspaceShellView.swift Adds custom back button with unread count to the compact navigation stack. The InteractiveSwipeBackEnabler does not save/restore the original gesture delegate, so after a pop the root list is left with a nil delegate on the gesture recognizer.
Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/WorkspaceBackButton.swift New view composing a chevron + unread count circle. Pure SwiftUI with correct accessibility label, capped display, and no layout issues.
Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/MobileShellComposite.swift Adds a read-receipt setWorkspaceUnread call inside openWorkspace, guarded by the Mac capability flag and existing unread state. Clean async pattern.
ios/cmux/Resources/Localizable.xcstrings Adds mobile.workspace.back and mobile.workspace.backUnreadCountFormat with both en and ja translations, consistent with the catalog's existing locale set.
.github/swift-file-length-budget.tsv Budget line for MobileShellComposite.swift incremented from 5558 to 5566, reflecting the 8 new lines added.

Sequence Diagram

%%{init: {'theme': 'neutral'}}%%
sequenceDiagram
    participant User
    participant WorkspaceShellView
    participant WorkspaceBackButton
    participant InteractiveSwipeBackEnabler
    participant UINavigationController
    participant MobileShellComposite

    User->>WorkspaceShellView: Tap workspace row
    WorkspaceShellView->>UINavigationController: Push destination (compactNavigationPath)
    UINavigationController->>InteractiveSwipeBackEnabler: didMove(toParent: hostingVC)
    InteractiveSwipeBackEnabler->>UINavigationController: "interactivePopGestureRecognizer.delegate = self"
    WorkspaceShellView->>WorkspaceBackButton: "unreadCount = unreadWorkspaceCount(excluding: workspaceID)"
    MobileShellComposite->>MobileShellComposite: openWorkspace → setWorkspaceUnread(false) [if hasUnread + capability]
    MobileShellComposite-->>WorkspaceShellView: store.workspaces updated (unread cleared)
    WorkspaceShellView->>WorkspaceBackButton: unreadCount decrements

    alt User taps back button
        User->>WorkspaceBackButton: Tap
        WorkspaceBackButton->>WorkspaceShellView: popCompactStack()
        WorkspaceShellView->>UINavigationController: compactNavigationPath.removeLast()
    else User swipes from edge
        User->>UINavigationController: Edge pan gesture
        UINavigationController->>InteractiveSwipeBackEnabler: gestureRecognizerShouldBegin?
        InteractiveSwipeBackEnabler-->>UINavigationController: "viewControllers.count > 1 → true"
        UINavigationController->>WorkspaceShellView: Pop animation
    end

    UINavigationController->>InteractiveSwipeBackEnabler: didMove(toParent: nil) → deallocates
    Note over UINavigationController: delegate becomes nil after dealloc
Loading
%%{init: {'theme': 'base', 'themeVariables': {"darkMode": true, "background": "#0d1117", "primaryColor": "#21262d", "primaryTextColor": "#e6edf3", "primaryBorderColor": "#8b949e", "lineColor": "#8b949e", "textColor": "#e6edf3", "edgeLabelBackground": "#161b22", "actorBkg": "#21262d", "actorBorder": "#8b949e", "actorTextColor": "#e6edf3", "actorLineColor": "#8b949e", "signalColor": "#8b949e", "signalTextColor": "#e6edf3", "noteBkgColor": "#373320", "noteBorderColor": "#d4a72c", "noteTextColor": "#f0e6c0", "labelBoxBkgColor": "#21262d", "labelBoxBorderColor": "#8b949e", "labelTextColor": "#e6edf3", "loopTextColor": "#e6edf3", "activationBkgColor": "#30363d", "activationBorderColor": "#8b949e"}}}%%
sequenceDiagram
    participant User
    participant WorkspaceShellView
    participant WorkspaceBackButton
    participant InteractiveSwipeBackEnabler
    participant UINavigationController
    participant MobileShellComposite

    User->>WorkspaceShellView: Tap workspace row
    WorkspaceShellView->>UINavigationController: Push destination (compactNavigationPath)
    UINavigationController->>InteractiveSwipeBackEnabler: didMove(toParent: hostingVC)
    InteractiveSwipeBackEnabler->>UINavigationController: "interactivePopGestureRecognizer.delegate = self"
    WorkspaceShellView->>WorkspaceBackButton: "unreadCount = unreadWorkspaceCount(excluding: workspaceID)"
    MobileShellComposite->>MobileShellComposite: openWorkspace → setWorkspaceUnread(false) [if hasUnread + capability]
    MobileShellComposite-->>WorkspaceShellView: store.workspaces updated (unread cleared)
    WorkspaceShellView->>WorkspaceBackButton: unreadCount decrements

    alt User taps back button
        User->>WorkspaceBackButton: Tap
        WorkspaceBackButton->>WorkspaceShellView: popCompactStack()
        WorkspaceShellView->>UINavigationController: compactNavigationPath.removeLast()
    else User swipes from edge
        User->>UINavigationController: Edge pan gesture
        UINavigationController->>InteractiveSwipeBackEnabler: gestureRecognizerShouldBegin?
        InteractiveSwipeBackEnabler-->>UINavigationController: "viewControllers.count > 1 → true"
        UINavigationController->>WorkspaceShellView: Pop animation
    end

    UINavigationController->>InteractiveSwipeBackEnabler: didMove(toParent: nil) → deallocates
    Note over UINavigationController: delegate becomes nil after dealloc
Loading

Reviews (4): Last reviewed commit: "ci: bump MobileShellComposite swift-leng..." | Re-trigger Greptile

Comment on lines +6396 to 6412
"mobile.workspace.backUnreadCountFormat": {
"extractionState": "manual",
"localizations": {
"en": {
"stringUnit": {
"state": "translated",
"value": "%d unread workspaces"
}
},
"ja": {
"stringUnit": {
"state": "translated",
"value": "未読のワークスペース %d 件"
}
}
}
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 The format string "%d unread workspaces" has no plural variation, so when count is exactly 1 VoiceOver will announce "1 unread workspaces" — grammatically incorrect in English and likely in Japanese too. Xcode string catalogs support plural rules via a variations / plural block that .xcstrings handles natively.

Suggested change
"mobile.workspace.backUnreadCountFormat": {
"extractionState": "manual",
"localizations": {
"en": {
"stringUnit": {
"state": "translated",
"value": "%d unread workspaces"
}
},
"ja": {
"stringUnit": {
"state": "translated",
"value": "未読のワークスペース %d 件"
}
}
}
}
"mobile.workspace.backUnreadCountFormat": {
"extractionState": "manual",
"localizations": {
"en": {
"variations": {
"plural": {
"one": {
"stringUnit": {
"state": "translated",
"value": "%d unread workspace"
}
},
"other": {
"stringUnit": {
"state": "translated",
"value": "%d unread workspaces"
}
}
}
}
},
"ja": {
"stringUnit": {
"state": "translated",
"value": "未読のワークスペース %d 件"
}
}
}
}

Rule Used: Flag production user-facing text that is not fully... (source)

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!

lawrencecchen and others added 2 commits June 17, 2026 15:42
…al color)

Per dogfood: the count was a separate pill in its own button and tinted blue.
Now it's a single custom back control.

- WorkspaceBackButton (renamed from WorkspaceBackUnreadBadge): chevron + count in
  one button ("‹ 3"), primary label color (white on the dark bar / black on
  light), no accent-blue pill. Just the chevron when nothing is unread; the
  button widens to fit the count.
- Replace the system back button with it (navigationBarBackButtonHidden) in the
  compact navigationDestination; pop via compactNavigationPath.
- InteractiveSwipeBackEnabler restores the edge swipe-back that hiding the system
  button disables, gated so it only begins when there's a screen to pop.
- Localized "Back" label (en + ja).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Per dogfood: bring back a circle background for the count (but monochrome, not
blue) and shrink the number. The chevron stays primary; the count is caption2 on
a `.primary`-filled circle with a `.systemBackground` numeral, so it reads as a
white circle / dark number on the dark bar and inverts on a light bar.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
ios/cmux/Resources/Localizable.xcstrings (1)

6396-6411: ⚠️ Potential issue | 🟡 Minor | ⚡ Quick win

Split unread-count localization into singular/plural keys.

mobile.workspace.backUnreadCountFormat is a single format string, so count 1 becomes “1 unread workspaces” in English. Add .one and .other keys and select by count in WorkspaceBackButton (Line 42-47).

💡 Suggested catalog shape
- "mobile.workspace.backUnreadCountFormat": { ... "%d unread workspaces" ... }
+ "mobile.workspace.backUnreadCountFormat.one": {
+   "extractionState": "manual",
+   "localizations": {
+     "en": { "stringUnit": { "state": "translated", "value": "1 unread workspace" } },
+     "ja": { "stringUnit": { "state": "translated", "value": "未読のワークスペース 1 件" } }
+   }
+ },
+ "mobile.workspace.backUnreadCountFormat.other": {
+   "extractionState": "manual",
+   "localizations": {
+     "en": { "stringUnit": { "state": "translated", "value": "%d unread workspaces" } },
+     "ja": { "stringUnit": { "state": "translated", "value": "未読のワークスペース %d 件" } }
+   }
+ }

Based on learnings: prefer .one/.other keys for unread-count pluralization in Swift localization; this avoids singular/plural mismatch in user-facing strings. As per coding guidelines: full internationalization must be enforced for production user-facing text.

🤖 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 `@ios/cmux/Resources/Localizable.xcstrings` around lines 6396 - 6411, The
localization key mobile.workspace.backUnreadCountFormat uses a single format
string that produces grammatically incorrect output for count 1 (showing "1
unread workspaces" instead of "1 unread workspace"). Split this key into
plural-form variants by creating mobile.workspace.backUnreadCountFormat.one with
a singular value and mobile.workspace.backUnreadCountFormat.other with the
plural value, then update the WorkspaceBackButton component (around Line 42-47)
to select the appropriate key based on whether the count equals 1 or is greater
than 1.

Sources: Coding guidelines, Learnings

🤖 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.

Outside diff comments:
In `@ios/cmux/Resources/Localizable.xcstrings`:
- Around line 6396-6411: The localization key
mobile.workspace.backUnreadCountFormat uses a single format string that produces
grammatically incorrect output for count 1 (showing "1 unread workspaces"
instead of "1 unread workspace"). Split this key into plural-form variants by
creating mobile.workspace.backUnreadCountFormat.one with a singular value and
mobile.workspace.backUnreadCountFormat.other with the plural value, then update
the WorkspaceBackButton component (around Line 42-47) to select the appropriate
key based on whether the count equals 1 or is greater than 1.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Pro

Run ID: 02ea2116-4982-4a07-8e03-3216e83bc0ec

📥 Commits

Reviewing files that changed from the base of the PR and between 03b0a9a and 0840ce0.

📒 Files selected for processing (3)
  • Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/WorkspaceBackButton.swift
  • Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/WorkspaceShellView.swift
  • ios/cmux/Resources/Localizable.xcstrings

@cursor cursor Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Cursor Bugbot has reviewed your changes and found 1 potential issue.

Fix All in Cursor

❌ Bugbot Autofix is OFF. To automatically fix reported issues with cloud agents, enable autofix in the Cursor dashboard.

Reviewed by Cursor Bugbot for commit a2ad943. Configure here.

),
unreadCount
)
return "\(back), \(unread)"

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Wrong singular unread label

Low Severity

The new back-button accessibility label always formats the unread suffix with %d unread workspaces, so VoiceOver announces grammatically wrong English when exactly one other workspace is unread (for example, “Back, 1 unread workspaces”).

Additional Locations (1)
Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit a2ad943. Configure here.

Comment on lines +327 to +329
func gestureRecognizerShouldBegin(_ gestureRecognizer: UIGestureRecognizer) -> Bool {
(navigationController?.viewControllers.count ?? 0) > 1
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 gestureRecognizerShouldBegin only checks the view-controller count, not whether a push/pop transition is already in progress. UIKit's own delegate for interactivePopGestureRecognizer also gates on the absence of an active transition coordinator; without that check the gesture can begin while a programmatic push is animating (e.g. a deep-link push lands and the user immediately swipes), leaving the navigation controller in an inconsistent state and potentially causing a crash or a corrupted stack.

Suggested change
func gestureRecognizerShouldBegin(_ gestureRecognizer: UIGestureRecognizer) -> Bool {
(navigationController?.viewControllers.count ?? 0) > 1
}
func gestureRecognizerShouldBegin(_ gestureRecognizer: UIGestureRecognizer) -> Bool {
guard (navigationController?.viewControllers.count ?? 0) > 1 else { return false }
// Prevent initiating a pop while a push/pop transition is already
// animating; without this guard the nav stack can end up in an
// inconsistent state if a deep-link push lands and the user swipes
// at the same moment.
return navigationController?.transitionCoordinator == nil
}

- WorkspaceBackButton count font: bold -> semibold (less heavy).
- openWorkspace now sends a read receipt: when the Mac supports read-state
  actions and the workspace is unread, mark it read on open (like opening a
  thread). This drops it from the unread list and the back-button count.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/WorkspaceBackButton.swift (1)

46-56: ⚠️ Potential issue | 🟡 Minor | ⚡ Quick win

Use pluralized localization keys for the unread-count accessibility label.

At Line 50, mobile.workspace.backUnreadCountFormat is a single format string, so unreadCount == 1 will produce incorrect singular copy in English (and weak plural handling for other locales). Please switch to plural-aware keys (e.g., .one/.other) and select by count.

Based on learnings, pluralized unread-count strings in Swift should use ICU-style .one and .other keys (for example, statusMenu.unreadCount.one / .other) to ensure correct localization behavior.

🤖 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
`@Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/WorkspaceBackButton.swift`
around lines 46 - 56, The accessibilityLabel computed property uses a single
format string `mobile.workspace.backUnreadCountFormat` that cannot properly
handle singular versus plural forms. Replace the current format string lookup
with plural-aware localization keys by checking the unreadCount value and
selecting between a `.one` key for singular (when unreadCount equals 1) and an
`.other` key for plural (when unreadCount is greater than 1), similar to
ICU-style pluralization patterns like `mobile.workspace.backUnreadCount.one` and
`mobile.workspace.backUnreadCount.other`. Update the String formatting logic to
use the appropriate plural key based on the unreadCount comparison before
formatting.

Sources: Coding guidelines, Learnings

🤖 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.

Outside diff comments:
In
`@Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/WorkspaceBackButton.swift`:
- Around line 46-56: The accessibilityLabel computed property uses a single
format string `mobile.workspace.backUnreadCountFormat` that cannot properly
handle singular versus plural forms. Replace the current format string lookup
with plural-aware localization keys by checking the unreadCount value and
selecting between a `.one` key for singular (when unreadCount equals 1) and an
`.other` key for plural (when unreadCount is greater than 1), similar to
ICU-style pluralization patterns like `mobile.workspace.backUnreadCount.one` and
`mobile.workspace.backUnreadCount.other`. Update the String formatting logic to
use the appropriate plural key based on the unreadCount comparison before
formatting.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Pro

Run ID: ece47372-9db6-4e5c-8d6a-e6de76da9f97

📥 Commits

Reviewing files that changed from the base of the PR and between 0840ce0 and a2ad943.

📒 Files selected for processing (1)
  • Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/WorkspaceBackButton.swift

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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)
Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/WorkspaceBackButton.swift (1)

46-58: ⚠️ Potential issue | 🟡 Minor

Pluralization format string will produce grammatically incorrect singular case.

The format string "%d unread workspaces" will read "1 unread workspaces" instead of "1 unread workspace" for the singular case. The L10n.string() wrapper and String(format:) approach are acceptable per codebase convention, but the xcstrings entry for mobile.workspace.backUnreadCountFormat should use ICU-style plural variants (.one and .other keys) to handle singular/plural forms correctly across all locales—or update the format string to handle the singular case explicitly.

🤖 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
`@Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/WorkspaceBackButton.swift`
around lines 46 - 58, The accessibilityLabel property uses a format string that
doesn't handle singular/plural forms correctly for the unread count. The L10n
localization key mobile.workspace.backUnreadCountFormat needs to be updated in
the xcstrings file to use ICU-style plural variants with .one and .other keys
instead of a single format string, so that singular cases (1 unread workspace)
and plural cases (2+ unread workspaces) are grammatically correct. This allows
the String(format:) call to properly select the correct plural form based on the
unreadCount value.

Sources: Coding guidelines, Learnings

🤖 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
`@Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/MobileShellComposite.swift`:
- Around line 2624-2626: Remove the `workspace?.hasUnread == true` condition
from the if statement in the setWorkspaceUnread call. The cached hasUnread state
should not gate the mark_read operation as it can be stale and cause the
read-receipt write to be silently skipped. Keep only the
`supportsWorkspaceReadStateActions` check as the precondition for calling
setWorkspaceUnread with id and false, ensuring the mark_read operation always
executes when the capability exists.

---

Outside diff comments:
In
`@Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/WorkspaceBackButton.swift`:
- Around line 46-58: The accessibilityLabel property uses a format string that
doesn't handle singular/plural forms correctly for the unread count. The L10n
localization key mobile.workspace.backUnreadCountFormat needs to be updated in
the xcstrings file to use ICU-style plural variants with .one and .other keys
instead of a single format string, so that singular cases (1 unread workspace)
and plural cases (2+ unread workspaces) are grammatically correct. This allows
the String(format:) call to properly select the correct plural form based on the
unreadCount value.
🪄 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: 24e8c0fe-285b-46af-bf66-24f5a7dcacfa

📥 Commits

Reviewing files that changed from the base of the PR and between a2ad943 and 8ca039d.

📒 Files selected for processing (2)
  • Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/MobileShellComposite.swift
  • Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/WorkspaceBackButton.swift

Comment on lines +2624 to +2626
if supportsWorkspaceReadStateActions, workspace?.hasUnread == true {
await setWorkspaceUnread(id: id, false)
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Do not gate mark_read on cached hasUnread state.

On Line 2624, using workspace?.hasUnread == true as a precondition can silently skip the authoritative read-receipt write when local state is stale. In this flow, opening should issue mark_read whenever capability exists.

Suggested fix
-        if supportsWorkspaceReadStateActions, workspace?.hasUnread == true {
+        if supportsWorkspaceReadStateActions {
             await setWorkspaceUnread(id: id, false)
         }

As per coding guidelines, “fail when the diff swaps a fresh authoritative read for a cached or opportunistic value in a persistence path without handling cold/stale caches.”

📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
if supportsWorkspaceReadStateActions, workspace?.hasUnread == true {
await setWorkspaceUnread(id: id, false)
}
if supportsWorkspaceReadStateActions {
await setWorkspaceUnread(id: id, false)
}
🤖 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
`@Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/MobileShellComposite.swift`
around lines 2624 - 2626, Remove the `workspace?.hasUnread == true` condition
from the if statement in the setWorkspaceUnread call. The cached hasUnread state
should not gate the mark_read operation as it can be stale and cause the
read-receipt write to be silently skipped. Keep only the
`supportsWorkspaceReadStateActions` check as the precondition for calling
setWorkspaceUnread with id and false, ensuring the mark_read operation always
executes when the capability exists.

Source: Coding guidelines

openWorkspace's read-receipt block pushed the file 8 lines over its budget
(5566 vs 5558). The file is already large; accept the small known debt.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Comment on lines +321 to +330
final class GestureHostController: UIViewController, UIGestureRecognizerDelegate {
override func didMove(toParent parent: UIViewController?) {
super.didMove(toParent: parent)
navigationController?.interactivePopGestureRecognizer?.delegate = self
}

func gestureRecognizerShouldBegin(_ gestureRecognizer: UIGestureRecognizer) -> Bool {
(navigationController?.viewControllers.count ?? 0) > 1
}
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Gesture delegate not restored on pop, breaking root-list swipe guard

UINavigationController is both the action target and the initial delegate of interactivePopGestureRecognizer. When GestureHostController moves to a parent it replaces that delegate with itself, but didMove(toParent: nil) (called during the pop) just re-sets the delegate to self once more before the VC deallocates. Since delegate is a weak reference, it becomes nil once the VC is released. After returning to the root list the gesture recognizer has no delegate, so gestureRecognizerShouldBegin defaults to true and the gesture can begin on a screen with nothing to pop — leading to a visual glitch or an undefined-state pop attempt.

Suggested change
final class GestureHostController: UIViewController, UIGestureRecognizerDelegate {
override func didMove(toParent parent: UIViewController?) {
super.didMove(toParent: parent)
navigationController?.interactivePopGestureRecognizer?.delegate = self
}
func gestureRecognizerShouldBegin(_ gestureRecognizer: UIGestureRecognizer) -> Bool {
(navigationController?.viewControllers.count ?? 0) > 1
}
}
final class GestureHostController: UIViewController, UIGestureRecognizerDelegate {
private weak var previousDelegate: UIGestureRecognizerDelegate?
override func didMove(toParent parent: UIViewController?) {
super.didMove(toParent: parent)
if parent != nil {
let recognizer = navigationController?.interactivePopGestureRecognizer
previousDelegate = recognizer?.delegate
recognizer?.delegate = self
} else {
navigationController?.interactivePopGestureRecognizer?.delegate = previousDelegate
previousDelegate = nil
}
}
func gestureRecognizerShouldBegin(_ gestureRecognizer: UIGestureRecognizer) -> Bool {
(navigationController?.viewControllers.count ?? 0) > 1
}
}

@lawrencecchen
lawrencecchen merged commit 90f77dd into main Jun 18, 2026
38 of 43 checks passed
hhsw2015 pushed a commit to hhsw2015/cmux that referenced this pull request Jun 18, 2026
…r + ios chat top-level)

- v1 debug/test control dispatch cut over to ControlCommandCoordinator (manaflow-ai#6343)
- iOS workspace read/unread from terminal menu, chat button promoted (manaflow-ai#6362)
- iOS unread-workspace count badge on back button (manaflow-ai#6350)

Adapter: stub v2DebugSidebarVisible / v2DebugIsTerminalFocused /
v2DebugSimulateTerminalFileDrop call sites with .err(unimplemented).
Bodies moved into ControlCommandCoordinator upstream; v2 ctl path
not used by cmux_term workflow.
lawrencecchen added a commit that referenced this pull request Jun 18, 2026
Merge brings in the back-button unread badge (#6350) and mark read/unread +
chat-top-level toolbar (#6362). Resolved the swift-length-budget conflict to a
single WorkspaceDetailView entry.

Also in this branch update:
- Add "Rename Workspace" to the terminal-icon picker menu (gated on
  supportsWorkspaceActions), presenting the shared WorkspaceRenameSheet and
  forwarding the new name via store.renameWorkspace. Sheet attached across the
  terminal / chat / browser panes so it works from any mode.
- Increase the glass title pill's vertical padding (5 -> 9) so its glass
  background height matches the back button and other bar-button glass items.
  Padding-based (not a fixed height) so the multi-line chat header pill grows.
- Bump WorkspaceDetailView length budget to 839.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

This branch was successfully deployed

1 active deployment
Preview – cmux — c2440137 Deployed Jun 17, 2026 by vercel[bot]
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant