Skip to content

Add workspace and tab notification mute menus - #6658

Closed
lawrencecchen wants to merge 7 commits into
mainfrom
task-right-click-mute-workspace-tab
Closed

lawrencecchen wants to merge 7 commits into
mainfrom
task-right-click-mute-workspace-tab

Conversation

@lawrencecchen

@lawrencecchen lawrencecchen commented Jun 23, 2026 •

Copy link
Copy Markdown
Contributor

Summary

  • Add workspace notification mute actions to the sidebar context menu, including multi-workspace targets.
  • Add terminal tab notification mute actions to the terminal context menu.
  • Add a permanent Until Unmuted option and configurable timed mute choices via notifications.muteDurations in cmux.json.
  • Suppress muted notification side effects while still recording unread notifications.

Testing

  • jq empty Resources/Localizable.xcstrings
  • jq empty web/data/cmux.schema.json
  • git diff --check
  • python3 scripts/swift_file_length_budget.py --budget .github/swift-file-length-budget.tsv
  • ./scripts/lint-pbxproj-test-wiring.sh
  • CMUX_ALLOW_LOCAL_XCODEBUILD=1 xcodebuild -project cmux.xcodeproj -scheme cmux -destination 'platform=macOS' -derivedDataPath /tmp/cmux-mutetb-tests build-for-testing
  • ./scripts/reload-cloud.sh --tag mutetb
  • xcodebuild test is left to CI because local cmux tests are guarded against the default socket.

Issues

  • Task: right click mute workspace/mute tab feature with duration submenu, permanent mute, and configurable durations

Note

Medium Risk
Changes the core notification apply path in TerminalNotificationStore, but behavior is gated on explicit mute state and covered by new unit tests.

Overview
Adds notification mute from the sidebar workspace context menu (including multi-select) and the terminal tab context menu, each with a duration submenu plus Unmute when a mute is active.

Mute state is stored on TerminalNotificationStore per workspace and per terminal surface. While muted, notifications can still be recorded as unread, but side effects are skipped (sounds, hooks/commands, native banners, pane flash, workspace reorder, and related external delivery). Workspace-level and surface-level mutes are evaluated separately so muting one tab does not silence siblings.

Timed choices come from notifications.muteDurations in cmux.json (local overrides global), always prefixed with Until Unmuted. Notification config decoding moves into CmuxNotificationConfigDefinitions.swift; defaults and schema/docs/localizations are updated accordingly.

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

@vercel

vercel Bot commented Jun 23, 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 23, 2026 8:49am
cmux-staging Building Building Preview, Comment Jun 23, 2026 8:49am

@coderabbitai

coderabbitai Bot commented Jun 23, 2026

Copy link
Copy Markdown

Note

Currently processing new changes in this PR. This may take a few minutes, please wait...

⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Pro

Run ID: 3cc7f1ef-d51f-437c-a2cd-e6d4b6000b3f

📥 Commits

Reviewing files that changed from the base of the PR and between f4d5b22 and c5f48d2.

📒 Files selected for processing (6)
  • Resources/Localizable.xcstrings
  • Sources/ContentView.swift
  • Sources/GhosttyNSView+MoveTabToNewWorkspace.swift
  • Sources/GhosttyTerminalView.swift
  • Sources/TerminalNotificationStore.swift
  • cmuxTests/TerminalNotificationClearAllTests.swift
 ____________________________________________________________
< To iterate is human, to recurse divine. - L. Peter Deutsch >
 ------------------------------------------------------------
  \
   \   (\__/)
       (•ㅅ•)
       /   づ
✨ 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 task-right-click-mute-workspace-tab

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.

Comment thread Sources/ContentView.swift
@coderabbitai

coderabbitai Bot commented Jun 23, 2026 •

Copy link
Copy Markdown

Review Change Stack

📝 Walkthrough

Walkthrough

Adds timed notification muting at workspace and terminal-surface granularity. Introduces CmuxNotificationMuteDurationDefinition, NotificationMuteMenuOption, and mute-expiration dictionaries in TerminalNotificationStore. Delivery side effects (sounds, banners, reordering) are suppressed when muted. Mute/unmute actions are surfaced in both the sidebar context menu and terminal tab context menu, backed by JSON config, JSON schema, localization, and tests.

Changes

Notification Muting Feature

Layer / File(s) Summary
Mute config definitions, menu option model, and display settings
Sources/CmuxNotificationConfigDefinitions.swift, Sources/NotificationMuteMenuOption.swift, Sources/NotificationDisplaySettings.swift, Sources/CmuxConfig.swift, Sources/CmuxSettingsJSONPathSupport.swift
CmuxNotificationMuteDurationDefinition and hook definitions are moved into a new file with strict Codable validation; NotificationMuteMenuOption struct defines Kind enum, default/configured option builders, and expiration computation; NotificationDisplaySettings enums are extracted into their own file; notifications.muteDurations is registered as a supported JSON path.
Config resolution, settings template, and schema
Sources/CmuxConfig.swift, Sources/KeyboardShortcutSettingsFileStore+Template.swift, web/data/cmux.schema.json, docs/notifications.md
CmuxConfigStore gains a notificationMuteMenuOptions published property resolved during loadAll() by preferring local over global muteDurations; the default template gains a preset muteDurations array; the JSON schema enforces shape and constraints for notifications.muteDurations; docs describe mute behavior and config.
TerminalNotificationStore mute-expiration state and APIs
Sources/TerminalNotificationStore+Mute.swift, Sources/TerminalNotificationStore.swift
Adds notificationMuteExpirationsByWorkspaceId and notificationMuteExpirationsBySurfaceId published dictionaries; workspace-scoped and surface-scoped mute/unmute/query APIs; combined tab+surface active expiration resolver; private expiry-check helper; DEBUG clear method.
Notification delivery gating
Sources/TerminalNotificationStore.swift
applyNotification computes shouldMuteNotificationSideEffects from activeNotificationMuteExpiration and ORs it into shouldSuppressExternalDelivery; early-return guards with DEBUG logs prevent all side effects when muted; recordNotification signature and internal read-indicator/reorder logic updated to respect the mute flag.
Terminal tab surface mute context menu
Sources/GhosttyNSView+MoveTabToNewWorkspace.swift, Sources/GhosttyTerminalView.swift
NotificationMuteMenuOptionBox NSObject wrapper carries mute options via representedObject; appendCurrentSurfaceNotificationMuteMenuItems builds a duration submenu and conditional unmute item; @objc handlers validate inputs and call the store; wired into GhosttyTerminalView context menu construction.
Workspace sidebar mute context menu
Sources/TabItemView+NotificationMuteContextMenu.swift, Sources/ContentView.swift
workspaceNotificationMuteContextMenuItems SwiftUI view builder renders mute/unmute Menu and Button from notificationMuteMenuOptions; contextMenuWorkspaceMuteActive is computed in workspaceRow and threaded into TabItemView inputs and Equatable; mute items are inserted into workspaceContextMenu.
Localization, tests, and Xcode project wiring
Resources/Localizable.xcstrings, cmuxTests/CmuxNotificationMuteConfigTests.swift, cmuxTests/TerminalNotificationMuteTests.swift, cmux.xcodeproj/project.pbxproj
En/ja localized keys for all mute/unmute actions and duration labels; tests cover muteDurations decoding validation, workspace mute suppression, until-unmuted lifecycle, and surface-scoped muting isolation; all new source files are wired into the Xcode project.

Sequence Diagram(s)

sequenceDiagram
  participant User
  participant ContextMenu
  participant TerminalNotificationStore
  participant NotificationDelivery

  rect rgba(100, 149, 237, 0.5)
    Note over User,ContextMenu: Mute action
    User->>ContextMenu: select "Mute Notifications" with duration
    ContextMenu->>TerminalNotificationStore: muteNotifications(forTabIds/surfaceId, until: expiration)
    TerminalNotificationStore-->>ContextMenu: updated notificationMuteExpirations
  end

  rect rgba(255, 165, 0, 0.5)
    Note over TerminalNotificationStore,NotificationDelivery: Notification arrives while muted
    TerminalNotificationStore->>TerminalNotificationStore: applyNotification → activeNotificationMuteExpiration != nil
    TerminalNotificationStore->>TerminalNotificationStore: shouldMuteNotificationSideEffects = true
    TerminalNotificationStore->>TerminalNotificationStore: recordNotification (unread recorded)
    TerminalNotificationStore-->>NotificationDelivery: early-return, no sound/banner/reorder
  end

  rect rgba(60, 179, 113, 0.5)
    Note over User,ContextMenu: Unmute action
    User->>ContextMenu: select "Unmute Notifications"
    ContextMenu->>TerminalNotificationStore: unmuteNotifications(forTabIds/forSurfaceId)
    TerminalNotificationStore-->>ContextMenu: mute expiration cleared
  end
