diff --git a/desktop/src/features/workspaces/workspaceUnreadObserver.test.mjs b/desktop/src/features/workspaces/workspaceUnreadObserver.test.mjs
index 4788bedbf2..06c8877122 100644
--- a/desktop/src/features/workspaces/workspaceUnreadObserver.test.mjs
+++ b/desktop/src/features/workspaces/workspaceUnreadObserver.test.mjs
@@ -12,6 +12,18 @@ const PUBKEY = "a".repeat(64);
 const OTHER = "b".repeat(64);
 const CHANNEL_ID = "channel-1";
 const THREAD_ROOT = "c".repeat(64);
+const THREAD_ROOT_2 = "d".repeat(64);
+
+const EMPTY_RELATIONSHIPS = {
+  participatedRootIds: new Set(),
+  followedRootIds: new Set(),
+  authoredRootIds: new Set(),
+  mutedRootIds: new Set(),
+};
+
+function readRelationships(overrides = {}) {
+  return () => ({ ...EMPTY_RELATIONSHIPS, ...overrides });
+}
 
 function event(overrides = {}) {
   return {
@@ -35,6 +47,15 @@ function relayFor(filters) {
   };
 }
 
+// Helper: encode a mutes payload as JSON (decryptMutes stub returns content as-is)
+function mutesContent(mutedIds) {
+  const channels = {};
+  for (const id of mutedIds) {
+    channels[id] = { muted: true, updatedAt: 1 };
+  }
+  return JSON.stringify({ version: 1, channels });
+}
+
 test("extractMemberChannelIds deduplicates d tags", () => {
   assert.deepEqual(
     extractMemberChannelIds([
@@ -103,6 +124,7 @@ test("extractHiddenDmIds reads h tags from latest visibility snapshot", () => {
 
 test("fetchWorkspaceUnread returns dot and mention count without total unread count", async () => {
   const relay = relayFor([
+    // 1. member events
     () => [
       event({
         tags: [
@@ -111,6 +133,7 @@ test("fetchWorkspaceUnread returns dot and mention count without total unread co
         ],
       }),
     ],
+    // 2. metadata events (parallel with visibility)
     () => [
       event({
         tags: [
@@ -119,8 +142,13 @@ test("fetchWorkspaceUnread returns dot and mention count without total unread co
         ],
       }),
     ],
+    // 3. visibility events (parallel with metadata)
     () => [],
+    // 4. read-state events (parallel with mutes)
     () => [],
+    // 5. mutes events (parallel with read-state)
+    () => [],
+    // 6. unread events
     () => [
       event({
         id: "unread".padEnd(64, "0"),
@@ -128,6 +156,7 @@ test("fetchWorkspaceUnread returns dot and mention count without total unread co
         tags: [["h", CHANNEL_ID]],
       }),
     ],
+    // 7. mention events
     () => [
       event({
         id: "mention".padEnd(64, "0"),
@@ -145,6 +174,8 @@ test("fetchWorkspaceUnread returns dot and mention count without total unread co
     pubkey: PUBKEY,
     nowSeconds: 100,
     decryptReadState: async (value) => value,
+    decryptMutes: async (value) => value,
+    readThreadRelationships: readRelationships(),
   });
 
   assert.deepEqual(result, { hasUnread: true, mentionCount: 1 });
@@ -173,6 +204,7 @@ test("fetchWorkspaceUnread ignores self-authored and read thread/message events"
   });
 
   const relay = relayFor([
+    // 1. member events
     () => [
       event({
         tags: [
@@ -181,6 +213,7 @@ test("fetchWorkspaceUnread ignores self-authored and read thread/message events"
         ],
       }),
     ],
+    // 2. metadata events (parallel with visibility)
     () => [
       event({
         tags: [
@@ -189,7 +222,9 @@ test("fetchWorkspaceUnread ignores self-authored and read thread/message events"
         ],
       }),
     ],
+    // 3. visibility events (parallel with metadata)
     () => [],
+    // 4. read-state events (parallel with mutes)
     () => [
       event({
         pubkey: PUBKEY,
@@ -208,7 +243,11 @@ test("fetchWorkspaceUnread ignores self-authored and read thread/message events"
         }),
       }),
     ],
+    // 5. mutes events (parallel with read-state)
+    () => [],
+    // 6. unread events
     () => [threadReply, selfMention],
+    // 7. mention events
     () => [threadReply, selfMention],
   ]);
 
@@ -217,6 +256,386 @@ test("fetchWorkspaceUnread ignores self-authored and read thread/message events"
     pubkey: PUBKEY,
     nowSeconds: 100,
     decryptReadState: async (value) => value,
+    decryptMutes: async (value) => value,
+    readThreadRelationships: readRelationships(),
+  });
+
+  assert.deepEqual(result, { hasUnread: false, mentionCount: 0 });
+});
+
+test("fetchWorkspaceUnread excludes muted-only channel — returns hasUnread:false mentionCount:0", async () => {
+  const MUTED_CHANNEL = "muted-channel-1";
+
+  const relay = relayFor([
+    // 1. member events — one muted channel
+    () => [
+      event({
+        tags: [
+          ["d", MUTED_CHANNEL],
+          ["p", PUBKEY],
+        ],
+      }),
+    ],
+    // 2. metadata events (parallel with visibility)
+    () => [
+      event({
+        tags: [
+          ["d", MUTED_CHANNEL],
+          ["t", "stream"],
+        ],
+      }),
+    ],
+    // 3. visibility events (parallel with metadata)
+    () => [],
+    // 4. read-state events (parallel with mutes)
+    () => [],
+    // 5. mutes events — MUTED_CHANNEL is muted
+    () => [
+      event({
+        pubkey: PUBKEY,
+        content: mutesContent([MUTED_CHANNEL]),
+      }),
+    ],
+    // No per-channel fetches should follow — muted channel is skipped
+  ]);
+
+  const result = await fetchWorkspaceUnread({
+    client: relay,
+    pubkey: PUBKEY,
+    nowSeconds: 100,
+    decryptReadState: async (value) => value,
+    decryptMutes: async (value) => value,
+    readThreadRelationships: readRelationships(),
+  });
+
+  assert.deepEqual(result, { hasUnread: false, mentionCount: 0 });
+});
+
+test("fetchWorkspaceUnread counts unmuted channel but skips muted channel", async () => {
+  const UNMUTED_CHANNEL = "channel-unmuted";
+  const MUTED_CHANNEL = "channel-muted";
+
+  const relay = relayFor([
+    // 1. member events — two channels
+    () => [
+      event({
+        tags: [
+          ["d", UNMUTED_CHANNEL],
+          ["d", MUTED_CHANNEL],
+          ["p", PUBKEY],
+        ],
+      }),
+    ],
+    // 2. metadata events (parallel with visibility)
+    () => [
+      event({
+        tags: [
+          ["d", UNMUTED_CHANNEL],
+          ["t", "stream"],
+        ],
+      }),
+      event({
+        tags: [
+          ["d", MUTED_CHANNEL],
+          ["t", "stream"],
+        ],
+      }),
+    ],
+    // 3. visibility events (parallel with metadata)
+    () => [],
+    // 4. read-state events (parallel with mutes)
+    () => [],
+    // 5. mutes events — only MUTED_CHANNEL is muted
+    () => [
+      event({
+        pubkey: PUBKEY,
+        content: mutesContent([MUTED_CHANNEL]),
+      }),
+    ],
+    // 6. unread events for UNMUTED_CHANNEL (muted channel loop iteration never fires)
+    () => [
+      event({
+        id: "unread".padEnd(64, "0"),
+        created_at: 20,
+        tags: [["h", UNMUTED_CHANNEL]],
+      }),
+    ],
+    // 7. mention events for UNMUTED_CHANNEL
+    () => [
+      event({
+        id: "mention".padEnd(64, "0"),
+        created_at: 30,
+        tags: [
+          ["h", UNMUTED_CHANNEL],
+          ["p", PUBKEY],
+        ],
+      }),
+    ],
+  ]);
+
+  const result = await fetchWorkspaceUnread({
+    client: relay,
+    pubkey: PUBKEY,
+    nowSeconds: 100,
+    decryptReadState: async (value) => value,
+    decryptMutes: async (value) => value,
+    readThreadRelationships: readRelationships(),
+  });
+
+  assert.deepEqual(result, { hasUnread: true, mentionCount: 1 });
+});
+
+test("fetchWorkspaceUnread treats decryption failure as empty mutes set", async () => {
+  const relay = relayFor([
+    // 1. member events
+    () => [
+      event({
+        tags: [
+          ["d", CHANNEL_ID],
+          ["p", PUBKEY],
+        ],
+      }),
+    ],
+    // 2. metadata events (parallel with visibility)
+    () => [
+      event({
+        tags: [
+          ["d", CHANNEL_ID],
+          ["t", "stream"],
+        ],
+      }),
+    ],
+    // 3. visibility events (parallel with metadata)
+    () => [],
+    // 4. read-state events (parallel with mutes)
+    () => [],
+    // 5. mutes events — present but decryption will throw
+    () => [
+      event({
+        pubkey: PUBKEY,
+        content: "corrupted-ciphertext",
+      }),
+    ],
+    // 6. unread events — channel is NOT muted (decryption failed → empty set)
+    () => [
+      event({
+        id: "unread".padEnd(64, "0"),
+        created_at: 20,
+        tags: [["h", CHANNEL_ID]],
+      }),
+    ],
+    // 7. mention events
+    () => [],
+  ]);
+
+  const result = await fetchWorkspaceUnread({
+    client: relay,
+    pubkey: PUBKEY,
+    nowSeconds: 100,
+    decryptReadState: async (value) => value,
+    decryptMutes: async () => {
+      throw new Error("decryption failed");
+    },
+    readThreadRelationships: readRelationships(),
+  });
+
+  // Channel counted as if no mutes
+  assert.deepEqual(result, { hasUnread: true, mentionCount: 0 });
+});
+
+test("fetchWorkspaceUnread treats absent mutes blob as empty mutes set", async () => {
+  const relay = relayFor([
+    // 1. member events
+    () => [
+      event({
+        tags: [
+          ["d", CHANNEL_ID],
+          ["p", PUBKEY],
+        ],
+      }),
+    ],
+    // 2. metadata events (parallel with visibility)
+    () => [
+      event({
+        tags: [
+          ["d", CHANNEL_ID],
+          ["t", "stream"],
+        ],
+      }),
+    ],
+    // 3. visibility events (parallel with metadata)
+    () => [],
+    // 4. read-state events (parallel with mutes)
+    () => [],
+    // 5. mutes events — none
+    () => [],
+    // 6. unread events
+    () => [
+      event({
+        id: "unread".padEnd(64, "0"),
+        created_at: 20,
+        tags: [["h", CHANNEL_ID]],
+      }),
+    ],
+    // 7. mention events
+    () => [],
+  ]);
+
+  const result = await fetchWorkspaceUnread({
+    client: relay,
+    pubkey: PUBKEY,
+    nowSeconds: 100,
+    decryptReadState: async (value) => value,
+    decryptMutes: async (value) => value,
+    readThreadRelationships: readRelationships(),
+  });
+
+  assert.deepEqual(result, { hasUnread: true, mentionCount: 0 });
+});
+
+// ── Thread-relevance gate tests ────────────────────────────────────────────
+
+function threadedReplyEvent(overrides = {}) {
+  return event({
+    id: overrides.id ?? "reply".padEnd(64, "0"),
+    created_at: overrides.created_at ?? 20,
+    pubkey: overrides.pubkey ?? OTHER,
+    tags: [
+      ["h", CHANNEL_ID],
+      ["e", THREAD_ROOT_2, "", "root"],
+      ["e", "parent".padEnd(64, "0"), "", "reply"],
+      ...(overrides.extraTags ?? []),
+    ],
+    ...overrides,
+  });
+}
+
+function baseRelay(unreadEvent, mutesPayload = null) {
+  return relayFor([
+    // 1. member events
+    () => [
+      event({
+        tags: [
+          ["d", CHANNEL_ID],
+          ["p", PUBKEY],
+        ],
+      }),
+    ],
+    // 2. metadata events (parallel with visibility)
+    () => [
+      event({
+        tags: [
+          ["d", CHANNEL_ID],
+          ["t", "stream"],
+        ],
+      }),
+    ],
+    // 3. visibility events (parallel with metadata)
+    () => [],
+    // 4. read-state events (parallel with mutes)
+    () => [],
+    // 5. mutes events
+    () =>
+      mutesPayload ? [event({ pubkey: PUBKEY, content: mutesPayload })] : [],
+    // 6. unread events — the single event under test
+    () => [unreadEvent],
+    // 7. mention events
+    () => [],
+  ]);
+}
+
+test("fetchWorkspaceUnread threaded reply in untracked root → hasUnread:false", async () => {
+  const relay = baseRelay(threadedReplyEvent());
+
+  const result = await fetchWorkspaceUnread({
+    client: relay,
+    pubkey: PUBKEY,
+    nowSeconds: 100,
+    decryptReadState: async (v) => v,
+    decryptMutes: async (v) => v,
+    // No root in any set → gate rejects the threaded reply
+    readThreadRelationships: readRelationships(),
+  });
+
+  assert.deepEqual(result, { hasUnread: false, mentionCount: 0 });
+});
+
+test("fetchWorkspaceUnread threaded reply in participatedRootIds → hasUnread:true", async () => {
+  const relay = baseRelay(threadedReplyEvent());
+
+  const result = await fetchWorkspaceUnread({
+    client: relay,
+    pubkey: PUBKEY,
+    nowSeconds: 100,
+    decryptReadState: async (v) => v,
+    decryptMutes: async (v) => v,
+    readThreadRelationships: readRelationships({
+      participatedRootIds: new Set([THREAD_ROOT_2]),
+    }),
+  });
+
+  assert.deepEqual(result, { hasUnread: true, mentionCount: 0 });
+});
+
+test("fetchWorkspaceUnread #p-mention reply in untracked root → hasUnread:true (mention overrides)", async () => {
+  // A @mention of the user bypasses the follow/participation gate
+  const relay = baseRelay(
+    threadedReplyEvent({
+      id: "mention-reply".padEnd(64, "0"),
+      extraTags: [["p", PUBKEY]],
+    }),
+  );
+
+  const result = await fetchWorkspaceUnread({
+    client: relay,
+    pubkey: PUBKEY,
+    nowSeconds: 100,
+    decryptReadState: async (v) => v,
+    decryptMutes: async (v) => v,
+    readThreadRelationships: readRelationships(),
+  });
+
+  assert.deepEqual(result, { hasUnread: true, mentionCount: 0 });
+});
+
+test("fetchWorkspaceUnread top-level post → hasUnread:true (no thread gate)", async () => {
+  // Top-level posts have no parentId — shouldNotifyForEvent returns true
+  const relay = baseRelay(
+    event({
+      id: "toplevel".padEnd(64, "0"),
+      created_at: 20,
+      tags: [["h", CHANNEL_ID]],
+    }),
+  );
+
+  const result = await fetchWorkspaceUnread({
+    client: relay,
+    pubkey: PUBKEY,
+    nowSeconds: 100,
+    decryptReadState: async (v) => v,
+    decryptMutes: async (v) => v,
+    readThreadRelationships: readRelationships(),
+  });
+
+  assert.deepEqual(result, { hasUnread: true, mentionCount: 0 });
+});
+
+test("fetchWorkspaceUnread threaded reply whose root is in mutedRootIds → hasUnread:false", async () => {
+  const relay = baseRelay(
+    threadedReplyEvent({ id: "muted-reply".padEnd(64, "0") }),
+  );
+
+  const result = await fetchWorkspaceUnread({
+    client: relay,
+    pubkey: PUBKEY,
+    nowSeconds: 100,
+    decryptReadState: async (v) => v,
+    decryptMutes: async (v) => v,
+    // Root is participated but also muted — mute wins
+    readThreadRelationships: readRelationships({
+      participatedRootIds: new Set([THREAD_ROOT_2]),
+      mutedRootIds: new Set([THREAD_ROOT_2]),
+    }),
   });
 
   assert.deepEqual(result, { hasUnread: false, mentionCount: 0 });
diff --git a/desktop/src/features/workspaces/workspaceUnreadObserver.ts b/desktop/src/features/workspaces/workspaceUnreadObserver.ts
index 8e8ea5e70c..2eb28a9e3c 100644
--- a/desktop/src/features/workspaces/workspaceUnreadObserver.ts
+++ b/desktop/src/features/workspaces/workspaceUnreadObserver.ts
@@ -1,3 +1,4 @@
+import { makeRootIdStore } from "@/features/channels/unreadRootIdStore";
 import { DM_NOTIFIABLE_EVENT_KINDS } from "@/features/channels/isDmNotifiableKind";
 import { mergeReadStateEvents } from "@/features/channels/readState/readStateSnapshot";
 import {
@@ -8,13 +9,20 @@ import {
   getThreadReference,
   isBroadcastReply,
 } from "@/features/messages/lib/threading";
+import { shouldNotifyForEvent } from "@/features/notifications/lib/shouldNotify";
+import {
+  mutedChannelIdsFromStore,
+  parseMutePayload,
+} from "@/features/sidebar/lib/channelMutesStorage";
 import type { Workspace } from "@/features/workspaces/types";
 import { withReadOnlyRelayClient } from "@/shared/api/readOnlyRelayClient";
 import type { RelaySubscriptionFilter } from "@/shared/api/relayClientShared";
+import { nip44DecryptFromSelf } from "@/shared/api/tauri";
 import type { ChannelType, RelayEvent } from "@/shared/api/types";
 import {
   CHANNEL_MESSAGE_EVENT_KINDS,
   HOME_MENTION_EVENT_KINDS,
+  KIND_CHANNEL_MUTES,
   KIND_DM_VISIBILITY,
   KIND_READ_STATE,
 } from "@/shared/constants/kinds";
@@ -22,6 +30,53 @@ import {
 const KIND_NIP29_GROUP_METADATA = 39000;
 const KIND_NIP29_GROUP_MEMBERS = 39002;
 
+// Stores for thread-relationship sets. Keyed by pubkey only (no relay/workspace),
+// so they read correctly from the same origin regardless of which workspace is active.
+const participationStore = makeRootIdStore("buzz-thread-participation.v1");
+const authoredStore = makeRootIdStore("buzz-thread-authored.v1");
+const mutedRootsStore = makeRootIdStore("buzz-thread-muted.v1");
+const FOLLOWS_STORAGE_KEY_PREFIX = "buzz-thread-follows.v1";
+
+export type ThreadRelationships = {
+  participatedRootIds: ReadonlySet<string>;
+  followedRootIds: ReadonlySet<string>;
+  authoredRootIds: ReadonlySet<string>;
+  mutedRootIds: ReadonlySet<string>;
+};
+
+function readFollowedRootIds(pubkey: string): Set<string> {
+  try {
+    const raw = window.localStorage.getItem(
+      `${FOLLOWS_STORAGE_KEY_PREFIX}:${pubkey}`,
+    );
+    if (!raw) return new Set();
+    const parsed = JSON.parse(raw);
+    if (!Array.isArray(parsed)) return new Set();
+    const ids = new Set<string>();
+    for (const entry of parsed) {
+      if (
+        typeof entry === "object" &&
+        entry !== null &&
+        typeof entry.rootId === "string"
+      ) {
+        ids.add(entry.rootId);
+      }
+    }
+    return ids;
+  } catch {
+    return new Set();
+  }
+}
+
+function defaultReadThreadRelationships(pubkey: string): ThreadRelationships {
+  return {
+    participatedRootIds: participationStore.read(pubkey),
+    followedRootIds: readFollowedRootIds(pubkey),
+    authoredRootIds: authoredStore.read(pubkey),
+    mutedRootIds: mutedRootsStore.read(pubkey),
+  };
+}
+
 const MEMBER_CHANNEL_LIMIT = 1000;
 const METADATA_LIMIT = 1000;
 const UNREAD_EXISTENCE_LIMIT = 50;
@@ -98,32 +153,68 @@ export async function fetchWorkspaceUnread(args: {
   pubkey: string;
   nowSeconds?: number;
   decryptReadState?: (ciphertext: string) => Promise<string>;
+  decryptMutes?: (ciphertext: string) => Promise<string>;
+  readThreadRelationships?: (pubkey: string) => ThreadRelationships;
 }): Promise<WorkspaceUnreadObserverResult> {
   const { client, pubkey } = args;
   const normalizedPubkey = pubkey.toLowerCase();
   const nowSeconds = args.nowSeconds ?? Math.floor(Date.now() / 1_000);
+  const decryptMutes = args.decryptMutes ?? nip44DecryptFromSelf;
+  const readRelationships =
+    args.readThreadRelationships ?? defaultReadThreadRelationships;
 
   const channels = await fetchObservedChannels(client, pubkey);
   if (channels.length === 0) {
     return { hasUnread: false, mentionCount: 0 };
   }
-  const readStateEvents = await client.fetchEvents({
-    kinds: [KIND_READ_STATE],
-    authors: [pubkey],
-    "#t": ["read-state"],
-    since: nowSeconds - READ_STATE_HORIZON_SECONDS,
-    limit: READ_STATE_FETCH_LIMIT,
-  });
+
+  const [readStateEvents, mutesEvents] = await Promise.all([
+    client.fetchEvents({
+      kinds: [KIND_READ_STATE],
+      authors: [pubkey],
+      "#t": ["read-state"],
+      since: nowSeconds - READ_STATE_HORIZON_SECONDS,
+      limit: READ_STATE_FETCH_LIMIT,
+    }),
+    client.fetchEvents({
+      kinds: [KIND_CHANNEL_MUTES],
+      authors: [pubkey],
+      "#d": ["channel-mutes"],
+      limit: 1,
+    }),
+  ]);
+
   const readState = await mergeReadStateEvents(
     readStateEvents,
     pubkey,
     args.decryptReadState,
   );
 
+  let mutedIds = new Set<string>();
+  if (mutesEvents.length > 0) {
+    try {
+      const plaintext = await decryptMutes(mutesEvents[0].content);
+      const store = parseMutePayload(JSON.parse(plaintext));
+      if (store) {
+        mutedIds = mutedChannelIdsFromStore(store);
+      }
+    } catch {
+      // decryption failure → treat as empty mutes set
+    }
+  }
+
+  const {
+    participatedRootIds,
+    followedRootIds,
+    authoredRootIds,
+    mutedRootIds,
+  } = readRelationships(normalizedPubkey);
+
   let hasUnread = false;
   let mentionCount = 0;
 
   for (const channel of channels) {
+    if (mutedIds.has(channel.id)) continue;
     const readAt = readState.get(channel.id) ?? null;
     const since = readAt === null ? 0 : readAt + 1;
     const kinds = unreadKindsForChannel(channel.channelType);
@@ -150,8 +241,17 @@ export async function fetchWorkspaceUnread(args: {
     ]);
 
     if (!hasUnread) {
-      hasUnread = unreadEvents.some((event) =>
-        isUnreadExternalEvent(event, readState, readAt, normalizedPubkey),
+      hasUnread = unreadEvents.some(
+        (event) =>
+          isUnreadExternalEvent(event, readState, readAt, normalizedPubkey) &&
+          shouldNotifyForEvent(event, normalizedPubkey, {
+            participatedRootIds,
+            followedRootIds,
+            authoredRootIds,
+            mutedRootIds,
+            mutedChannelIds: mutedIds,
+            channelId: channel.id,
+          }),
       );
     }
 
