Skip to content

feat: per-workspace notification mute toggle (#2038)#2145

Open
anthhub wants to merge 4 commits intomanaflow-ai:mainfrom
anthhub:feat/workspace-notification-mute
Open

feat: per-workspace notification mute toggle (#2038)#2145
anthhub wants to merge 4 commits intomanaflow-ai:mainfrom
anthhub:feat/workspace-notification-mute

Conversation

@anthhub
Copy link
Contributor

@anthhub anthhub commented Mar 25, 2026

Summary

Implements per-workspace notification mute as requested in #2038.

  • Adds @Published var isMuted: Bool = false to Workspace
  • Adds var isMuted: Bool? to SessionWorkspaceSnapshot for session persistence (optional/backward-compatible — old sessions decode nilfalse)
  • Restores isMuted in restoreSessionSnapshot(_:) so mute state survives app restart
  • Early-returns in TerminalNotificationStore.addNotification() when the workspace is muted — no sound, no tab flash, no desktop notification
  • Adds Mute Notifications / Unmute Notifications context menu button immediately after Pin/Unpin, toggling all selected workspaces
  • Adds localized strings for en and ja

Test plan

  • Create two or more workspaces running noisy commands (e.g. while true; do echo hello; sleep 0.5; done)
  • Right-click a workspace tab → verify Mute Notifications appears in the context menu next to Pin/Unpin
  • Click Mute Notifications → verify the menu item now reads Unmute Notifications on re-open
  • Confirm no sound, no tab flash, and no desktop notification fires for the muted workspace while the noisy command runs
  • Confirm the other (unmuted) workspace still receives notifications normally
  • Quit and relaunch the app; verify the muted workspace is still muted after restore
  • Multi-select two workspaces, right-click → Mute Notifications — both should be muted simultaneously

Fixes #2038

🤖 Generated with Claude Code


Summary by cubic

Add per-workspace notification mute with session persistence and a context menu toggle, so you can silence noisy tabs without affecting others (fixes #2038).

  • New Features

    • Added isMuted on Workspace with session persistence (SessionWorkspaceSnapshot.isMuted optional, default false).
    • Context menu adds Mute/Unmute Notifications next to Pin/Unpin; supports multi-select.
    • Muted workspaces skip all notifications (sound, tab flash, desktop) via TerminalNotificationStore.addNotification(); localized strings for en/ja.
  • Bug Fixes

    • Mute works across windows using tabManagerFor(tabId:) instead of only the key window manager.
    • TabItemView Equatable includes isMuted, so the Mute/Unmute label updates after toggling.
    • Reverted unrelated sidebar path changes that were accidentally included earlier.

Written for commit 04b92b9. Summary will update on new commits.

Summary by CodeRabbit

  • New Features

    • Mute/unmute notifications per workspace via context menu
    • Muted workspaces suppress notifications immediately
    • Mute preferences are saved and restored across sessions
  • Localization

    • Added English and Japanese strings for "Mute Notifications" and "Unmute Notifications"

Adds an isMuted flag to Workspace with full session persistence.
The context menu now shows Mute/Unmute Notifications next to Pin/Unpin.
addNotification() silently discards notifications for muted workspaces.
@vercel
Copy link

vercel bot commented Mar 25, 2026

@anthhub is attempting to deploy a commit to the Manaflow Team on Vercel.

A member of the Team first needs to authorize it.

@coderabbitai
Copy link

coderabbitai bot commented Mar 25, 2026

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: b60f7f96-1f17-43db-9f50-60bf6195e9d7

📥 Commits

Reviewing files that changed from the base of the PR and between 523360f and 04b92b9.

📒 Files selected for processing (1)
  • Sources/ContentView.swift

📝 Walkthrough

Walkthrough

Adds per-workspace notification muting: a context-menu toggle in the tab UI, a published isMuted property on Workspace that’s persisted in session snapshots, and early-return suppression of notification delivery when a workspace is muted. Also adds two localization keys for the context menu.

Changes

Cohort / File(s) Summary
Localization
Resources/Localizable.xcstrings
Added contextMenu.muteNotifications and contextMenu.unmuteNotifications with English and Japanese translations (extractionState: "manual").
Workspace model & persistence
Sources/Workspace.swift, Sources/SessionPersistence.swift
Added @Published var isMuted: Bool = false to Workspace; added isMuted: Bool? to SessionWorkspaceSnapshot and persisted/restored it (defaults to false when missing).
Notification filtering
Sources/TerminalNotificationStore.swift
addNotification(...) now returns early when the target workspace is muted, skipping in-memory updates and external delivery/scheduling.
UI / Sidebar row
Sources/ContentView.swift
Tab row equality updated to include isMuted; context-menu button added to toggle Mute/Unmute using tab.isMuted and apply the change to matching workspaces in tabManager.tabs.

Sequence Diagram

sequenceDiagram
    participant User
    participant ContentView as "ContentView\n(Context Menu)"
    participant Workspace
    participant SessionPersistence as "Session\nPersistence"
    participant NotificationStore as "TerminalNotificationStore"
    participant NotificationSystem as "Notification\nSystem"

    User->>ContentView: Toggle "Mute/Unmute Notifications"
    ContentView->>Workspace: set isMuted = true/false
    ContentView->>SessionPersistence: persist snapshot (includes isMuted)

    NotificationSystem->>NotificationStore: addNotification(event, tabId)
    NotificationStore->>Workspace: lookup workspace by tabId
    alt workspace.isMuted == true
        NotificationStore--x NotificationSystem: return (suppressed)
    else workspace.isMuted == false
        NotificationStore->>NotificationSystem: deliver/schedule notification
    end
Loading

Estimated Code Review Effort

🎯 3 (Moderate) | ⏱️ ~20 minutes

Possibly related PRs

Poem

🐰
I nudge the bell with a gentle paw,
Quiet settles where loud logs saw,
Tabs hum softer, peace in a click,
The rabbit hops off—silence, quick. 🎩✨

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
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 (4 passed)
Check name Status Explanation
Title check ✅ Passed The title accurately summarizes the main change: adding a per-workspace notification mute toggle feature, matching the primary objectives and referenced issue #2038.
Description check ✅ Passed The description covers the Summary and Testing sections well, with detailed implementation details and test plan. A demo video link and bot review requests are missing, but core sections are substantially complete.
Linked Issues check ✅ Passed The PR fully implements all coding objectives from #2038: workspace isMuted property, session persistence, notification suppression, context menu button, and localized strings (en/ja). Multi-select support is also confirmed in the summary.
Out of Scope Changes check ✅ Passed All changes are directly scoped to #2038's requirements. The PR explicitly reverted accidental SidebarPathFormatter changes in a prior commit, retaining only mute-related modifications.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests

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.

@greptile-apps
Copy link

greptile-apps bot commented Mar 25, 2026

Greptile Summary

This PR implements per-workspace notification muting as requested in #2038, and also bundles an unrelated SidebarPathFormatter path-truncation refactor. The mute feature itself is well-constructed end-to-end: isMuted is a @Published property on Workspace, optionally persisted in SessionWorkspaceSnapshot (backward-compatible nil → false decode), restored on session reload, and gated correctly at the single entry-point TerminalNotificationStore.addNotification — suppressing sound, tab-flash, and desktop notification together.

Key observations:

  • The session persistence is backward-compatible: old sessions decode isMuted as nil, which is coerced to false in restoreSessionSnapshot.
  • The early return in addNotification is on @MainActor, consistent with all other AppDelegate.shared?.tabManager accesses in that method.
  • The context menu label reads tab.isMuted lazily at right-click time, so it always reflects the current state even without an explicit re-render trigger.
  • Multi-select behavior mirrors the existing pin/unpin pattern (label and action both derived from the right-clicked tab), which is intentional.
  • The SidebarPathFormatter change is logically correct but is an unrelated feature with no mention in the PR description; it should ideally ship as its own PR to keep history bisect-clean.
  • The mute context menu item does not use contextMenuLabel(multi:single:isMulti:) to produce a "Workspace" vs "Workspaces" variant, unlike every other action in the same menu.

Confidence Score: 4/5

  • The mute feature is functionally correct and safe to merge; the only remaining items are a minor labeling inconsistency and an unrelated bundled change.
  • Core mute logic is correct in every layer (model, persistence, notification suppression, UI). The two P2 findings — no singular/plural mute label variants and the unrelated SidebarPathFormatter refactor bundled in — don't break any primary user path and can be addressed in follow-up PRs without blocking this feature.
  • Sources/ContentView.swift — contains both the mute UI (correct) and an unrelated path-truncation change (undocumented in the PR description).

Important Files Changed

Filename Overview
Sources/Workspace.swift Adds @Published var isMuted: Bool = false alongside isPinned; wires it into makeSessionSnapshot() and restoreSessionSnapshot(_:). Correct and minimal.
Sources/SessionPersistence.swift Adds var isMuted: Bool? to SessionWorkspaceSnapshot (optional for backward-compatible decoding: nilfalse). Clean and safe.
Sources/TerminalNotificationStore.swift Early-return guard in addNotification suppresses sound, tab-flash, and desktop notification when the workspace is muted. Consistent with existing AppDelegate.shared?.tabManager access pattern in the same function; runs on @MainActor so thread-safe.
Sources/ContentView.swift Two separate changes: (1) mute toggle context menu button — correct, reads tab.isMuted lazily at right-click time and applies shouldMute to all targetIds; (2) unrelated SidebarPathFormatter smart-truncation refactor — also correct but should be its own PR. Minor: mute label lacks singular/plural variants unlike every other context menu item.
Resources/Localizable.xcstrings Adds contextMenu.muteNotifications and contextMenu.unmuteNotifications with English and Japanese translations. Both strings are correctly inserted in alphabetical key order.

Sequence Diagram

sequenceDiagram
    participant User
    participant TabItemView
    participant Workspace
    participant SessionPersistence
    participant TerminalNotificationStore

    User->>TabItemView: Right-click workspace tab
    TabItemView->>Workspace: read isMuted
    TabItemView-->>User: Show "Mute / Unmute Notifications"

    User->>TabItemView: Click "Mute Notifications"
    TabItemView->>Workspace: isMuted = true
    Workspace-->>TabItemView: objectWillChange fires
    TabItemView->>TabItemView: workspaceObservationGeneration += 1 (debounced)

    Note over Workspace,SessionPersistence: On app quit / session save
    Workspace->>SessionPersistence: makeSessionSnapshot() → isMuted: true

    Note over SessionPersistence,Workspace: On app relaunch / session restore
    SessionPersistence->>Workspace: restoreSessionSnapshot() → isMuted = snapshot.isMuted ?? false

    Note over TerminalNotificationStore: During noisy command output
    TerminalNotificationStore->>Workspace: tabs.first(id==tabId)?.isMuted
    alt isMuted == true
        TerminalNotificationStore-->>TerminalNotificationStore: early return (no sound, flash, desktop notification)
    else isMuted == false
        TerminalNotificationStore->>TerminalNotificationStore: normal notification processing
    end
Loading

Comments Outside Diff (1)

  1. Sources/ContentView.swift, line 11660-11667 (link)

    P2 No singular/plural label variant for multi-select

    Every other context menu action uses contextMenuLabel(multi:single:isMulti:) to differentiate "Workspace" vs "Workspaces" when multiple tabs are targeted (e.g. "Close Workspace" / "Close Workspaces", "Pin Workspace" / "Pin Workspaces"). The mute button always shows "Mute Notifications" regardless of whether one or many workspaces are selected, breaking this established pattern.

    Consider adding singular/plural localization keys and using contextMenuLabel:

    let muteLabel = shouldMute
        ? contextMenuLabel(
            multi: String(localized: "contextMenu.muteNotifications.multi", defaultValue: "Mute Workspace Notifications"),
            single: String(localized: "contextMenu.muteNotifications", defaultValue: "Mute Notifications"),
            isMulti: isMulti)
        : contextMenuLabel(
            multi: String(localized: "contextMenu.unmuteNotifications.multi", defaultValue: "Unmute Workspace Notifications"),
            single: String(localized: "contextMenu.unmuteNotifications", defaultValue: "Unmute Notifications"),
            isMulti: isMulti)

    Alternatively, add "Mute Workspace" / "Mute Workspaces" keys that parallel the existing noun-verb pattern.

    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!

Reviews (1): Last reviewed commit: "feat: per-workspace notification mute to..." | Re-trigger Greptile

Copy link

@cubic-dev-ai cubic-dev-ai bot left a comment

Choose a reason for hiding this comment

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

No issues found across 5 files

Comment on lines 10855 to 10896
enum SidebarPathFormatter {
static let homeDirectoryPath: String = FileManager.default.homeDirectoryForCurrentUser.path

/// Maximum number of path segments shown before adding a leading ellipsis.
/// e.g. `~/a/b/c/d` → `…/c/d` when maxSegments == 2.
static let maxDisplaySegments: Int = 3

static func shortenedPath(
_ path: String,
homeDirectoryPath: String = Self.homeDirectoryPath
) -> String {
let trimmed = path.trimmingCharacters(in: .whitespacesAndNewlines)
guard !trimmed.isEmpty else { return path }

// Replace home directory prefix with ~
let tildeReplaced: String
if trimmed == homeDirectoryPath {
return "~"
} else if trimmed.hasPrefix(homeDirectoryPath + "/") {
tildeReplaced = "~" + trimmed.dropFirst(homeDirectoryPath.count)
} else {
tildeReplaced = trimmed
}
if trimmed.hasPrefix(homeDirectoryPath + "/") {
return "~" + trimmed.dropFirst(homeDirectoryPath.count)

// Apply smart truncation: keep only the last maxDisplaySegments segments
// so paths with a long common prefix remain distinguishable in the sidebar.
// e.g. `~/Desktop/YOKE/Claude Code/Projects/Athlete Merch/nilclub`
// → `…/Projects/Athlete Merch/nilclub`
let segments = tildeReplaced.split(separator: "/", omittingEmptySubsequences: false)
// For a tilde-replaced path like `~/a/b/c`, split on "/" yields
// ["~", "a", "b", "c"]. The tilde token is always a single leading segment.
// For an absolute path like `/tmp/a/b`, split yields ["", "tmp", "a", "b"].
// We only truncate when there are strictly more segments than allowed.
let effectiveMax = maxDisplaySegments + 1 // +1 for the leading "~" or "" token
guard segments.count > effectiveMax else {
return tildeReplaced
}
return trimmed
let tail = segments.suffix(maxDisplaySegments).joined(separator: "/")
return "…/" + tail
}
}

Copy link

Choose a reason for hiding this comment

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

P2 Unrelated feature bundled into mute-toggle PR

The SidebarPathFormatter.shortenedPath smart-truncation rewrite (adding maxDisplaySegments, path ellipsis logic) is a completely separate UX feature from the per-workspace notification mute described in the PR title and description. Bundling it makes:

  • The PR scope harder to review in isolation
  • Bisecting harder if either feature regresses (a bisect lands on this commit for both changes simultaneously)
  • The changelog entry for the path-truncation feature effectively missing

The change is logically correct on its own — the segment-count math handles ~-prefixed and absolute paths properly — but it belongs in its own PR and PR description entry.

Copy link

@coderabbitai coderabbitai bot left a comment

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)
Sources/ContentView.swift (1)

11660-11693: ⚠️ Potential issue | 🟠 Major

Add a persistent mute indicator in the row.

This wires the toggle, but tab.isMuted is only used here in ContentView.swift, so after the menu closes there is still no visible sidebar state for muted workspaces. That misses the linked issue’s requested tab-level affordance.

🔕 Suggested follow-up near the pin/title cluster
                 if tab.isPinned {
                     Image(systemName: "pin.fill")
                         .font(.system(size: 9, weight: .semibold))
                         .foregroundColor(activeSecondaryColor(0.8))
                         .safeHelp(protectedWorkspaceTooltip)
                 }
+                if tab.isMuted {
+                    Image(systemName: "bell.slash.fill")
+                        .font(.system(size: 9, weight: .semibold))
+                        .foregroundColor(activeSecondaryColor(0.8))
+                }
 
                 Text(tab.title)
                     .font(.system(size: 12.5, weight: titleFontWeight))
                     .foregroundColor(activePrimaryTextColor)
+                    .opacity(tab.isMuted ? 0.72 : 1.0)
                     .lineLimit(1)
                     .truncationMode(.tail)
                     .layoutPriority(1)
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@Sources/ContentView.swift` around lines 11660 - 11693, The context menu
toggles mute by directly setting workspace.isMuted inside the Button(muteLabel)
closure but never updates the tab model/controller or the row UI, so the sidebar
shows no persistent mute indicator; change the mutation to use the tab manager
API (mirror setPinned pattern) — e.g., add/update a method like
tabManager.setMuted(_ tab: Tab, muted: Bool) and call that for each target id
instead of assigning workspace.isMuted directly, and ensure the row view that
renders the sidebar (the Tab/Workspace row component used by ContentView.swift)
reads Tab.isMuted (observable) and shows a mute icon/affordance so the muted
state persists and is visible after the menu closes.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@Sources/TerminalNotificationStore.swift`:
- Around line 895-896: The mute check uses AppDelegate.shared?.tabManager which
only reflects the active window and can miss muted tabs in other windows; change
the lookup to use AppDelegate.shared?.workspaceFor(tabId: tabId) (or equivalent
global workspace resolver) to find the workspace/tab by tabId across all
mainWindowContexts and then check its isMuted property before returning; update
the conditional that references
AppDelegate.shared?.tabManager?.tabs.first(where: { $0.id == tabId })?.isMuted
to resolve the workspace/tab via workspaceFor(tabId:) and use that result's
isMuted.

---

Outside diff comments:
In `@Sources/ContentView.swift`:
- Around line 11660-11693: The context menu toggles mute by directly setting
workspace.isMuted inside the Button(muteLabel) closure but never updates the tab
model/controller or the row UI, so the sidebar shows no persistent mute
indicator; change the mutation to use the tab manager API (mirror setPinned
pattern) — e.g., add/update a method like tabManager.setMuted(_ tab: Tab, muted:
Bool) and call that for each target id instead of assigning workspace.isMuted
directly, and ensure the row view that renders the sidebar (the Tab/Workspace
row component used by ContentView.swift) reads Tab.isMuted (observable) and
shows a mute icon/affordance so the muted state persists and is visible after
the menu closes.

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: e635ce85-f2fc-4652-a084-b819cae802d7

📥 Commits

Reviewing files that changed from the base of the PR and between 99ca3c9 and 53fcc1b.

📒 Files selected for processing (5)
  • Resources/Localizable.xcstrings
  • Sources/ContentView.swift
  • Sources/SessionPersistence.swift
  • Sources/TerminalNotificationStore.swift
  • Sources/Workspace.swift

anthhub added 3 commits March 25, 2026 20:55
- TerminalNotificationStore: use tabManagerFor(tabId:) to search all windows
  instead of the global key-window tabManager, so mute takes effect for tabs
  in non-key windows
- ContentView TabItemView ==: add lhs.tab.isMuted == rhs.tab.isMuted so that
  toggling mute invalidates the .equatable() guard and the context menu label
  updates correctly
Add lhs.tab.isMuted == rhs.tab.isMuted to TabItemView == so that toggling
mute on a workspace invalidates the .equatable() guard and forces the context
menu label (Mute / Unmute Notifications) to re-render.
…ai#2148

PR manaflow-ai#2145 inadvertently included SidebarPathFormatter maxDisplaySegments
and shortenedPath truncation logic that belongs to PR manaflow-ai#2148
(sidebar path optimisation). This commit reverts ContentView.swift to
origin/main for SidebarPathFormatter while keeping only the mute-related
changes: TabItemView == guard, shouldMute/muteLabel vars, mute Button.
Copy link

@coderabbitai coderabbitai bot left a comment

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 the current code and only fix it if needed.

Inline comments:
In `@Sources/ContentView.swift`:
- Around line 11661-11664: The toggle wired in ContentView sets tab.isMuted but
TabItemView never reads or draws that state; update TabItemView (the view that
renders each tab row) to accept and/or observe tab.isMuted (or the Tab model)
and render a visible muted affordance in the row body (for example a speaker /
muted icon, badge, or dimmed text) when isMuted is true; ensure the view updates
when the property changes and update the row's accessibility label/tooltip to
reflect muted/unmuted so users can discover the status.

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: c907bd10-8d20-4794-86b7-19c87fce6075

📥 Commits

Reviewing files that changed from the base of the PR and between 53fcc1b and 523360f.

📒 Files selected for processing (2)
  • Sources/ContentView.swift
  • Sources/TerminalNotificationStore.swift
🚧 Files skipped from review as they are similar to previous changes (1)
  • Sources/TerminalNotificationStore.swift

Comment on lines +11661 to +11664
let shouldMute = !tab.isMuted
let muteLabel = shouldMute
? String(localized: "contextMenu.muteNotifications", defaultValue: "Mute Notifications")
: String(localized: "contextMenu.unmuteNotifications", defaultValue: "Unmute Notifications")
Copy link

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major

Muted workspaces still have no visible sidebar affordance.

This wires up the toggle, but TabItemView still never renders tab.isMuted, so once the menu closes there is no way to tell which workspace is muted. That misses the linked objective for a tab-level muted indicator.

💡 Suggested follow-up in the row body
                 if tab.isPinned {
                     Image(systemName: "pin.fill")
                         .font(.system(size: 9, weight: .semibold))
                         .foregroundColor(activeSecondaryColor(0.8))
                         .safeHelp(protectedWorkspaceTooltip)
                 }
+                if tab.isMuted {
+                    Image(systemName: "bell.slash.fill")
+                        .font(.system(size: 9, weight: .semibold))
+                        .foregroundColor(activeSecondaryColor(0.8))
+                }

                 Text(tab.title)
                     .font(.system(size: 12.5, weight: titleFontWeight))
                     .foregroundColor(activePrimaryTextColor)
+                    .opacity(tab.isMuted ? 0.6 : 1.0)
                     .lineLimit(1)

Also applies to: 11688-11694

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@Sources/ContentView.swift` around lines 11661 - 11664, The toggle wired in
ContentView sets tab.isMuted but TabItemView never reads or draws that state;
update TabItemView (the view that renders each tab row) to accept and/or observe
tab.isMuted (or the Tab model) and render a visible muted affordance in the row
body (for example a speaker / muted icon, badge, or dimmed text) when isMuted is
true; ensure the view updates when the property changes and update the row's
accessibility label/tooltip to reflect muted/unmuted so users can discover the
status.

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.

Feature request: per-workspace notification mute (context menu toggle)

1 participant