Loading

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~60 minutes

Possibly related PRs

  • manaflow-ai/cmux#6460: Modifies TabItemView in ContentView.swift at the same stored-property and Equatable level that this PR also extends to add contextMenuWorkspaceMuteActive and notificationMuteMenuOptions.

Poem

🐇 Hoppity hop, the notifications rest,
Muted at last — the bunny knows best!
With 15 minutes or hours galore,
Silence the pings, let me focus some more.
Until unmuted, the carrots are clear —
No badge, no bang, just calm in the ear! 🥕


Important

Pre-merge checks failed

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

❌ Failed checks (6 errors, 1 warning)

Check name Status Explanation Resolution
Cmux Swift Actor Isolation ❌ Error NotificationMuteMenuOption is a pure Identifiable value struct used in @Published of @MainActor class but lacks Sendable conformance, violating Swift 6 actor isolation rules like similar structs in... Add Sendable conformance: struct NotificationMuteMenuOption: Hashable, Identifiable, Sendable { ... }
Cmux Algorithmic Complexity ❌ Error Sources/TabItemView+NotificationMuteContextMenu.swift lines 8-13: remoteContextMenuWorkspaces() performs O(m×n) rescan (m selected workspaceIds × n total tabs via repeated first(where:)), violating... Replace per-id full scans with dictionary lookup: let tabsById = Dictionary(uniqueKeysWithValues: tabManager.tabs.map { ($0.id, $0) }); return remoteContextMenuWorkspaceIds.compactMap { tabsById[$0] }
Cmux Swift Concurrency ❌ Error PR introduces new @Published state in CmuxConfigStore for notificationMuteMenuOptions, violating swift-concurrency-modernization.md rule against new Combine-based app state when @Observable is avai... Replace @Published notificationMuteMenuOptions with @Observable pattern and @State/value snapshots per cmux coding guidelines for new state.
Cmux Full Internationalization ❌ Error Schema default labels (e.g., "15 Minutes", "1 Hour") in cmux.schema.json are user-facing strings that appear in notification mute menus when users copy them to cmux.json, but they lack localization... Either update schema defaults to be localization keys with mappings documented, add every supported locale's label variants to schema defaults, or implement label-to-localization-key mapping in NotificationMuteMenuOption.options() to loc...
Cmux Swiftui State Layout ❌ Error PR adds new @Published property (notificationMuteMenuOptions) to CmuxConfigStore which uses legacy ObservableObject instead of modern @Observable pattern, violating swiftui-state-layout.md rules. Convert CmuxConfigStore from ObservableObject/@published to @Observable, or migrate notificationMuteMenuOptions to a snapshot-based pattern not requiring @Published.
Cmux No Test Or Debug Seam In Production Source ❌ Error Sources/TerminalNotificationStore+Mute.swift adds a #if DEBUG extension with clearNotificationMutesForTesting() method that violates no-test-debug-seam-in-production-source.md; the ForTesting-named... Remove the #if DEBUG extension; widen notificationMuteExpirationsByWorkspaceId and notificationMuteExpirationsBySurfaceId to internal, and access directly via @testable import in tests per PR #6452 pattern.
Docstring Coverage ⚠️ Warning Docstring coverage is 1.96% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (16 passed)
Check name Status Explanation
Title check ✅ Passed The title accurately summarizes the main changes—adding notification mute controls to workspace and tab context menus.
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 Blocking Runtime ✅ Passed No blocking or timing-based synchronization (semaphores, Task.sleep, main-queue sync, manual locks) introduced in production Swift code; mute logic uses synchronous Date comparisons and non-blockin...
Cmux Expensive Synchronous Load ✅ Passed PR adds notification muting UI/logic only; contains zero agent-history loads, transcript parsing, or expensive synchronous operations on main actor or interactive paths.
Cmux Cache Substitution Correctness ✅ Passed The PR introduces notificationMuteMenuOptions as a cached list of UI menu choices (15m, 1h, 4h, 8h, Until Unmuted) from config. This is a transient non-persisted UI hint used only to populate conte...
Cmux No Hacky Sleeps ✅ Passed The check applies only to TypeScript, JavaScript, shell, or non-Swift build/runtime scripts. This PR contains only Swift source/test files, localization strings, project config, documentation, and...
Cmux Swift @Concurrent ✅ Passed PR adds only synchronous code: Codable structs, synchronous UI helpers, @objc handlers, and synchronous state mutations. Zero new async functions or @concurrent annotations, so no violations of asy...
Cmux Swift File And Package Boundaries ✅ Passed All new files are under 400 lines with single, clear responsibilities (config parsing, UI models, persistence helpers, extensions). Changes to existing large files are minimal (<30 lines each) and...
Cmux Swiftpm Lockfiles ✅ Passed PR contains only local source file additions to pbxproj; no external SwiftPM dependency, Package.swift, .gitignore, or workflow changes that would trigger the lockfile rule.
Cmux Swift Logging ✅ Passed All Swift files comply with swift-logging.md: no unguarded print/debugPrint/dump/NSLog in app code; all DEBUG logs properly guarded with #if DEBUG; no MainActor-coupled Logger constants; no secrets...
Cmux User-Facing Error Privacy ✅ Passed All user-facing strings are localized, generic, and product-appropriate (e.g., "Mute Workspace Notifications", "15 Minutes", "Until Unmuted"). No vendor names, credentials, tokens, or sensitive dat...
Cmux Architecture Rethink ✅ Passed PR implements mute feature with clear ownership in TerminalNotificationStore, deterministic expiration checks (no timers/polling), and single source of truth for mute state; no timing repairs, dupl...
Cmux Swift Auxiliary Window Close Shortcuts ✅ Passed PR adds notification mute context menu items and configuration/utility structures, not standalone windows. No NSWindow, NSPanel, NSWindowController, SwiftUI Window, or WindowGroup definitions/insta...
Cmux Source Artifacts ✅ Passed All 18 changed files are intentional source artifacts: Swift source/test files, localization catalogs, JSON configs/schemas, Xcode project config, and documentation—no build artifacts, cache, temp...
Description check ✅ Passed The pull request description covers the main changes (notification mute actions, configuration, suppression logic) and includes comprehensive testing procedures, but lacks a demo video for the UI changes.
✨ 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 task-right-click-mute-workspace-tab

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.

@greptile-apps

greptile-apps Bot commented Jun 23, 2026 •

Copy link
Copy Markdown
Contributor

Greptile Summary

This PR adds workspace and terminal-tab notification muting from right-click context menus. Muted notifications are still recorded as unread in TerminalNotificationStore, but all side effects (native banners, sound, hooks, mobile forwarding, pane flash, workspace reorder) are suppressed. Timed and permanent mute options are configurable via notifications.muteDurations in cmux.json.

  • TerminalNotificationStore gains two @Published dictionaries tracking workspace and surface mute expirations; applyNotification and recordNotification each check these before applying side effects.
  • NotificationMuteMenuOption and CmuxNotificationMuteDurationDefinition are new value types for decoding and representing mute choices; notification config types are extracted into a dedicated CmuxNotificationConfigDefinitions.swift file.
  • Sidebar workspace context menu (TabItemView+NotificationMuteContextMenu) and terminal AppKit context menu (GhosttyNSView+MoveTabToNewWorkspace) each grow mute/unmute items; mute state is in-memory and session-scoped.

Confidence Score: 4/5

Safe to merge with the open threads resolved; the notification delivery path changes are surgical and well-guarded, but the unresolved localization gaps, stale sidebar menu state, and test seam in production source from prior review rounds should be addressed before shipping.

The core mute logic in applyNotification and recordNotification is correctly threaded and the mutual exclusion between the record and non-record branches is sound. However, prior review comments that are still open cover a test-only seam (clearNotificationMutesForTesting) added to production Sources/, missing translations across the majority of supported locales, and a stale sidebar unmute label that persists past mute expiry without a timer-driven refresh.

Sources/TerminalNotificationStore+Mute.swift (production test seam), Resources/Localizable.xcstrings (missing locale translations), Sources/ContentView.swift (stale mute-active flag after expiry)

Important Files Changed

Filename Overview
Sources/TerminalNotificationStore.swift Core delivery path modified to thread shouldMuteNotificationSideEffects through applyNotification and recordNotification; reorder-workspace mute guard is duplicated in both branches.
Sources/TerminalNotificationStore+Mute.swift New extension adding workspace and surface mute/unmute methods; contains a #if DEBUG clearNotificationMutesForTesting() seam in production source (flagged in existing review thread).
Sources/ContentView.swift Threads contextMenuWorkspaceMuteActive and notificationMuteMenuOptions down to TabItemView; mute-active flag is computed once at render time (stale-after-expiry noted in existing review thread).
Sources/GhosttyNSView+MoveTabToNewWorkspace.swift Adds terminal tab mute/unmute to the AppKit context menu; NSMenu is rebuilt per right-click so stale state is not an issue here.
Sources/CmuxNotificationConfigDefinitions.swift Splits notification config types out of CmuxConfig.swift into a dedicated file; adds CmuxNotificationMuteDurationDefinition with multi-key (seconds/minutes/hours) decoding.
Resources/Localizable.xcstrings Adds 10 new mute/unmute string keys but only with en and ja translations; other supported locales are missing (flagged in existing review thread).

Sequence Diagram

%%{init: {'theme': 'neutral'}}%%
sequenceDiagram
    participant User
    participant SidebarMenu as Sidebar/Tab ContextMenu
    participant TNS as TerminalNotificationStore
    participant CmuxConfig as CmuxConfigStore
    participant GhosttyNSView as GhosttyNSView (Terminal Tab)

    User->>SidebarMenu: Right-click workspace
    SidebarMenu->>CmuxConfig: notificationMuteMenuOptions
    CmuxConfig-->>SidebarMenu: [NotificationMuteMenuOption]
    User->>SidebarMenu: Select mute duration
    SidebarMenu->>TNS: muteNotifications(forTabIds:until:)
    TNS-->>TNS: "notificationMuteExpirationsByWorkspaceId[tabId] = expiration"

    User->>GhosttyNSView: Right-click terminal
    GhosttyNSView->>TNS: activeSurfaceNotificationMuteExpiration(forSurfaceId:)
    TNS-->>GhosttyNSView: nil / Date (show Unmute item if active)
    User->>GhosttyNSView: Select mute duration
    GhosttyNSView->>TNS: muteNotifications(forTabId:surfaceId:until:)
    TNS-->>TNS: "notificationMuteExpirationsBySurfaceId[surfaceId] = expiration"

    Note over TNS: Notification arrives
    TNS->>TNS: applyNotification()
    TNS->>TNS: activeNotificationMuteExpiration(forTabId:surfaceId:)
    alt Mute active
        TNS-->>TNS: "shouldMuteNotificationSideEffects = true"
        TNS->>TNS: recordNotification() store as unread, skip deliverNotificationSideEffects
    else No mute
        TNS->>TNS: Normal delivery path (sound, banner, hooks, mobile forward)
    end
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 SidebarMenu as Sidebar/Tab ContextMenu
    participant TNS as TerminalNotificationStore
    participant CmuxConfig as CmuxConfigStore
    participant GhosttyNSView as GhosttyNSView (Terminal Tab)

    User->>SidebarMenu: Right-click workspace
    SidebarMenu->>CmuxConfig: notificationMuteMenuOptions
    CmuxConfig-->>SidebarMenu: [NotificationMuteMenuOption]
    User->>SidebarMenu: Select mute duration
    SidebarMenu->>TNS: muteNotifications(forTabIds:until:)
    TNS-->>TNS: "notificationMuteExpirationsByWorkspaceId[tabId] = expiration"

    User->>GhosttyNSView: Right-click terminal
    GhosttyNSView->>TNS: activeSurfaceNotificationMuteExpiration(forSurfaceId:)
    TNS-->>GhosttyNSView: nil / Date (show Unmute item if active)
    User->>GhosttyNSView: Select mute duration
    GhosttyNSView->>TNS: muteNotifications(forTabId:surfaceId:until:)
    TNS-->>TNS: "notificationMuteExpirationsBySurfaceId[surfaceId] = expiration"

    Note over TNS: Notification arrives
    TNS->>TNS: applyNotification()
    TNS->>TNS: activeNotificationMuteExpiration(forTabId:surfaceId:)
    alt Mute active
        TNS-->>TNS: "shouldMuteNotificationSideEffects = true"
        TNS->>TNS: recordNotification() store as unread, skip deliverNotificationSideEffects
    else No mute
        TNS->>TNS: Normal delivery path (sound, banner, hooks, mobile forward)
    end
Loading

Reviews (5): Last reviewed commit: "Fix mute duration config test types" | Re-trigger Greptile

Comment on lines +5 to 175
"contextMenu.muteWorkspaceNotifications": {
"extractionState": "manual",
"localizations": {
"en": {
"stringUnit": {
"state": "translated",
"value": "Mute Workspace Notifications"
}
},
"ja": {
"stringUnit": {
"state": "translated",
"value": "ワークスペース通知をミュート"
}
}
}
},
"contextMenu.muteWorkspacesNotifications": {
"extractionState": "manual",
"localizations": {
"en": {
"stringUnit": {
"state": "translated",
"value": "Mute Workspaces Notifications"
}
},
"ja": {
"stringUnit": {
"state": "translated",
"value": "ワークスペース通知をミュート"
}
}
}
},
"contextMenu.unmuteWorkspaceNotifications": {
"extractionState": "manual",
"localizations": {
"en": {
"stringUnit": {
"state": "translated",
"value": "Unmute Workspace Notifications"
}
},
"ja": {
"stringUnit": {
"state": "translated",
"value": "ワークスペース通知のミュートを解除"
}
}
}
},
"contextMenu.unmuteWorkspacesNotifications": {
"extractionState": "manual",
"localizations": {
"en": {
"stringUnit": {
"state": "translated",
"value": "Unmute Workspaces Notifications"
}
},
"ja": {
"stringUnit": {
"state": "translated",
"value": "ワークスペース通知のミュートを解除"
}
}
}
},
"notificationMute.duration.eightHours": {
"extractionState": "manual",
"localizations": {
"en": {
"stringUnit": {
"state": "translated",
"value": "8 Hours"
}
},
"ja": {
"stringUnit": {
"state": "translated",
"value": "8時間"
}
}
}
},
"notificationMute.duration.fifteenMinutes": {
"extractionState": "manual",
"localizations": {
"en": {
"stringUnit": {
"state": "translated",
"value": "15 Minutes"
}
},
"ja": {
"stringUnit": {
"state": "translated",
"value": "15分"
}
}
}
},
"notificationMute.duration.fourHours": {
"extractionState": "manual",
"localizations": {
"en": {
"stringUnit": {
"state": "translated",
"value": "4 Hours"
}
},
"ja": {
"stringUnit": {
"state": "translated",
"value": "4時間"
}
}
}
},
"notificationMute.duration.oneHour": {
"extractionState": "manual",
"localizations": {
"en": {
"stringUnit": {
"state": "translated",
"value": "1 Hour"
}
},
"ja": {
"stringUnit": {
"state": "translated",
"value": "1時間"
}
}
}
},
"terminalContextMenu.muteTabNotifications": {
"extractionState": "manual",
"localizations": {
"en": {
"stringUnit": {
"state": "translated",
"value": "Mute Tab Notifications"
}
},
"ja": {
"stringUnit": {
"state": "translated",
"value": "タブ通知をミュート"
}
}
}
},
"terminalContextMenu.unmuteTabNotifications": {
"extractionState": "manual",
"localizations": {
"en": {
"stringUnit": {
"state": "translated",
"value": "Unmute Tab Notifications"
}
},
"ja": {
"stringUnit": {
"state": "translated",
"value": "タブ通知のミュートを解除"
}
}
}
},
"command.auth.signIn.title": {

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 Missing translations for all supported locales

All ten new mute/unmute string keys (contextMenu.muteWorkspaceNotifications, contextMenu.muteWorkspacesNotifications, contextMenu.unmuteWorkspaceNotifications, contextMenu.unmuteWorkspacesNotifications, notificationMute.duration.*, terminalContextMenu.*) only carry en and ja entries. Existing catalog strings already include zh-Hans, zh-Hant, ko, de, es, fr, it, da, pl, ru, bs, ar, and potentially others (visible starting at line 241). Users in those locales will see raw English fallback text in the mute menus and submenu items.

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!

Comment thread Sources/TerminalNotificationStore.swift Outdated
Comment on lines +2279 to +2282
func clearNotificationMutesForTesting() {
notificationMuteExpirationsByWorkspaceId = [:]
notificationMuteExpirationsBySurfaceId = [:]
}

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 Test seam with …ForTesting name added to production source

clearNotificationMutesForTesting follows the banned …ForTesting naming convention and is a new seam introduced in this PR — it is not covered by the "existing seams not worsened" pass condition. Both notificationMuteExpirationsByWorkspaceId and notificationMuteExpirationsBySurfaceId are declared @Published private(set), so the test target can already read them via @testable import. To reset them from a test without a production-source seam, widen the setter visibility to internal (or add a dedicated non-ForTesting-named production reset path) so the test can assign directly after @testable import, following the pattern established in PR 6452.

Rule Used: Flag Swift files under a production Sources path (... (source)

Comment thread Sources/ContentView.swift
Comment on lines 12082 to +12085
in: tabManager,
target: contextMenuPinTarget
)
let contextMenuWorkspaceMuteActive = notificationStore.hasActiveWorkspaceNotificationMute(

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 Stale "Unmute" menu item after mute expires without interaction

contextMenuWorkspaceMuteActive is computed once at render time by snapshotting Date(). Because notificationMuteExpirationsByWorkspaceId never changes automatically when a mute timer fires, @Published never emits a new value, and SwiftUI will not re-render. The "Unmute Workspace Notifications" button therefore stays visible past the expiry time until some unrelated state change triggers a fresh render. Notifications themselves suppress correctly (the activeNotificationMuteExpiration check evaluates a live Date() on every call), but the context menu label remains stale. A Timer-driven objectWillChange send or an explicit expiry observation would keep the menu in sync.

@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: 2

Caution

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

⚠️ Outside diff range comments (1)
Sources/TerminalNotificationStore.swift (1)

1248-1266: 🎯 Functional Correctness | 🟠 Major | ⚡ Quick win

Don’t consume cooldown reservations for muted notifications.

Both paths commit the reservation before returning for shouldMuteNotificationSideEffects, so a muted notification can advance delivery cooldowns and throttle the first notification after unmute/expiry even though no side effect was delivered.

🐛 Proposed fix
         if effects.reorderWorkspace,
            !shouldMuteNotificationSideEffects,
            UserDefaultsSettingsClient(defaults: .standard).value(for: SettingCatalog().app.reorderOnNotification) {
             AppDelegate.shared?.tabManagerFor(tabId: notification.tabId)?
                 .moveTabToTopForNotification(notification.tabId)
         }
-        if hasAnyNotificationEffect(effects) {
+        if hasAnyNotificationEffect(effects), !shouldMuteNotificationSideEffects {
             commitCooldownReservation(cooldownReservation, at: now)
         } else {
             restoreCooldownReservation(cooldownReservation)
         }
         guard !shouldMuteNotificationSideEffects else {
@@
         updated.insert(notification, at: 0)
         setWorkspaceManualUnread(false, forTabId: notification.tabId)
         notifications = updated
-        commitCooldownReservation(cooldownReservation, at: now)
 `#if` DEBUG
         cmuxDebugLog(
             "notification.store.record workspace=\(notification.tabId.uuidString.prefix(8)) surface=\(notification.surfaceId?.uuidString.prefix(8) ?? "nil") removed=\(idsToClear.count) unread=\(!notification.isRead ? 1 : 0) paneFlash=\(notification.paneFlash ? 1 : 0) suppressExternal=\(shouldSuppressExternalDelivery ? 1 : 0) total=\(notifications.count)"
         )
@@
         guard !shouldMuteNotificationSideEffects else {
+            restoreCooldownReservation(cooldownReservation)
 `#if` DEBUG
             cmuxDebugLog(
                 "notification.store.sideEffects.skip workspace=\(notification.tabId.uuidString.prefix(8)) surface=\(notification.surfaceId?.uuidString.prefix(8) ?? "nil") reason=muted"
             )
 `#endif`
             return
         }
+        commitCooldownReservation(cooldownReservation, at: now)
         deliverNotificationSideEffects(
             notification,
             shouldSuppressExternalDelivery: shouldSuppressExternalDelivery,
             effects: effects
         )

Also applies to: 1306-1368

🤖 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/TerminalNotificationStore.swift` around lines 1248 - 1266, The
cooldown reservation is being committed or restored before checking if the
notification should be muted. Move the cooldown reservation logic that checks
hasAnyNotificationEffect and calls either commitCooldownReservation or
restoreCooldownReservation to occur after the shouldMuteNotificationSideEffects
guard statement. This ensures that muted notifications do not consume the
cooldown reservation and do not throttle future notifications after unmute or
expiry.
🤖 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/TerminalNotificationStore.swift`:
- Around line 503-504: The notificationMuteExpirationsByWorkspaceId and
notificationMuteExpirationsBySurfaceId properties are marked with `@Published`,
which will invalidate all observers on each mute/unmute operation due to
dictionary mutations. Remove the `@Published` decorator from these properties and
make them private storage instead. Then expose focused query methods or
value-snapshot APIs that return only the specific data needed by UI components,
following the repo's pattern of using `@Observable` with `@State` or value snapshots
rather than ObservableObject with `@Published` for new state.
- Around line 2279-2282: Remove the clearNotificationMutesForTesting method from
TerminalNotificationStore since production source files must not contain
test-only members with ForTesting naming conventions. Instead, use the existing
unmuteNotifications method to clean up notification mute state in tests, or if
direct property access is necessary, adjust the visibility of
notificationMuteExpirationsByWorkspaceId and
notificationMuteExpirationsBySurfaceId properties to be testable and then use
`@testable` import in test files to access them directly.

---

Outside diff comments:
In `@Sources/TerminalNotificationStore.swift`:
- Around line 1248-1266: The cooldown reservation is being committed or restored
before checking if the notification should be muted. Move the cooldown
reservation logic that checks hasAnyNotificationEffect and calls either
commitCooldownReservation or restoreCooldownReservation to occur after the
shouldMuteNotificationSideEffects guard statement. This ensures that muted
notifications do not consume the cooldown reservation and do not throttle future
notifications after unmute or expiry.
🪄 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: 3cc7f1ef-d51f-437c-a2cd-e6d4b6000b3f

📥 Commits

Reviewing files that changed from the base of the PR and between f4d5b22 and c5f48d2.

📒 Files selected for processing (6)
  • Resources/Localizable.xcstrings
  • Sources/ContentView.swift
  • Sources/GhosttyNSView+MoveTabToNewWorkspace.swift
  • Sources/GhosttyTerminalView.swift
  • Sources/TerminalNotificationStore.swift
  • cmuxTests/TerminalNotificationClearAllTests.swift

Comment thread Sources/TerminalNotificationStore.swift Outdated
Comment thread Sources/TerminalNotificationStore.swift Outdated
Comment thread Sources/TerminalNotificationStore.swift Outdated
Comment thread Sources/TerminalNotificationStore.swift
Comment thread Sources/ContentView.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: 7

♻️ Duplicate comments (2)
Sources/TerminalNotificationStore+Mute.swift (1)

83-90: 📐 Maintainability & Code Quality | 🟠 Major | ⚡ Quick win

Remove clearNotificationMutesForTesting from production Sources/ code.

This is a test/debug seam (#if DEBUG + ...ForTesting) in a production Sources/**/*.swift file, which is disallowed by repo policy.

🧹 Proposed fix
-#if DEBUG
-extension TerminalNotificationStore {
-    func clearNotificationMutesForTesting() {
-        notificationMuteExpirationsByWorkspaceId = [:]
-        notificationMuteExpirationsBySurfaceId = [:]
-    }
-}
-#endif

As per path instructions, production Sources/**/*.swift must not add test/debug-only seams (including #if DEBUG members and ...ForTesting naming).

🤖 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/TerminalNotificationStore`+Mute.swift around lines 83 - 90, Remove
the entire `#if DEBUG` extension block from TerminalNotificationStore+Mute.swift
that contains the `clearNotificationMutesForTesting()` method. This debug seam
violates the repository policy that prohibits test/debug-only code in production
`Sources/` directories. Delete the complete extension definition starting with
`#if DEBUG` through the closing `#endif`, including the
`clearNotificationMutesForTesting()` method and its implementation that directly
modifies `notificationMuteExpirationsByWorkspaceId` and
`notificationMuteExpirationsBySurfaceId`.

Source: Path instructions

Sources/TerminalNotificationStore.swift (1)

437-438: 🚀 Performance & Scalability | 🟠 Major | ⚡ Quick win

Avoid introducing new @Published mute maps.

Publishing whole mute dictionaries invalidates broad observers on every mute/unmute and violates the repo’s new-state policy for cmux-owned SwiftUI state. Keep this as non-published internal state (or migrate to Observation/value snapshots) and expose focused read APIs.

♻️ Suggested direction
-    `@Published` var notificationMuteExpirationsByWorkspaceId: [UUID: Date] = [:]
-    `@Published` var notificationMuteExpirationsBySurfaceId: [UUID: Date] = [:]
+    private(set) var notificationMuteExpirationsByWorkspaceId: [UUID: Date] = [:]
+    private(set) var notificationMuteExpirationsBySurfaceId: [UUID: Date] = [:]

As per coding guidelines, “Do not use ObservableObject, @Published, @StateObject, or @EnvironmentObject for new cmux-owned SwiftUI state,” and as per path instructions for Sources/**/*.swift, SwiftUI state-layout violations in production changes should fail.

🤖 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/TerminalNotificationStore.swift` around lines 437 - 438, The
properties notificationMuteExpirationsByWorkspaceId and
notificationMuteExpirationsBySurfaceId should not be marked with `@Published` as
this violates the repo's policy for cmux-owned SwiftUI state and causes broad
observer invalidation on every mute/unmute operation. Remove the `@Published`
attribute from both properties, keep them as non-published internal state, and
instead create focused read APIs (getter methods) that expose only the specific
information needed by consumers rather than publishing the entire mute
dictionaries.

Sources: Coding guidelines, Path instructions

🤖 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 `@cmuxTests/CmuxNotificationMuteConfigTests.swift`:
- Around line 20-22: The test fixture contains a mixed-unit duration entry
combining both "hours" and "minutes" fields, which violates the schema contract
requiring exactly one of seconds, minutes, or hours per entry. Fix the
CmuxNotificationMuteDurationDefinition.init(from:) initializer to enforce one-of
validation and reject entries with multiple unit types by checking that exactly
one of the three duration unit fields is present. Update the fixture to replace
the mixed-unit entry (the "2.5 Hours" entry with both hours and minutes) with a
single-unit equivalent such as "minutes": 150, and add a separate test case that
explicitly validates the rejection of multi-unit entries to ensure the
validation logic works correctly.

In `@docs/notifications.md`:
- Line 130: The constraint documentation in the notifications.md file
incorrectly states "at least one positive" for the `seconds`, `minutes`, or
`hours` fields, but the schema contract requires exactly one of these fields to
be present. Update the constraint wording on this line to clearly state that
each muteDurations entry must include exactly one (not at least one) of the
`seconds`, `minutes`, or `hours` fields to accurately reflect the schema
definition referenced in web/data/cmux.schema.json.

In `@Sources/CmuxConfig.swift`:
- Line 1809: The notificationMuteMenuOptions property in CmuxConfigStore is
using the deprecated `@Published` decorator which violates the coding guidelines
requiring modern Observation framework for new state. Remove the `@Published`
annotation from the notificationMuteMenuOptions declaration and refactor
CmuxConfigStore to use `@Observable` macro instead of ObservableObject, or
alternatively implement this property using value snapshots and async/await
patterns as per the modern state management guidelines specified in the repo
rules.

In `@Sources/CmuxNotificationConfigDefinitions.swift`:
- Around line 39-52: The decoder currently accepts and sums combinations of
seconds, minutes, and hours fields, but the schema requires exactly one of these
fields to be present. Modify the validation logic in the decode initialization
to first check that exactly one of the three fields (seconds, minutes, hours)
from CodingKeys is non-nil before processing. If more than one or zero fields
are provided, throw a DecodingError.dataCorruptedError with an appropriate
debugDescription indicating that exactly one duration unit must be specified.
Then decode and use only the single provided field instead of summing all three.

In `@Sources/TabItemView`+NotificationMuteContextMenu.swift:
- Around line 8-12: The remoteContextMenuWorkspaces() function performs an
O(m×n) scan by iterating through each workspace ID and calling first(where:) on
tabManager.tabs for every ID. To fix this, build a dictionary lookup from
tabManager.tabs once (mapping workspace IDs to Workspace objects) before
iterating through remoteContextMenuWorkspaceIds, then use direct dictionary
lookups instead of repeated full array scans. This changes the complexity to O(n
+ m) where n is the number of tabs and m is the number of workspace IDs, making
the function scalable for large workspace sets.

In `@web/data/cmux.schema.json`:
- Around line 658-668: In the mute duration constraint section of the schema,
replace the `anyOf` keyword with `oneOf` to enforce mutual exclusivity among the
time unit fields (seconds, minutes, and hours). The current `anyOf` allows
multiple time unit properties to coexist in a single entry, which creates
ambiguous configurations. Using `oneOf` ensures that exactly one of these three
time unit options must be present and no combination of multiple time units is
permitted.
- Around line 635-652: The default mute duration labels in the schema are
hardcoded English strings that bypass localization. Replace the literal label
values ("15 Minutes", "1 Hour", "4 Hours", "8 Hours") with localization keys
that correspond to the existing entries in Resources/Localizable.xcstrings (such
as notificationMute.duration.fifteenMinutes, notificationMute.duration.oneHour,
etc.), and update the NotificationMuteMenuOption.options(configuredDurations:)
method to perform localization lookup on these keys when displaying menu
options. Alternatively, if keeping literal strings in the schema is required,
modify the implementation to map known label patterns to their corresponding
localization keys before displaying them.

---

Duplicate comments:
In `@Sources/TerminalNotificationStore.swift`:
- Around line 437-438: The properties notificationMuteExpirationsByWorkspaceId
and notificationMuteExpirationsBySurfaceId should not be marked with `@Published`
as this violates the repo's policy for cmux-owned SwiftUI state and causes broad
observer invalidation on every mute/unmute operation. Remove the `@Published`
attribute from both properties, keep them as non-published internal state, and
instead create focused read APIs (getter methods) that expose only the specific
information needed by consumers rather than publishing the entire mute
dictionaries.

In `@Sources/TerminalNotificationStore`+Mute.swift:
- Around line 83-90: Remove the entire `#if DEBUG` extension block from
TerminalNotificationStore+Mute.swift that contains the
`clearNotificationMutesForTesting()` method. This debug seam violates the
repository policy that prohibits test/debug-only code in production `Sources/`
directories. Delete the complete extension definition starting with `#if DEBUG`
through the closing `#endif`, including the `clearNotificationMutesForTesting()`
method and its implementation that directly modifies
`notificationMuteExpirationsByWorkspaceId` and
`notificationMuteExpirationsBySurfaceId`.
🪄 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: e4963ec8-2c5e-4a4f-ac90-f22a7479718a

📥 Commits

Reviewing files that changed from the base of the PR and between c5f48d2 and 5717d94.

📒 Files selected for processing (18)
  • Resources/Localizable.xcstrings
  • Sources/CmuxConfig.swift
  • Sources/CmuxNotificationConfigDefinitions.swift
  • Sources/CmuxSettingsJSONPathSupport.swift
  • Sources/ContentView.swift
  • Sources/GhosttyNSView+MoveTabToNewWorkspace.swift
  • Sources/GhosttyTerminalView.swift
  • Sources/KeyboardShortcutSettingsFileStore+Template.swift
  • Sources/NotificationDisplaySettings.swift
  • Sources/NotificationMuteMenuOption.swift
  • Sources/TabItemView+NotificationMuteContextMenu.swift
  • Sources/TerminalNotificationStore+Mute.swift
  • Sources/TerminalNotificationStore.swift
  • cmux.xcodeproj/project.pbxproj
  • cmuxTests/CmuxNotificationMuteConfigTests.swift
  • cmuxTests/TerminalNotificationMuteTests.swift
  • docs/notifications.md
  • web/data/cmux.schema.json
💤 Files with no reviewable changes (1)
  • Sources/GhosttyTerminalView.swift

Comment on lines +20 to +22
{ "label": "30 Minutes", "minutes": 30 },
{ "label": "2.5 Hours", "hours": 2, "minutes": 30 }
]

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🗄️ Data Integrity & Integration | 🟠 Major | ⚡ Quick win

Mixed-unit duration fixture conflicts with the schema contract.

Line 21 asserts that a single entry may combine "hours" and "minutes", but the config schema contract requires exactly one of seconds, minutes, or hours per item. This test currently codifies behavior that diverges from the schema and from user-facing config validation.

Please align both sides: enforce one-of semantics in CmuxNotificationMuteDurationDefinition.init(from:), and make this fixture single-unit (plus add a reject-multiple-units test).

Proposed test-side alignment
@@
-              { "label": "2.5 Hours", "hours": 2, "minutes": 30 }
+              { "label": "150 Minutes", "minutes": 150 }

As per path instructions, web/data/cmux.schema.json requires each notifications.muteDurations entry to include exactly one of seconds, minutes, or hours.

📝 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
{ "label": "30 Minutes", "minutes": 30 },
{ "label": "2.5 Hours", "hours": 2, "minutes": 30 }
]
{ "label": "30 Minutes", "minutes": 30 },
{ "label": "150 Minutes", "minutes": 150 }
]
🤖 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 `@cmuxTests/CmuxNotificationMuteConfigTests.swift` around lines 20 - 22, The
test fixture contains a mixed-unit duration entry combining both "hours" and
"minutes" fields, which violates the schema contract requiring exactly one of
seconds, minutes, or hours per entry. Fix the
CmuxNotificationMuteDurationDefinition.init(from:) initializer to enforce one-of
validation and reject entries with multiple unit types by checking that exactly
one of the three duration unit fields is present. Update the fixture to replace
the mixed-unit entry (the "2.5 Hours" entry with both hours and minutes) with a
single-unit equivalent such as "minutes": 150, and add a separate test case that
explicitly validates the rejection of multi-unit entries to ensure the
validation logic works correctly.

Source: Path instructions

Comment thread docs/notifications.md
}
```

Each item needs a non-empty `label` and at least one positive `seconds`, `minutes`, or `hours` value. Project `cmux.json` values replace the global timed choices for that project.

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

Fix the mute-duration unit constraint wording.

This line says “at least one” unit field, but the schema contract requires exactly one of seconds, minutes, or hours.

📝 Proposed doc fix
-Each item needs a non-empty `label` and at least one positive `seconds`, `minutes`, or `hours` value. Project `cmux.json` values replace the global timed choices for that project.
+Each item needs a non-empty `label` and exactly one positive `seconds`, `minutes`, or `hours` value. Project `cmux.json` values replace the global timed choices for that project.

As per path instructions (referenced web/data/cmux.schema.json), each muteDurations entry must include exactly one of seconds, minutes, or hours.

📝 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
Each item needs a non-empty `label` and at least one positive `seconds`, `minutes`, or `hours` value. Project `cmux.json` values replace the global timed choices for that project.
Each item needs a non-empty `label` and exactly one positive `seconds`, `minutes`, or `hours` value. Project `cmux.json` values replace the global timed choices for that project.
🤖 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 `@docs/notifications.md` at line 130, The constraint documentation in the
notifications.md file incorrectly states "at least one positive" for the
`seconds`, `minutes`, or `hours` fields, but the schema contract requires
exactly one of these fields to be present. Update the constraint wording on this
line to clearly state that each muteDurations entry must include exactly one
(not at least one) of the `seconds`, `minutes`, or `hours` fields to accurately
reflect the schema definition referenced in web/data/cmux.schema.json.

Source: Path instructions

Comment thread Sources/CmuxConfig.swift
@Published private(set) var workspaceGroupConfigs: [CmuxResolvedWorkspaceGroupConfig] = []
@Published private(set) var surfaceTabBarButtons: [CmuxSurfaceTabBarButton] = CmuxSurfaceTabBarButton.defaults
@Published private(set) var notificationHooks: [CmuxResolvedNotificationHook] = []
@Published private(set) var notificationMuteMenuOptions: [NotificationMuteMenuOption] = NotificationMuteMenuOption.defaultOptions

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

📐 Maintainability & Code Quality | 🟠 Major | 🏗️ Heavy lift

Avoid adding new @Published state in cmux-owned production Swift.

This adds new Combine-based state (@Published) to CmuxConfigStore. The repo rules require new state to use modern Observation (@Observable + snapshots) rather than expanding ObservableObject/@Published usage.

As per coding guidelines, “Do not use ObservableObject, @Published, @StateObject, or @EnvironmentObject for new cmux-owned SwiftUI state; use @Observable plus @State or value snapshots instead,” and “Flag new Combine usage … when Observation and async/await are available.”

🤖 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/CmuxConfig.swift` at line 1809, The notificationMuteMenuOptions
property in CmuxConfigStore is using the deprecated `@Published` decorator which
violates the coding guidelines requiring modern Observation framework for new
state. Remove the `@Published` annotation from the notificationMuteMenuOptions
declaration and refactor CmuxConfigStore to use `@Observable` macro instead of
ObservableObject, or alternatively implement this property using value snapshots
and async/await patterns as per the modern state management guidelines specified
in the repo rules.

Source: Coding guidelines

Comment on lines +39 to +52
let seconds = try container.decodeIfPresent(TimeInterval.self, forKey: .seconds)
let minutes = try container.decodeIfPresent(TimeInterval.self, forKey: .minutes)
let hours = try container.decodeIfPresent(TimeInterval.self, forKey: .hours)
for (key, value) in [(CodingKeys.seconds, seconds), (.minutes, minutes), (.hours, hours)] {
if let value, (!value.isFinite || value <= 0) {
throw DecodingError.dataCorruptedError(
forKey: key,
in: container,
debugDescription: "\(key.stringValue) must be greater than 0"
)
}
}
let decodedInterval = (seconds ?? 0) + ((minutes ?? 0) * 60) + ((hours ?? 0) * 60 * 60)
if decodedInterval <= 0 {

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🗄️ Data Integrity & Integration | 🟠 Major | ⚡ Quick win

Enforce a single duration unit to match the schema contract.

The decoder currently accepts combinations of seconds, minutes, and hours by summing all provided fields. That diverges from the notifications.muteDurations schema contract and can make app parsing disagree with schema-based validation.

Suggested fix
     let seconds = try container.decodeIfPresent(TimeInterval.self, forKey: .seconds)
     let minutes = try container.decodeIfPresent(TimeInterval.self, forKey: .minutes)
     let hours = try container.decodeIfPresent(TimeInterval.self, forKey: .hours)
+    let presentCount = [seconds, minutes, hours].compactMap { $0 }.count
+    guard presentCount == 1 else {
+        throw DecodingError.dataCorruptedError(
+            forKey: .seconds,
+            in: container,
+            debugDescription: "exactly one of seconds, minutes, or hours must be provided"
+        )
+    }
     for (key, value) in [(CodingKeys.seconds, seconds), (.minutes, minutes), (.hours, hours)] {
         if let value, (!value.isFinite || value <= 0) {
             throw DecodingError.dataCorruptedError(
                 forKey: key,
                 in: container,
                 debugDescription: "\(key.stringValue) must be greater than 0"
             )
         }
     }

As per path instructions, “Each entry must include exactly one of seconds, minutes, or hours.”

📝 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
let seconds = try container.decodeIfPresent(TimeInterval.self, forKey: .seconds)
let minutes = try container.decodeIfPresent(TimeInterval.self, forKey: .minutes)
let hours = try container.decodeIfPresent(TimeInterval.self, forKey: .hours)
for (key, value) in [(CodingKeys.seconds, seconds), (.minutes, minutes), (.hours, hours)] {
if let value, (!value.isFinite || value <= 0) {
throw DecodingError.dataCorruptedError(
forKey: key,
in: container,
debugDescription: "\(key.stringValue) must be greater than 0"
)
}
}
let decodedInterval = (seconds ?? 0) + ((minutes ?? 0) * 60) + ((hours ?? 0) * 60 * 60)
if decodedInterval <= 0 {
let seconds = try container.decodeIfPresent(TimeInterval.self, forKey: .seconds)
let minutes = try container.decodeIfPresent(TimeInterval.self, forKey: .minutes)
let hours = try container.decodeIfPresent(TimeInterval.self, forKey: .hours)
let presentCount = [seconds, minutes, hours].compactMap { $0 }.count
guard presentCount == 1 else {
throw DecodingError.dataCorruptedError(
forKey: .seconds,
in: container,
debugDescription: "exactly one of seconds, minutes, or hours must be provided"
)
}
for (key, value) in [(CodingKeys.seconds, seconds), (.minutes, minutes), (.hours, hours)] {
if let value, (!value.isFinite || value <= 0) {
throw DecodingError.dataCorruptedError(
forKey: key,
in: container,
debugDescription: "\(key.stringValue) must be greater than 0"
)
}
}
let decodedInterval = (seconds ?? 0) + ((minutes ?? 0) * 60) + ((hours ?? 0) * 60 * 60)
if decodedInterval <= 0 {
🤖 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/CmuxNotificationConfigDefinitions.swift` around lines 39 - 52, The
decoder currently accepts and sums combinations of seconds, minutes, and hours
fields, but the schema requires exactly one of these fields to be present.
Modify the validation logic in the decode initialization to first check that
exactly one of the three fields (seconds, minutes, hours) from CodingKeys is
non-nil before processing. If more than one or zero fields are provided, throw a
DecodingError.dataCorruptedError with an appropriate debugDescription indicating
that exactly one duration unit must be specified. Then decode and use only the
single provided field instead of summing all three.

Source: Path instructions

Comment on lines +8 to +12
func remoteContextMenuWorkspaces() -> [Workspace] {
guard !remoteContextMenuWorkspaceIds.isEmpty else { return [] }
return remoteContextMenuWorkspaceIds.compactMap { workspaceId in
tabManager.tabs.first(where: { $0.id == workspaceId })
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🚀 Performance & Scalability | 🟠 Major | ⚡ Quick win

Avoid per-target rescans in remoteContextMenuWorkspaces().

Line 10 iterates each selected workspace id, and Line 11 does a full tabManager.tabs.first(where:) scan per id. This becomes O(m×n) on large workspace sets.

Suggested fix
 func remoteContextMenuWorkspaces() -> [Workspace] {
     guard !remoteContextMenuWorkspaceIds.isEmpty else { return [] }
-    return remoteContextMenuWorkspaceIds.compactMap { workspaceId in
-        tabManager.tabs.first(where: { $0.id == workspaceId })
-    }
+    let tabsById = Dictionary(uniqueKeysWithValues: tabManager.tabs.map { ($0.id, $0) })
+    return remoteContextMenuWorkspaceIds.compactMap { tabsById[$0] }
 }

As per path instructions, apply .github/review-bot-rules/algorithmic-complexity.md: avoid repeated full scans per target and prefer Set/dictionary lookups for scalable collections.

🤖 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/TabItemView`+NotificationMuteContextMenu.swift around lines 8 - 12,
The remoteContextMenuWorkspaces() function performs an O(m×n) scan by iterating
through each workspace ID and calling first(where:) on tabManager.tabs for every
ID. To fix this, build a dictionary lookup from tabManager.tabs once (mapping
workspace IDs to Workspace objects) before iterating through
remoteContextMenuWorkspaceIds, then use direct dictionary lookups instead of
repeated full array scans. This changes the complexity to O(n + m) where n is
the number of tabs and m is the number of workspace IDs, making the function
scalable for large workspace sets.

Source: Path instructions

Comment thread web/data/cmux.schema.json
Comment on lines +635 to +652
"default": [
{
"label": "15 Minutes",
"minutes": 15
},
{
"label": "1 Hour",
"hours": 1
},
{
"label": "4 Hours",
"hours": 4
},
{
"label": "8 Hours",
"hours": 8
}
],

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🎯 Functional Correctness | 🟠 Major

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# Description: Verify mute duration label localization handling

# Check how NotificationMuteMenuOption uses labels
echo "=== NotificationMuteMenuOption label usage ==="
rg -nP -A5 -B5 'struct NotificationMuteMenuOption' --type=swift

# Check if default labels are referenced or localized
echo -e "\n=== Search for default label strings in Swift ==="
rg -nP '(15 Minutes|1 Hour|4 Hours|8 Hours)' --type=swift

# Check localization file for mute duration keys
echo -e "\n=== Check Localizable.xcstrings for mute duration translations ==="
cat Resources/Localizable.xcstrings | jq '.strings | to_entries | map(select(.key | contains("mute") or contains("duration"))) | from_entries' 2>/dev/null || echo "Run jq to inspect xcstrings"

# Check template generation for muteDurations
echo -e "\n=== Template generation for muteDurations ==="
rg -nP -C5 'muteDurations' Sources/KeyboardShortcutSettingsFileStore+Template.swift --type=swift

Repository: manaflow-ai/cmux

Length of output: 201


🏁 Script executed:

# Find files related to notification and mute functionality
fd -type f -name "*[Nn]otif*" | head -20

Repository: manaflow-ai/cmux

Length of output: 325


🏁 Script executed:

# Search for muteDurations usage across the codebase
rg "muteDurations" --type-list | head -20
rg "muteDurations" -l

Repository: manaflow-ai/cmux

Length of output: 544


🏁 Script executed:

# Check web directory structure for notification handling
find web -type f -name "*.ts" -o -name "*.tsx" -o -name "*.json" | grep -iE "(notif|mute)" | head -20

Repository: manaflow-ai/cmux

Length of output: 396


🏁 Script executed:

# Look for where default schema is used or referenced
rg "cmux.schema.json\|NotificationMute\|mute.*duration" --type=ts --type=tsx -l

Repository: manaflow-ai/cmux

Length of output: 182


🏁 Script executed:

# Check web/messages localization files for mute-related strings
ls -la web/messages/
cat web/messages/en.json | jq 'keys | map(select(contains("mute") or contains("notif")))' 2>/dev/null || echo "Check structure"

Repository: manaflow-ai/cmux

Length of output: 1486


🏁 Script executed:

# Search for muteDurations across the entire codebase
rg "muteDurations" -l

Repository: manaflow-ai/cmux

Length of output: 154


🏁 Script executed:

# Check the schema file itself to understand the structure
cat web/data/cmux.schema.json | jq '.properties.notifications.properties.muteDurations'

Repository: manaflow-ai/cmux

Length of output: 1500


🏁 Script executed:

# Look for notification-related messages in the localization files
rg "mute|notif" web/messages/en.json | head -20

Repository: manaflow-ai/cmux

Length of output: 4029


🏁 Script executed:

# Check how the config is used in the backend - look for notification handlers
rg -l "mute|duration" web --type=js | head -10

Repository: manaflow-ai/cmux

Length of output: 154


🏁 Script executed:

# Search for where these default values might be used or displayed
rg "15 Minutes|1 Hour|4 Hours|8 Hours" -l

Repository: manaflow-ai/cmux

Length of output: 154


🏁 Script executed:

# Check if there's any backend code that processes the schema defaults
find web -type f \( -name "*.ts" -o -name "*.js" \) -exec grep -l "schema\|cmux.json\|config" {} \; | head -15

Repository: manaflow-ai/cmux

Length of output: 1443


🏁 Script executed:

# Look for notification or mute-related backend logic
rg "notification|mute" web/src web/api --type=js | head -30

Repository: manaflow-ai/cmux

Length of output: 250


🏁 Script executed:

# Check if there are any test files that reference the defaults
find . -type f -name "*.test.ts" -o -name "*.test.js" | xargs grep -l "mute\|duration" 2>/dev/null

Repository: manaflow-ai/cmux

Length of output: 242


🏁 Script executed:

# Check the schema description more carefully and look for related schema sections
cat web/data/cmux.schema.json | jq '.properties.notifications'

Repository: manaflow-ai/cmux

Length of output: 4724


🏁 Script executed:

# Look for where user config gets loaded and used
rg "cmux\.json\|loadConfig\|getConfig" --type=js | head -20

Repository: manaflow-ai/cmux

Length of output: 154


🏁 Script executed:

# Search the entire codebase for any reference to muteDurations or mute menu
rg -i "mutedurations\|mute.*menu\|notification.*mute" --type-list | head
rg -r "mutedurations\|mute.*menu\|notification.*mute"

Repository: manaflow-ai/cmux

Length of output: 941


🏁 Script executed:

# Look for where config files are loaded in the app
rg "cmux\.json" -A5 -B5 | head -50

Repository: manaflow-ai/cmux

Length of output: 154


🏁 Script executed:

# Check if there's any iOS/macOS code handling notifications and mute options
find . -name "*.swift" -exec grep -l "mute\|notification" {} \; 2>/dev/null | head -10

Repository: manaflow-ai/cmux

Length of output: 727


🏁 Script executed:

# Look at the recent commit history to understand the context of this schema addition
git log --oneline web/data/cmux.schema.json 2>/dev/null | head -5

Repository: manaflow-ai/cmux

Length of output: 206


🏁 Script executed:

# Check if there are any pending implementation TODOs related to this
rg "TODO|FIXME" web/data/cmux.schema.json -B5 -A5

Repository: manaflow-ai/cmux

Length of output: 154


🏁 Script executed:

# Look at the specific commit that added this schema entry
git show d3719e2c --stat

Repository: manaflow-ai/cmux

Length of output: 50373


🏁 Script executed:

# Check the full diff of that commit
git show d3719e2c

Repository: manaflow-ai/cmux

Length of output: 50373


🏁 Script executed:

# Look at any related PRs or issue context
git log --grep="mute\|notification" --oneline | head -20

Repository: manaflow-ai/cmux

Length of output: 206


🏁 Script executed:

# Check if there are TypeScript/JavaScript types generated from the schema
find . -type f \( -name "*.ts" -o -name "*.d.ts" \) -exec grep -l "muteDurations\|NotificationMute" {} \; 2>/dev/null

Repository: manaflow-ai/cmux

Length of output: 154


🏁 Script executed:

# Look for any documentation about mute durations
find . -type f \( -name "*.md" -o -name "*.tsx" \) -exec grep -l "mute.*duration\|15 Minutes" {} \; 2>/dev/null | head -10

Repository: manaflow-ai/cmux

Length of output: 154


🏁 Script executed:

# Look at NotificationMuteMenuOption.swift to see how labels are used
fd -type f -name "*NotificationMute*"

Repository: manaflow-ai/cmux

Length of output: 325


🏁 Script executed:

# Search for where cmux.json config is loaded and used in Swift
rg "CmuxConfig\|config\.notifications" Sources --type=swift | head -20

Repository: manaflow-ai/cmux

Length of output: 154


🏁 Script executed:

# Look at the coding guidelines reference - check if there's a localization audit for notifications
rg "notification.*mute\|mute.*menu" Resources/Localizable.xcstrings -A3 -B3

Repository: manaflow-ai/cmux

Length of output: 154


🏁 Script executed:

# Check how config defaults are applied in the app
rg "default.*mute\|muteDuration" Sources --type=swift -B3 -A3 | head -30

Repository: manaflow-ai/cmux

Length of output: 154


🏁 Script executed:

# Find the notification mute menu option file
find Sources -name "*NotificationMute*" -o -name "*Mute*" | grep -i notification

Repository: manaflow-ai/cmux

Length of output: 294


🏁 Script executed:

# Search for where the mute durations from config are actually used
rg "muteDurations" Sources -B5 -A5

Repository: manaflow-ai/cmux

Length of output: 4161


🏁 Script executed:

# Check if there's any dynamic localization happening
rg "localized.*label\|label.*locali" Sources -B2 -A2

Repository: manaflow-ai/cmux

Length of output: 154


🏁 Script executed:

# Look at CmuxConfig to understand how config is structured
cat Sources/CmuxConfig.swift | head -100

Repository: manaflow-ai/cmux

Length of output: 4301


🏁 Script executed:

# Look at how NotificationMuteMenuOption uses the labels
cat Sources/NotificationMuteMenuOption.swift

Repository: manaflow-ai/cmux

Length of output: 2741


🏁 Script executed:

# Check TabItemView to see how the mute menu is displayed
cat Sources/TabItemView+NotificationMuteContextMenu.swift | head -80

Repository: manaflow-ai/cmux

Length of output: 2097


🏁 Script executed:

# Check if the schema defaults are actually loaded into the app or if the app always uses its hardcoded defaults
rg "defaultTimedOptions\|defaultOptions" Sources -B3 -A3

Repository: manaflow-ai/cmux

Length of output: 154


🏁 Script executed:

# Verify localization keys exist in the xcstrings for the default mute options
rg "notificationMute.duration" Resources/Localizable.xcstrings -B2 -A2

Repository: manaflow-ai/cmux

Length of output: 773


🏁 Script executed:

# Check how schema defaults are actually used - are they written to config or just documentation?
rg "default.*muteDurations\|schema.*default" Sources CmuxConfig.swift -B5 -A5

Repository: manaflow-ai/cmux

Length of output: 211


🏁 Script executed:

# Verify that the app doesn't load schema defaults into the config file
find . -name "*.swift" -exec grep -l "cmux\.schema\|schema.*notification" {} \; 2>/dev/null

Repository: manaflow-ai/cmux

Length of output: 382


Default mute duration labels require localization for every supported locale or schema structure change.

The schema defaults are user-facing strings that appear in notification mute context menus when users customize cmux.json. While the app's hardcoded default options are properly localized via String(localized:) with keys in Resources/Localizable.xcstrings, users copying the schema-provided defaults will see English labels in non-English app instances.

The notificationMute.duration.* keys exist in Resources/Localizable.xcstrings with Japanese translations, but the schema default labels ("15 Minutes", "1 Hour", "4 Hours", "8 Hours") are literal strings, not localization keys. When a user adds these to their cmux.json, the NotificationMuteMenuOption.options(configuredDurations:) method passes the label directly as the menu item title without localization lookup.

Either update the schema defaults to be localization keys (and document the mapping), or add every supported locale's label variants to the default array, or update the implementation to map known label patterns to localized strings.

🤖 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 `@web/data/cmux.schema.json` around lines 635 - 652, The default mute duration
labels in the schema are hardcoded English strings that bypass localization.
Replace the literal label values ("15 Minutes", "1 Hour", "4 Hours", "8 Hours")
with localization keys that correspond to the existing entries in
Resources/Localizable.xcstrings (such as
notificationMute.duration.fifteenMinutes, notificationMute.duration.oneHour,
etc.), and update the NotificationMuteMenuOption.options(configuredDurations:)
method to perform localization lookup on these keys when displaying menu
options. Alternatively, if keeping literal strings in the schema is required,
modify the implementation to map known label patterns to their corresponding
localization keys before displaying them.

Source: Path instructions

Comment thread web/data/cmux.schema.json
Comment on lines +658 to +668
"anyOf": [
{
"required": ["seconds"]
},
{
"required": ["minutes"]
},
{
"required": ["hours"]
}
],

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🗄️ Data Integrity & Integration | 🟡 Minor | ⚡ Quick win

Use oneOf instead of anyOf to enforce mutual exclusivity.

The current anyOf constraint allows an entry to provide multiple time unit fields simultaneously (e.g., both "minutes": 15 and "hours": 1). While additionalProperties: false prevents unknown fields, it doesn't prevent multiple known time-unit fields from coexisting. Semantically, each mute duration entry should represent exactly one time span.

Replace anyOf with oneOf to ensure exactly one of seconds, minutes, or hours is present per entry, rejecting ambiguous configurations.

🛡️ Proposed fix to enforce mutual exclusivity
           "required": ["label"],
-          "anyOf": [
+          "oneOf": [
             {
               "required": ["seconds"]
🤖 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 `@web/data/cmux.schema.json` around lines 658 - 668, In the mute duration
constraint section of the schema, replace the `anyOf` keyword with `oneOf` to
enforce mutual exclusivity among the time unit fields (seconds, minutes, and
hours). The current `anyOf` allows multiple time unit properties to coexist in a
single entry, which creates ambiguous configurations. Using `oneOf` ensures that
exactly one of these three time unit options must be present and no combination
of multiple time units is permitted.

Comment thread Sources/CmuxConfig.swift

@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 805c30c. Configure here.

Comment thread Sources/ContentView.swift
workspaceGroups: workspaceGroups,
workspaceGroupById: workspaceGroupById,
workspaceGroupMenuSnapshot: workspaceGroupMenuSnapshot,
notificationMuteMenuOptions: cmuxConfigStore.notificationMuteMenuOptions,

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 workspace mute menu config

Medium Severity

Workspace context mute submenus always use cmuxConfigStore.notificationMuteMenuOptions, which is resolved from the selected workspace’s local config path. Right-clicking a different workspace (or multi-select spanning projects) can show another project’s notifications.muteDurations labels and intervals while muting the wrong targets.

Additional Locations (1)
Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit 805c30c. Configure here.

@lawrencecchen lawrencecchen added the stale-revisit Closed after 30+ days without activity; preserved for possible revisit or reopening. label Sep 23, 2026
@github-project-automation github-project-automation Bot moved this from Todo to Done in cmux backlog Sep 23, 2026

This branch was successfully deployed

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

Labels

stale-revisit Closed after 30+ days without activity; preserved for possible revisit or reopening.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants