diff --git a/desktop/playwright.config.ts b/desktop/playwright.config.ts
index 7e22fb77c2..497f6d69f3 100644
--- a/desktop/playwright.config.ts
+++ b/desktop/playwright.config.ts
@@ -41,6 +41,8 @@ export default defineConfig({
         "**/identity-archive.spec.ts",
         "**/identity-archive-hide.spec.ts",
         "**/relay-connectivity-screenshots.spec.ts",
+        "**/unread-pill-screenshots.spec.ts",
+        "**/thread-unread-screenshots.spec.ts",
         "**/animated-avatar-screenshots.spec.ts",
       ],
       use: {
diff --git a/desktop/src/app/AppShell.helpers.ts b/desktop/src/app/AppShell.helpers.ts
new file mode 100644
index 0000000000..8ce0b8ca19
--- /dev/null
+++ b/desktop/src/app/AppShell.helpers.ts
@@ -0,0 +1,92 @@
+import type { DesktopNotificationTarget } from "@/features/notifications/lib/desktop";
+import type { SearchHit } from "@/shared/api/types";
+
+export type AppView =
+  | "home"
+  | "channel"
+  | "agents"
+  | "workflows"
+  | "pulse"
+  | "projects";
+
+const WINDOW_DRAG_HANDLE_HEIGHT = 44;
+const WINDOW_DRAG_INTERACTIVE_SELECTOR =
+  'button, a, input, textarea, select, [role="button"], [contenteditable="true"]';
+
+export function isWindowDragHandleEvent(event: MouseEvent | PointerEvent) {
+  if (event.clientY > WINDOW_DRAG_HANDLE_HEIGHT) {
+    return false;
+  }
+
+  const target = event.target;
+  return !(
+    target instanceof Element &&
+    target.closest(WINDOW_DRAG_INTERACTIVE_SELECTOR)
+  );
+}
+
+export function toSearchHit(
+  target: DesktopNotificationTarget,
+): SearchHit | null {
+  if (!target.eventId) {
+    return null;
+  }
+
+  return {
+    eventId: target.eventId,
+    content: target.content ?? "",
+    kind: target.kind ?? 9,
+    pubkey: target.pubkey ?? "",
+    channelId: target.channelId,
+    channelName: target.channelName ?? null,
+    createdAt: target.createdAt ?? Math.floor(Date.now() / 1_000),
+    score: 0,
+    threadRootId: target.threadRootId ?? null,
+  };
+}
+
+export function deriveShellRoute(pathname: string): {
+  selectedChannelId: string | null;
+  selectedView: AppView;
+} {
+  if (pathname.startsWith("/channels/")) {
+    const [, , rawChannelId] = pathname.split("/");
+    return {
+      selectedChannelId: rawChannelId ? decodeURIComponent(rawChannelId) : null,
+      selectedView: "channel",
+    };
+  }
+
+  if (pathname === "/agents") {
+    return {
+      selectedChannelId: null,
+      selectedView: "agents",
+    };
+  }
+
+  if (pathname === "/workflows" || pathname.startsWith("/workflows/")) {
+    return {
+      selectedChannelId: null,
+      selectedView: "workflows",
+    };
+  }
+
+  if (pathname === "/projects" || pathname.startsWith("/projects/")) {
+    return {
+      selectedChannelId: null,
+      selectedView: "projects",
+    };
+  }
+
+  if (pathname === "/pulse") {
+    return {
+      selectedChannelId: null,
+      selectedView: "pulse",
+    };
+  }
+
+  return {
+    selectedChannelId: null,
+    selectedView: "home",
+  };
+}
diff --git a/desktop/src/app/AppShell.tsx b/desktop/src/app/AppShell.tsx
index 84c101c68f..85904fde0e 100644
--- a/desktop/src/app/AppShell.tsx
+++ b/desktop/src/app/AppShell.tsx
@@ -3,6 +3,11 @@ import { getCurrentWindow } from "@tauri-apps/api/window";
 import { useQueryClient } from "@tanstack/react-query";
 import { Outlet, useLocation } from "@tanstack/react-router";
 
+import {
+  deriveShellRoute,
+  isWindowDragHandleEvent,
+  toSearchHit,
+} from "@/app/AppShell.helpers";
 import { AppShellProvider } from "@/app/AppShellContext";
 import {
   AppShellOverlays,
@@ -79,99 +84,11 @@ import { useMessageDeepLinks } from "@/shared/useMessageDeepLinks";
 import { ConnectionBanner } from "@/shared/ui/ConnectionBanner";
 import { SidebarInset, SidebarProvider } from "@/shared/ui/sidebar";
 
-type AppView =
-  | "home"
-  | "channel"
-  | "agents"
-  | "workflows"
-  | "pulse"
-  | "projects";
-
 const LazySettingsScreen = React.lazy(async () => {
   const module = await import("@/features/settings/ui/SettingsScreen");
   return { default: module.SettingsScreen };
 });
 
-const WINDOW_DRAG_HANDLE_HEIGHT = 44;
-const WINDOW_DRAG_INTERACTIVE_SELECTOR =
-  'button, a, input, textarea, select, [role="button"], [contenteditable="true"]';
-
-function isWindowDragHandleEvent(event: MouseEvent | PointerEvent) {
-  if (event.clientY > WINDOW_DRAG_HANDLE_HEIGHT) {
-    return false;
-  }
-
-  const target = event.target;
-  return !(
-    target instanceof Element &&
-    target.closest(WINDOW_DRAG_INTERACTIVE_SELECTOR)
-  );
-}
-
-function toSearchHit(target: DesktopNotificationTarget): SearchHit | null {
-  if (!target.eventId) {
-    return null;
-  }
-
-  return {
-    eventId: target.eventId,
-    content: target.content ?? "",
-    kind: target.kind ?? 9,
-    pubkey: target.pubkey ?? "",
-    channelId: target.channelId,
-    channelName: target.channelName ?? null,
-    createdAt: target.createdAt ?? Math.floor(Date.now() / 1_000),
-    score: 0,
-    threadRootId: target.threadRootId ?? null,
-  };
-}
-
-function deriveShellRoute(pathname: string): {
-  selectedChannelId: string | null;
-  selectedView: AppView;
-} {
-  if (pathname.startsWith("/channels/")) {
-    const [, , rawChannelId] = pathname.split("/");
-    return {
-      selectedChannelId: rawChannelId ? decodeURIComponent(rawChannelId) : null,
-      selectedView: "channel",
-    };
-  }
-
-  if (pathname === "/agents") {
-    return {
-      selectedChannelId: null,
-      selectedView: "agents",
-    };
-  }
-
-  if (pathname === "/workflows" || pathname.startsWith("/workflows/")) {
-    return {
-      selectedChannelId: null,
-      selectedView: "workflows",
-    };
-  }
-
-  if (pathname === "/projects" || pathname.startsWith("/projects/")) {
-    return {
-      selectedChannelId: null,
-      selectedView: "projects",
-    };
-  }
-
-  if (pathname === "/pulse") {
-    return {
-      selectedChannelId: null,
-      selectedView: "pulse",
-    };
-  }
-
-  return {
-    selectedChannelId: null,
-    selectedView: "home",
-  };
-}
-
 export function AppShell() {
   useWebviewZoomShortcuts();
 
@@ -373,30 +290,39 @@ export function AppShell() {
     highPriorityUnreadChannelIds,
     getEffectiveTimestamp: getChannelReadAt,
     readStateVersion,
+    setContextParentResolver,
     participatedRootIds,
     authoredRootIds,
     threadActivityItems,
     mutedRootIds,
     muteThread,
     unmuteThread,
-  } = useUnreadChannels(
-    sidebarChannels,
-    activeChannel,
-    // Wait for ChannelScreen to report the latest loaded message before
-    // advancing unread state for the active channel.
-    null,
-    {
-      pubkey: identityQuery.data?.pubkey,
-      relayClient,
-      currentPubkey: identityQuery.data?.pubkey,
-      mutedChannelIds,
-      notifyForActiveChannel: notificationSettings.settings.notifyWhileViewing,
-      onChannelMessage: handleChannelNotification,
-      onDmMessage: handleDmNotification,
-      onLiveMention: refetchHomeFeedOnLiveMention,
-      onThreadReplyDesktopNotification: handleThreadReplyDesktopNotification,
-      followedRootIds,
+  } = useUnreadChannels(sidebarChannels, activeChannel, {
+    pubkey: identityQuery.data?.pubkey,
+    relayClient,
+    currentPubkey: identityQuery.data?.pubkey,
+    mutedChannelIds,
+    notifyForActiveChannel: notificationSettings.settings.notifyWhileViewing,
+    onChannelMessage: handleChannelNotification,
+    onDmMessage: handleDmNotification,
+    onLiveMention: refetchHomeFeedOnLiveMention,
+    onThreadReplyDesktopNotification: handleThreadReplyDesktopNotification,
+    followedRootIds,
+  });
+
+  const getThreadReadAt = React.useCallback(
+    (rootId: string) => getChannelReadAt(`thread:${rootId}`),
+    [getChannelReadAt],
+  );
+
+  const markThreadRead = React.useCallback(
+    (rootId: string, timestamp: number) => {
+      markChannelRead(
+        `thread:${rootId}`,
+        new Date(timestamp * 1_000).toISOString(),
+      );
     },
+    [markChannelRead],
   );
 
   // Badge count is computed here (rather than inside useHomeFeedNotifications)
@@ -743,7 +669,10 @@ export function AppShell() {
               setIsChannelManagementOpen(true);
             },
             getChannelReadAt,
+            getThreadReadAt,
+            markThreadRead,
             readStateVersion,
+            setContextParentResolver,
             followThread: handleFollowThread,
             unfollowThread: handleUnfollowThread,
             isFollowingThread,
diff --git a/desktop/src/app/AppShellContext.tsx b/desktop/src/app/AppShellContext.tsx
index 1df855c66f..bd27e3019f 100644
--- a/desktop/src/app/AppShellContext.tsx
+++ b/desktop/src/app/AppShellContext.tsx
@@ -1,4 +1,5 @@
 import * as React from "react";
+import type { ContextParentResolver } from "@/features/channels/readState/readStateManager";
 import type { ThreadActivityItem } from "@/features/channels/useUnreadChannels";
 
 type AppShellContextValue = {
@@ -6,6 +7,7 @@ type AppShellContextValue = {
   markChannelRead: (
     channelId: string,
     readAt: string | null | undefined,
+    options?: { topLevelOnly?: boolean },
   ) => void;
   markChannelUnread: (channelId: string) => void;
   openCreateChannel: () => void;
@@ -14,9 +16,17 @@ type AppShellContextValue = {
   // when unknown. Backed by the single AppShell-mounted ReadStateManager so
   // every surface (sidebar, home, badges) projects from the same source.
   getChannelReadAt: (channelId: string) => number | null;
+  // Thread read frontier as unix-seconds timestamp, or null when never read.
+  // Uses `thread:<rootId>` context keys in the same ReadStateManager.
+  getThreadReadAt: (rootId: string) => number | null;
+  // Advance the thread read frontier to the given unix-seconds timestamp.
+  markThreadRead: (rootId: string, timestamp: number) => void;
   // Bump-counter that invalidates whenever the read marker changes. Include
   // in memo deps that consume getChannelReadAt.
   readStateVersion: number;
+  // Inject the thread→channel parent resolver derived from the event graph
+  // (NIP-RS hierarchical frontier). Set by the active channel surface.
+  setContextParentResolver: (resolver: ContextParentResolver | null) => void;
   followThread: (rootId: string) => void;
   unfollowThread: (rootId: string) => void;
   isFollowingThread: (rootId: string) => boolean;
@@ -33,7 +43,10 @@ const AppShellContext = React.createContext<AppShellContextValue>({
   openCreateChannel: () => {},
   openChannelManagement: () => {},
   getChannelReadAt: () => null,
+  getThreadReadAt: () => null,
+  markThreadRead: () => {},
   readStateVersion: 0,
+  setContextParentResolver: () => {},
   followThread: () => {},
   unfollowThread: () => {},
   isFollowingThread: () => false,
diff --git a/desktop/src/features/channels/lib/subtreeCreatedAt.test.mjs b/desktop/src/features/channels/lib/subtreeCreatedAt.test.mjs
new file mode 100644
index 0000000000..1239003fef
--- /dev/null
+++ b/desktop/src/features/channels/lib/subtreeCreatedAt.test.mjs
@@ -0,0 +1,188 @@
+import assert from "node:assert/strict";
+import test from "node:test";
+
+import { computeThreadUnreadMarker } from "../../messages/lib/unreadMarker.ts";
+import {
+  directRepliesMaxCreatedAt,
+  subtreeMaxCreatedAt,
+} from "./subtreeCreatedAt.ts";
+
+// Tree:    w(100)
+//          ├── deep1(400) ── deep2(500)
+//          └── sib(300)
+// `deep1` is the deep branch (subtree-max 500); `sib` is a shallower sibling
+// whose only reply (300) is chronologically older than the deep tail.
+function fixture() {
+  const directReplyIdsByParentId = new Map([
+    ["w", ["deep1", "sib"]],
+    ["deep1", ["deep2"]],
+  ]);
+  const createdAtByMessageId = new Map([
+    ["w", 100],
+    ["deep1", 400],
+    ["deep2", 500],
+    ["sib", 300],
+  ]);
+  const replies = [
+    { id: "sib", createdAt: 300 },
+    { id: "deep1", createdAt: 400 },
+    { id: "deep2", createdAt: 500 },
+  ];
+  return { directReplyIdsByParentId, createdAtByMessageId, replies };
+}
+
+test("subtreeMaxCreatedAt_branchWithDescendants_returnsDeepestCreatedAt", () => {
+  const { directReplyIdsByParentId, createdAtByMessageId } = fixture();
+
+  const result = subtreeMaxCreatedAt(
+    "deep1",
+    directReplyIdsByParentId,
+    createdAtByMessageId,
+  );
+
+  // Includes the descendant deep2(500), not just deep1's own 400.
+  assert.equal(result, 500);
+});
+
+test("subtreeMaxCreatedAt_leafBranch_returnsOwnCreatedAt", () => {
+  const { directReplyIdsByParentId, createdAtByMessageId } = fixture();
+
+  const result = subtreeMaxCreatedAt(
+    "sib",
+    directReplyIdsByParentId,
+    createdAtByMessageId,
+  );
+
+  assert.equal(result, 300);
+});
+
+test("subtreeMaxCreatedAt_absentMessage_returnsNull", () => {
+  const { directReplyIdsByParentId, createdAtByMessageId } = fixture();
+
+  const result = subtreeMaxCreatedAt(
+    "ghost",
+    directReplyIdsByParentId,
+    createdAtByMessageId,
+  );
+
+  // Null signals the caller to skip the read-state write.
+  assert.equal(result, null);
+});
+
+// Invariant 3: expanding the deep branch advances the single monotonic frontier
+// to the branch subtree-max (500), which consumes the chronologically-older
+// unexpanded sibling (300) too. This is the accepted single-frontier semantic.
+test("expandDeepBranch_advancesFrontierToSubtreeMax_consumesOlderSibling", () => {
+  const { directReplyIdsByParentId, createdAtByMessageId, replies } = fixture();
+
+  const frontier = subtreeMaxCreatedAt(
+    "deep1",
+    directReplyIdsByParentId,
+    createdAtByMessageId,
+  );
+  const marker = computeThreadUnreadMarker(replies, frontier);
+
+  assert.equal(frontier, 500);
+  // Everything at or below 500 is read — including sib(300), never expanded.
+  assert.equal(marker.firstUnreadReplyId, null);
+  assert.equal(marker.unreadCount, 0);
+});
+
+// directRepliesMaxCreatedAt covers the head and its DIRECT replies only — it
+// must NOT descend into deeper branches. Here w's direct replies are deep1(400)
+// and sib(300); deep2(500) is a grandchild and must be excluded.
+test("directRepliesMaxCreatedAt_excludesDeeperDescendants", () => {
+  const { directReplyIdsByParentId, createdAtByMessageId } = fixture();
+
+  const result = directRepliesMaxCreatedAt(
+    "w",
+    directReplyIdsByParentId,
+    createdAtByMessageId,
+  );
+
+  // max(w=100, deep1=400, sib=300) = 400 — deep2(500) is NOT counted.
+  assert.equal(result, 400);
+});
+
+test("directRepliesMaxCreatedAt_noReplies_returnsOwnCreatedAt", () => {
+  const { directReplyIdsByParentId, createdAtByMessageId } = fixture();
+
+  const result = directRepliesMaxCreatedAt(
+    "sib",
+    directReplyIdsByParentId,
+    createdAtByMessageId,
+  );
+
+  assert.equal(result, 300);
+});
+
+test("directRepliesMaxCreatedAt_absentMessage_returnsNull", () => {
+  const { directReplyIdsByParentId, createdAtByMessageId } = fixture();
+
+  const result = directRepliesMaxCreatedAt(
+    "ghost",
+    directReplyIdsByParentId,
+    createdAtByMessageId,
+  );
+
+  assert.equal(result, null);
+});
+
+// Invariant 2: opening a thread advances the frontier to the visible direct
+// replies' max, consuming them (their channel badge clears), while a deeper
+// collapsed branch stays unread until expanded. The channel badge counts the
+// head's DIRECT replies, so we assert against those.
+test("openThread_frontierAtDirectRepliesMax_consumesVisible_keepsDeeperUnread", () => {
+  const { directReplyIdsByParentId, createdAtByMessageId } = fixture();
+
+  const openFrontier = directRepliesMaxCreatedAt(
+    "w",
+    directReplyIdsByParentId,
+    createdAtByMessageId,
+  );
+
+  // The channel badge is computed over the head's direct replies.
+  const directReplies = [
+    { id: "deep1", createdAt: 400 },
+    { id: "sib", createdAt: 300 },
+  ];
+  const channelBadge = computeThreadUnreadMarker(directReplies, openFrontier);
+
+  assert.equal(openFrontier, 400);
+  // Both visible direct replies are at/below 400 → channel badge clears.
+  assert.equal(channelBadge.unreadCount, 0);
+
+  // But the deeper collapsed branch deep2(500) is still unread until expanded.
+  const deepBranch = [{ id: "deep2", createdAt: 500 }];
+  const deepUnread = computeThreadUnreadMarker(deepBranch, openFrontier);
+  assert.equal(deepUnread.unreadCount, 1);
+});
+
+// Invariant 1: the session divider is computed from the open-time frontier
+// SNAPSHOT, the badge/consume from the LIVE frontier. After expand advances the
+// live frontier to the subtree-max (500), the two clocks deliberately diverge:
+// the live frontier reports everything consumed, while the divider — read from
+// the frozen open-time snapshot (100) — stays pinned on the first unread reply.
+// This is what keeps the divider from moving mid-session when you expand.
+test("expandAfterOpen_dividerFromSnapshot_holds_whileLiveFrontierConsumes", () => {
+  const { directReplyIdsByParentId, createdAtByMessageId, replies } = fixture();
+
+  const openSnapshot = 100;
+  const liveFrontierAfterExpand = subtreeMaxCreatedAt(
+    "deep1",
+    directReplyIdsByParentId,
+    createdAtByMessageId,
+  );
+
+  const dividerFromSnapshot = computeThreadUnreadMarker(replies, openSnapshot);
+  const consumeFromLive = computeThreadUnreadMarker(
+    replies,
+    liveFrontierAfterExpand,
+  );
+
+  // Divider stays on the first unread, computed against the frozen snapshot...
+  assert.equal(dividerFromSnapshot.firstUnreadReplyId, "sib");
+  assert.equal(dividerFromSnapshot.unreadCount, 3);
+  // ...even though the live frontier has consumed the whole branch.
+  assert.equal(consumeFromLive.firstUnreadReplyId, null);
+});
diff --git a/desktop/src/features/channels/lib/subtreeCreatedAt.ts b/desktop/src/features/channels/lib/subtreeCreatedAt.ts
new file mode 100644
index 0000000000..9bd3deaa23
--- /dev/null
+++ b/desktop/src/features/channels/lib/subtreeCreatedAt.ts
@@ -0,0 +1,102 @@
+/**
+ * Newest `createdAt` across a thread branch: the message itself plus every
+ * descendant, walked through the direct-children adjacency map. Drilling into a
+ * branch advances the thread read frontier to this value, so it determines how
+ * far "expanding consumes unread" reaches. Returns null when the message is
+ * absent from the timeline so the caller can skip the read-state write.
+ */
+export function subtreeMaxCreatedAt(
+  messageId: string,
+  directReplyIdsByParentId: ReadonlyMap<string, string[]>,
+  createdAtByMessageId: ReadonlyMap<string, number>,
+): number | null {
+  const ownCreatedAt = createdAtByMessageId.get(messageId);
+  if (ownCreatedAt === undefined) return null;
+
+  let maxCreatedAt = ownCreatedAt;
+  const pendingIds = [...(directReplyIdsByParentId.get(messageId) ?? [])];
+  while (pendingIds.length > 0) {
+    const currentId = pendingIds.pop();
+    if (!currentId) continue;
+    const createdAt = createdAtByMessageId.get(currentId);
+    if (createdAt !== undefined && createdAt > maxCreatedAt) {
+      maxCreatedAt = createdAt;
+    }
+    pendingIds.push(...(directReplyIdsByParentId.get(currentId) ?? []));
+  }
+  return maxCreatedAt;
+}
+
+/**
+ * Newest `createdAt` across a thread head and its DIRECT replies only — the
+ * content visible the instant the panel opens, before any branch is expanded.
+ * Opening a thread advances the read frontier to this, mirroring channel-open
+ * parity: you see (and thus consume) the top-level replies on open, while
+ * deeper collapsed branches stay unread until drilled into. Returns null when
+ * the head is absent so the caller can skip the read-state write.
+ */
+export function directRepliesMaxCreatedAt(
+  messageId: string,
+  directReplyIdsByParentId: ReadonlyMap<string, string[]>,
+  createdAtByMessageId: ReadonlyMap<string, number>,
+): number | null {
+  const ownCreatedAt = createdAtByMessageId.get(messageId);
+  if (ownCreatedAt === undefined) return null;
+
+  let maxCreatedAt = ownCreatedAt;
+  for (const replyId of directReplyIdsByParentId.get(messageId) ?? []) {
+    const createdAt = createdAtByMessageId.get(replyId);
+    if (createdAt !== undefined && createdAt > maxCreatedAt) {
+      maxCreatedAt = createdAt;
+    }
+  }
+  return maxCreatedAt;
+}
+
+/** Minimal timeline shape the adjacency/createdAt builders read. */
+interface ReplyGraphMessage {
+  id: string;
+  parentId?: string | null;
+  createdAt: number;
+}
+
+/** Maps each parent message id to its direct-reply ids in timeline order. */
+export function buildDirectReplyIdsByParentId(
+  messages: readonly ReplyGraphMessage[],
+): Map<string, string[]> {
+  const map = new Map<string, string[]>();
+  for (const message of messages) {
+    if (!message.parentId) continue;
+    const currentReplies = map.get(message.parentId) ?? [];
+    currentReplies.push(message.id);
+    map.set(message.parentId, currentReplies);
+  }
+  return map;
+}
+
+/** Maps each message id to its `createdAt`. */
+export function buildCreatedAtByMessageId(
+  messages: readonly ReplyGraphMessage[],
+): Map<string, number> {
+  const map = new Map<string, number>();
+  for (const message of messages) {
+    map.set(message.id, message.createdAt);
+  }
+  return map;
+}
+
+/** Every descendant reply id under a message, walked breadth-first. */
+export function collectReplyDescendantIds(
+  messageId: string,
+  directReplyIdsByParentId: ReadonlyMap<string, string[]>,
+): string[] {
+  const descendantIds: string[] = [];
+  const pendingIds = [...(directReplyIdsByParentId.get(messageId) ?? [])];
+  while (pendingIds.length > 0) {
+    const currentId = pendingIds.pop();
+    if (!currentId) continue;
+    descendantIds.push(currentId);
+    pendingIds.push(...(directReplyIdsByParentId.get(currentId) ?? []));
+  }
+  return descendantIds;
+}
diff --git a/desktop/src/features/channels/lib/threadReplyUnreadCounts.test.mjs b/desktop/src/features/channels/lib/threadReplyUnreadCounts.test.mjs
new file mode 100644
index 0000000000..0c3c762449
--- /dev/null
+++ b/desktop/src/features/channels/lib/threadReplyUnreadCounts.test.mjs
@@ -0,0 +1,175 @@
+import assert from "node:assert/strict";
+import test from "node:test";
+
+import { computeThreadReplyUnreadCounts } from "./threadReplyUnreadCounts.ts";
+
+// Open thread "root":
+//   root(100)
+//   ├── a(200) ── a1(400)
+//   └── b(300) ── b1(500) ── b2(600)
+// Sibling thread "other" lives outside root's subtree.
+function fixture() {
+  return [
+    { id: "root", createdAt: 100, parentId: null },
+    { id: "a", createdAt: 200, parentId: "root" },
+    { id: "b", createdAt: 300, parentId: "root" },
+    { id: "a1", createdAt: 400, parentId: "a" },
+    { id: "b1", createdAt: 500, parentId: "b" },
+    { id: "b2", createdAt: 600, parentId: "b1" },
+    { id: "other", createdAt: 700, parentId: null },
+    { id: "other1", createdAt: 800, parentId: "other" },
+  ];
+}
+
+const ROOT_SUBTREE = ["a", "b", "a1", "b1", "b2"];
+
+test("computeThreadReplyUnreadCounts_collapsedBranch_countsUnreadDescendants", () => {
+  // Frontier 350: a1(400), b1(500), b2(600) are unread.
+  const counts = computeThreadReplyUnreadCounts({
+    timelineMessages: fixture(),
+    subtreeReplyIds: ROOT_SUBTREE,
+    visibleReplyIds: ["a", "b"],
+    expandedReplyIds: new Set(),
+    expandedSubtreeReplyIds: new Set(),
+    frontierSeconds: 350,
+  });
+  assert.equal(counts.get("a"), 1); // a1
+  assert.equal(counts.get("b"), 2); // b1, b2
+});
+
+test("computeThreadReplyUnreadCounts_expandedBranch_omitsBadge", () => {
+  const counts = computeThreadReplyUnreadCounts({
+    timelineMessages: fixture(),
+    subtreeReplyIds: ROOT_SUBTREE,
+    visibleReplyIds: ["a", "b"],
+    expandedReplyIds: new Set(["b"]),
+    expandedSubtreeReplyIds: new Set(["b1", "b2"]),
+    frontierSeconds: 350,
+  });
+  assert.equal(counts.get("a"), 1);
+  assert.equal(counts.has("b"), false);
+});
+
+test("computeThreadReplyUnreadCounts_expandedBranch_revealedChildNoStaleBadge", () => {
+  // Expand b: mark-read-on-expand reads b's whole subtree, and the panel now
+  // reveals collapsed child b1 (descendant b2 still unread vs the open-time
+  // frontier). b1 must carry NO badge — the expanded subtree is excluded.
+  const counts = computeThreadReplyUnreadCounts({
+    timelineMessages: fixture(),
+    subtreeReplyIds: ROOT_SUBTREE,
+    visibleReplyIds: ["a", "b", "b1"],
+    expandedReplyIds: new Set(["b"]),
+    expandedSubtreeReplyIds: new Set(["b1", "b2"]),
+    frontierSeconds: 350,
+  });
+  assert.equal(counts.get("a"), 1);
+  assert.equal(counts.has("b"), false);
+  assert.equal(counts.has("b1"), false);
+});
+
+test("computeThreadReplyUnreadCounts_descendantsButNoneUnread_noBadge", () => {
+  // Frontier 1000: nothing is newer, so no unread descendants anywhere.
+  const counts = computeThreadReplyUnreadCounts({
+    timelineMessages: fixture(),
+    subtreeReplyIds: ROOT_SUBTREE,
+    visibleReplyIds: ["a", "b"],
+    expandedReplyIds: new Set(),
+    expandedSubtreeReplyIds: new Set(),
+    frontierSeconds: 1000,
+  });
+  assert.equal(counts.size, 0);
+});
+
+test("computeThreadReplyUnreadCounts_nullFrontier_allDescendantsUnread", () => {
+  const counts = computeThreadReplyUnreadCounts({
+    timelineMessages: fixture(),
+    subtreeReplyIds: ROOT_SUBTREE,
+    visibleReplyIds: ["a", "b"],
+    expandedReplyIds: new Set(),
+    expandedSubtreeReplyIds: new Set(),
+    frontierSeconds: null,
+  });
+  assert.equal(counts.get("a"), 1); // a1
+  assert.equal(counts.get("b"), 2); // b1, b2
+});
+
+test("computeThreadReplyUnreadCounts_otherThreadReply_notCounted", () => {
+  // other1(800) is unread by frontier but outside root's subtree — its
+  // ancestor "other" is not a visible row here and must never be keyed.
+  const counts = computeThreadReplyUnreadCounts({
+    timelineMessages: fixture(),
+    subtreeReplyIds: ROOT_SUBTREE,
+    visibleReplyIds: ["a", "b", "other"],
+    expandedReplyIds: new Set(),
+    expandedSubtreeReplyIds: new Set(),
+    frontierSeconds: 350,
+  });
+  assert.equal(counts.has("other"), false);
+});
+
+test("computeThreadReplyUnreadCounts_onlyVisibleRowsKeyed", () => {
+  // b is collapsed and unread, but not in the visible set this render.
+  const counts = computeThreadReplyUnreadCounts({
+    timelineMessages: fixture(),
+    subtreeReplyIds: ROOT_SUBTREE,
+    visibleReplyIds: ["a"],
+    expandedReplyIds: new Set(),
+    expandedSubtreeReplyIds: new Set(),
+    frontierSeconds: 350,
+  });
+  assert.equal(counts.get("a"), 1);
+  assert.equal(counts.has("b"), false);
+});
+
+test("computeThreadReplyUnreadCounts_selfAuthored_skipsOwnReplies", () => {
+  // a1(400) is authored by "me" — should not count as unread.
+  // b1(500) and b2(600) are authored by "other" — should count.
+  const messages = [
+    { id: "root", createdAt: 100, parentId: null, pubkey: "other" },
+    { id: "a", createdAt: 200, parentId: "root", pubkey: "other" },
+    { id: "b", createdAt: 300, parentId: "root", pubkey: "other" },
+    { id: "a1", createdAt: 400, parentId: "a", pubkey: "me" },
+    { id: "b1", createdAt: 500, parentId: "b", pubkey: "other" },
+    { id: "b2", createdAt: 600, parentId: "b1", pubkey: "other" },
+  ];
+  const counts = computeThreadReplyUnreadCounts({
+    timelineMessages: messages,
+    subtreeReplyIds: ["a", "b", "a1", "b1", "b2"],
+    visibleReplyIds: ["a", "b"],
+    expandedReplyIds: new Set(),
+    expandedSubtreeReplyIds: new Set(),
+    frontierSeconds: 350,
+    currentPubkey: "me",
+  });
+  assert.equal(counts.has("a"), false); // a1 is self-authored, so 0 unread
+  assert.equal(counts.get("b"), 2); // b1, b2 are by "other"
+});
+
+test("computeThreadReplyUnreadCounts_openTimeSnapshot_survivesChannelMarkRead", () => {
+  // Regression (Fix 1): the in-panel badge must reflect "what was unread when
+  // the thread opened", NOT the live root marker. On channel-open
+  // markChannelRead advances the channel marker to the newest TOP-LEVEL
+  // message; effective(thread) = max(thread_own, channel_marker), so the live
+  // value can jump PAST the nested replies and zero every badge. Passing the
+  // open-time snapshot (frontier 350, captured before the advance) keeps the
+  // badges; passing the post-advance live value (650, past b2(600)) loses them.
+  const args = {
+    timelineMessages: fixture(),
+    subtreeReplyIds: ROOT_SUBTREE,
+    visibleReplyIds: ["a", "b"],
+    expandedReplyIds: new Set(),
+    expandedSubtreeReplyIds: new Set(),
+  };
+  const snapshotCounts = computeThreadReplyUnreadCounts({
+    ...args,
+    frontierSeconds: 350,
+  });
+  assert.equal(snapshotCounts.get("a"), 1);
+  assert.equal(snapshotCounts.get("b"), 2);
+
+  const liveAdvancedCounts = computeThreadReplyUnreadCounts({
+    ...args,
+    frontierSeconds: 650,
+  });
+  assert.equal(liveAdvancedCounts.size, 0);
+});
diff --git a/desktop/src/features/channels/lib/threadReplyUnreadCounts.ts b/desktop/src/features/channels/lib/threadReplyUnreadCounts.ts
new file mode 100644
index 0000000000..2c7083deb5
--- /dev/null
+++ b/desktop/src/features/channels/lib/threadReplyUnreadCounts.ts
@@ -0,0 +1,70 @@
+import { buildDescendantStatsByMessageId } from "@/features/messages/lib/threadPanel";
+import type { TimelineMessage } from "@/features/messages/types";
+
+/**
+ * Per-row subtree unread counts for the in-panel thread summary rows. A
+ * collapsed branch's badge counts unread replies anywhere beneath it; the
+ * count is omitted for expanded branches (suppress-on-expand happens here,
+ * upstream of the panel, so the panel needs no gate) and for rows with zero
+ * unread descendants (no "0" badge).
+ *
+ * Unread is measured against the open-time frontier snapshot — the same
+ * boundary the in-thread divider uses — so the mark-read-on-open advance does
+ * not zero the badges the instant the panel opens. A null frontier (thread
+ * never read) treats every subtree reply as unread.
+ *
+ * @param subtreeReplyIds Descendant reply ids of the open thread head. Scoping
+ *   the unread set to this subtree keeps one thread's frontier from marking
+ *   replies that belong to a different thread.
+ * @param visibleReplyIds Ids of the rows actually rendered in the panel; only
+ *   these are keyed, keeping the map consistent with row presence.
+ * @param expandedSubtreeReplyIds Reply ids beneath any expanded row. Expanding
+ *   a branch persistently marks its whole subtree read (mark-read-on-expand),
+ *   so those replies are dropped from the unread set — otherwise a revealed
+ *   child would carry a stale badge for a reply the same gesture just read.
+ */
+export function computeThreadReplyUnreadCounts(params: {
+  timelineMessages: TimelineMessage[];
+  subtreeReplyIds: Iterable<string>;
+  visibleReplyIds: Iterable<string>;
+  expandedReplyIds: ReadonlySet<string>;
+  expandedSubtreeReplyIds: ReadonlySet<string>;
+  frontierSeconds: number | null;
+  currentPubkey?: string;
+}): Map<string, number> {
+  const {
+    timelineMessages,
+    subtreeReplyIds,
+    visibleReplyIds,
+    expandedReplyIds,
+    expandedSubtreeReplyIds,
+    frontierSeconds,
+    currentPubkey,
+  } = params;
+
+  const subtree = new Set(subtreeReplyIds);
+  const unreadReplyIds = new Set(
+    timelineMessages
+      .filter(
+        (message) =>
+          subtree.has(message.id) &&
+          !expandedSubtreeReplyIds.has(message.id) &&
+          (!currentPubkey || message.pubkey !== currentPubkey) &&
+          (frontierSeconds === null || message.createdAt > frontierSeconds),
+      )
+      .map((message) => message.id),
+  );
+
+  const stats = buildDescendantStatsByMessageId(
+    timelineMessages,
+    unreadReplyIds,
+  );
+
+  const counts = new Map<string, number>();
+  for (const replyId of visibleReplyIds) {
+    if (expandedReplyIds.has(replyId)) continue;
+    const unread = stats.get(replyId)?.unreadDescendantCount ?? 0;
+    if (unread > 0) counts.set(replyId, unread);
+  }
+  return counts;
+}
diff --git a/desktop/src/features/channels/readState/readStateManager.test.mjs b/desktop/src/features/channels/readState/readStateManager.test.mjs
index c4ccfca0ee..8cc74f2a5d 100644
--- a/desktop/src/features/channels/readState/readStateManager.test.mjs
+++ b/desktop/src/features/channels/readState/readStateManager.test.mjs
@@ -1,7 +1,99 @@
 import assert from "node:assert/strict";
 import test from "node:test";
 
-import { applyRemoteContextTimestamp } from "./readStateManager.ts";
+import {
+  applyRemoteContextTimestamp,
+  resolveEffectiveTimestamp,
+} from "./readStateManager.ts";
+
+const threadKey = "thread:" + "a".repeat(64);
+const channelKey = "channel-1";
+const channelResolver = (ctx) =>
+  ctx.startsWith("thread:") ? channelKey : null;
+
+test("resolveEffectiveTimestamp returns own value when context has no parent", () => {
+  const effectiveState = new Map([[channelKey, 200]]);
+  const result = resolveEffectiveTimestamp({
+    effectiveState,
+    contextId: channelKey,
+    parentResolver: channelResolver,
+  });
+  assert.equal(result, 200);
+});
+
+test("resolveEffectiveTimestamp inherits the channel frontier when it is newer than the thread", () => {
+  // Channel-read clears its threads: marking the channel read at 300 must
+  // dominate a thread last read at 100.
+  const effectiveState = new Map([
+    [threadKey, 100],
+    [channelKey, 300],
+  ]);
+  const result = resolveEffectiveTimestamp({
+    effectiveState,
+    contextId: threadKey,
+    parentResolver: channelResolver,
+  });
+  assert.equal(result, 300);
+});
+
+test("resolveEffectiveTimestamp keeps the thread frontier when it is newer than the channel", () => {
+  const effectiveState = new Map([
+    [threadKey, 400],
+    [channelKey, 300],
+  ]);
+  const result = resolveEffectiveTimestamp({
+    effectiveState,
+    contextId: threadKey,
+    parentResolver: channelResolver,
+  });
+  assert.equal(result, 400);
+});
+
+test("resolveEffectiveTimestamp returns the channel frontier when the thread was never read", () => {
+  const effectiveState = new Map([[channelKey, 300]]);
+  const result = resolveEffectiveTimestamp({
+    effectiveState,
+    contextId: threadKey,
+    parentResolver: channelResolver,
+  });
+  assert.equal(result, 300);
+});
+
+test("resolveEffectiveTimestamp degrades to the thread's own value when the root is unresolvable", () => {
+  // Resolver returns null (root not in the event graph) → own term only.
+  const effectiveState = new Map([
+    [threadKey, 100],
+    [channelKey, 300],
+  ]);
+  const result = resolveEffectiveTimestamp({
+    effectiveState,
+    contextId: threadKey,
+    parentResolver: () => null,
+  });
+  assert.equal(result, 100);
+});
+
+test("resolveEffectiveTimestamp degrades to own value when no resolver is set", () => {
+  const effectiveState = new Map([
+    [threadKey, 100],
+    [channelKey, 300],
+  ]);
+  const result = resolveEffectiveTimestamp({
+    effectiveState,
+    contextId: threadKey,
+    parentResolver: null,
+  });
+  assert.equal(result, 100);
+});
+
+test("resolveEffectiveTimestamp returns null when neither context nor parent has a value", () => {
+  const result = resolveEffectiveTimestamp({
+    effectiveState: new Map(),
+    contextId: threadKey,
+    parentResolver: channelResolver,
+  });
+  assert.equal(result, null);
+});
 
 test("applyRemoteContextTimestamp ignores older remote read markers from newer sync events", () => {
   const effectiveState = new Map([["channel-1", 200]]);
diff --git a/desktop/src/features/channels/readState/readStateManager.ts b/desktop/src/features/channels/readState/readStateManager.ts
index c777e3694b..ce93b0b3d0 100644
--- a/desktop/src/features/channels/readState/readStateManager.ts
+++ b/desktop/src/features/channels/readState/readStateManager.ts
@@ -49,6 +49,36 @@ function slotIdKey(pubkey: string): string {
 
 export type ApplyRemoteContextResult = "unchanged" | "advanced";
 
+export type ContextParentResolver = (contextId: string) => string | null;
+
+/**
+ * NIP-RS Hierarchical Frontier Rule (NIP-RS.md:141-167):
+ * `effective(ctx) = max(merged[ctx], effective(parent(ctx)))`.
+ *
+ * The thread→channel relationship is NOT serialized into the blob
+ * (NIP-RS.md:136-139); it is derived from the event graph at evaluation time
+ * via `parentResolver`. When the resolver yields no parent (channels, or an
+ * unresolvable thread root), the frontier degrades to the context's own merged
+ * value alone (NIP-RS.md:165-167). Returns null when the context has never been
+ * read and no parent term covers it.
+ */
+export function resolveEffectiveTimestamp(args: {
+  effectiveState: Map<string, number>;
+  contextId: string;
+  parentResolver: ContextParentResolver | null;
+}): number | null {
+  const { effectiveState, contextId, parentResolver } = args;
+  const own = effectiveState.get(contextId) ?? null;
+
+  const parentId = parentResolver?.(contextId) ?? null;
+  if (parentId === null) return own;
+
+  const parent = effectiveState.get(parentId) ?? null;
+  if (parent === null) return own;
+  if (own === null) return parent;
+  return Math.max(own, parent);
+}
+
 function resolveRemoteContextTimestamp(args: {
   current: number;
   timestamp: number;
@@ -106,6 +136,7 @@ export class ReadStateManager {
   private contextSourceCreatedAt = new Map<string, number>();
   private pendingSyncedAdvances = new Set<string>();
   private destroyed = false;
+  private parentResolver: ContextParentResolver | null = null;
 
   constructor(pubkey: string, relayClient: RelayClient) {
     this.pubkey = pubkey;
@@ -182,9 +213,34 @@ export class ReadStateManager {
   }
 
   getEffectiveTimestamp(contextId: string): number | null {
+    return resolveEffectiveTimestamp({
+      effectiveState: this.effectiveState,
+      contextId,
+      parentResolver: this.parentResolver,
+    });
+  }
+
+  /**
+   * The context's OWN merged read marker, WITHOUT the hierarchical parent term.
+   * Callers that evaluate a `thread:<root>` context outside the active channel
+   * (e.g. the sidebar unread scan over background channels) must use this:
+   * getEffectiveTimestamp folds in parentResolver, which is installed by the
+   * active ChannelScreen and maps every thread to the *active* channel — using
+   * it for a background channel's thread would borrow the wrong channel marker.
+   */
+  getOwnTimestamp(contextId: string): number | null {
     return this.effectiveState.get(contextId) ?? null;
   }
 
+  /**
+   * Inject the thread→channel parent resolver derived from the React event
+   * graph (NIP-RS.md:136-139). The hierarchical max in getEffectiveTimestamp
+   * is a no-op until this is set.
+   */
+  setContextParentResolver(resolver: ContextParentResolver | null): void {
+    this.parentResolver = resolver;
+  }
+
   subscribe(listener: () => void): () => void {
     this.listeners.add(listener);
     return () => {
@@ -536,6 +592,27 @@ export class ReadStateManager {
       }
       contexts[ctx] = ts;
     }
+
+    // Evict oldest thread:* entries when approaching the MAX_CONTEXTS cap
+    // (10,000). Channel keys are never evicted — only thread read-state keys
+    // are eligible. This prevents silent blob rejection by isValidBlob().
+    const EVICTION_THRESHOLD = 8_000;
+    const contextCount = Object.keys(contexts).length;
+    if (contextCount > EVICTION_THRESHOLD) {
+      const threadEntries: [string, number][] = [];
+      for (const [key, ts] of Object.entries(contexts)) {
+        if (key.startsWith("thread:")) {
+          threadEntries.push([key, ts]);
+        }
+      }
+      // Sort oldest-first (lowest timestamp = oldest)
+      threadEntries.sort((a, b) => a[1] - b[1]);
+      const toEvict = contextCount - EVICTION_THRESHOLD;
+      for (let i = 0; i < Math.min(toEvict, threadEntries.length); i++) {
+        delete contexts[threadEntries[i][0]];
+      }
+    }
+
     return contexts;
   }
 
diff --git a/desktop/src/features/channels/readState/useReadState.ts b/desktop/src/features/channels/readState/useReadState.ts
index 34172a1cbf..8326a2bd0a 100644
--- a/desktop/src/features/channels/readState/useReadState.ts
+++ b/desktop/src/features/channels/readState/useReadState.ts
@@ -1,10 +1,14 @@
 import * as React from "react";
-import { ReadStateManager } from "@/features/channels/readState/readStateManager";
+import {
+  ReadStateManager,
+  type ContextParentResolver,
+} from "@/features/channels/readState/readStateManager";
 import type { RelayClient } from "@/shared/api/relayClientSession";
 
 const noopGetTimestamp = () => null;
 const noopMarkRead = () => {};
 const noopDrainAdvances = (): ReadonlySet<string> => new Set<string>();
+const noopSetResolver = () => {};
 
 /**
  * React hook that creates and manages a ReadStateManager instance.
@@ -57,6 +61,13 @@ export function useReadState(
     [],
   );
 
+  const getOwnTimestamp = React.useCallback(
+    (contextId: string): number | null => {
+      return managerRef.current?.getOwnTimestamp(contextId) ?? null;
+    },
+    [],
+  );
+
   const markContextRead = React.useCallback(
     (contextId: string, unixTimestamp: number): void => {
       managerRef.current?.markContextRead(contextId, unixTimestamp);
@@ -75,6 +86,13 @@ export function useReadState(
     return managerRef.current?.drainSyncedAdvances() ?? new Set<string>();
   }, []);
 
+  const setContextParentResolver = React.useCallback(
+    (resolver: ContextParentResolver | null): void => {
+      managerRef.current?.setContextParentResolver(resolver);
+    },
+    [],
+  );
+
   const isReady = Boolean(
     pubkey && relayClient && initializedPubkey === pubkey,
   );
@@ -86,7 +104,9 @@ export function useReadState(
       markContextRead: noopMarkRead,
       seedContextRead: noopMarkRead,
       drainSyncedAdvances: noopDrainAdvances,
+      setContextParentResolver: noopSetResolver,
       readStateVersion: 0,
+      getOwnTimestamp: noopGetTimestamp,
     };
   }
 
@@ -96,6 +116,8 @@ export function useReadState(
     markContextRead,
     seedContextRead,
     drainSyncedAdvances,
+    setContextParentResolver,
     readStateVersion,
+    getOwnTimestamp,
   };
 }
diff --git a/desktop/src/features/channels/ui/ChannelPane.helpers.ts b/desktop/src/features/channels/ui/ChannelPane.helpers.ts
new file mode 100644
index 0000000000..8aef5f523a
--- /dev/null
+++ b/desktop/src/features/channels/ui/ChannelPane.helpers.ts
@@ -0,0 +1,53 @@
+import { isEphemeralChannel } from "@/features/channels/lib/ephemeralChannel";
+import type { TimelineMessage } from "@/features/messages/types";
+import type { Channel } from "@/shared/api/types";
+import { KIND_SYSTEM_MESSAGE } from "@/shared/constants/kinds";
+
+export function getChannelIntroKind(channel: Channel): string {
+  const isPrivate = channel.visibility === "private";
+  const isEphemeral = isEphemeralChannel(channel);
+
+  if (isPrivate && isEphemeral) {
+    return "private ephemeral channel";
+  }
+  if (isPrivate) {
+    return "private channel";
+  }
+  if (isEphemeral) {
+    return "ephemeral channel";
+  }
+  return "regular channel";
+}
+
+export function getChannelIntroDescription(channel: Channel): string | null {
+  return (
+    channel.topic?.trim() ||
+    channel.purpose?.trim() ||
+    channel.description.trim() ||
+    null
+  );
+}
+
+export function isWelcomeSetupSystemMessage(message: TimelineMessage) {
+  if (message.kind !== KIND_SYSTEM_MESSAGE) {
+    return false;
+  }
+
+  try {
+    const payload = JSON.parse(message.body) as { type?: string };
+    return (
+      payload.type === "channel_created" || payload.type === "member_joined"
+    );
+  } catch {
+    return false;
+  }
+}
+
+export function mentionsKnownAgent(
+  mentionPubkeys: string[],
+  knownAgentPubkeys: ReadonlySet<string>,
+) {
+  return mentionPubkeys.some((pubkey) =>
+    knownAgentPubkeys.has(pubkey.toLowerCase()),
+  );
+}
diff --git a/desktop/src/features/channels/ui/ChannelPane.tsx b/desktop/src/features/channels/ui/ChannelPane.tsx
index a28d396179..cf7aa5fb94 100644
--- a/desktop/src/features/channels/ui/ChannelPane.tsx
+++ b/desktop/src/features/channels/ui/ChannelPane.tsx
@@ -35,7 +35,12 @@ import {
   WELCOME_PERSONA_ROTATION_MS,
   type WelcomeComposerBannerState,
 } from "@/features/channels/ui/WelcomeComposerBanner";
-import { isEphemeralChannel } from "@/features/channels/lib/ephemeralChannel";
+import {
+  getChannelIntroDescription,
+  getChannelIntroKind,
+  isWelcomeSetupSystemMessage,
+  mentionsKnownAgent,
+} from "@/features/channels/ui/ChannelPane.helpers";
 import type { ChannelAgentSessionAgent } from "@/features/channels/ui/useChannelAgentSessions";
 import { Button } from "@/shared/ui/button";
 import type { useChannelFind } from "@/features/search/useChannelFind";
@@ -72,6 +77,10 @@ type ChannelPaneProps = {
   isSending: boolean;
   isTimelineLoading: boolean;
   messages: TimelineMessage[];
+  /** Event id of the oldest unread top-level message at channel open, or null. */
+  firstUnreadMessageId?: string | null;
+  /** Count of unread top-level messages at channel open. */
+  unreadCount?: number;
   canResetThreadPanelWidth: boolean;
   onCancelEdit?: () => void;
   onCancelThreadReply: () => void;
@@ -132,6 +141,12 @@ type ChannelPaneProps = {
   threadTypingPubkeys: string[];
   threadReplyTargetMessage: TimelineMessage | null;
   threadScrollTargetId: string | null;
+  /** Per-thread unread counts keyed by thread root id. */
+  threadUnreadCounts?: ReadonlyMap<string, number>;
+  /** Subtree unread counts for in-panel summary rows, keyed by reply id. */
+  threadReplyUnreadCounts?: ReadonlyMap<string, number>;
+  /** Event id of the first unread reply in the open thread panel. */
+  threadFirstUnreadReplyId?: string | null;
   targetMessageId: string | null;
   typingPubkeys: string[];
   isFollowingThread?: boolean;
@@ -142,55 +157,6 @@ type ChannelPaneProps = {
   isFollowingThreadById?: (rootId: string) => boolean;
 };
 
-function getChannelIntroKind(channel: Channel): string {
-  const isPrivate = channel.visibility === "private";
-  const isEphemeral = isEphemeralChannel(channel);
-
-  if (isPrivate && isEphemeral) {
-    return "private ephemeral channel";
-  }
-  if (isPrivate) {
-    return "private channel";
-  }
-  if (isEphemeral) {
-    return "ephemeral channel";
-  }
-  return "regular channel";
-}
-
-function getChannelIntroDescription(channel: Channel): string | null {
-  return (
-    channel.topic?.trim() ||
-    channel.purpose?.trim() ||
-    channel.description.trim() ||
-    null
-  );
-}
-
-function isWelcomeSetupSystemMessage(message: TimelineMessage) {
-  if (message.kind !== KIND_SYSTEM_MESSAGE) {
-    return false;
-  }
-
-  try {
-    const payload = JSON.parse(message.body) as { type?: string };
-    return (
-      payload.type === "channel_created" || payload.type === "member_joined"
-    );
-  } catch {
-    return false;
-  }
-}
-
-function mentionsKnownAgent(
-  mentionPubkeys: string[],
-  knownAgentPubkeys: ReadonlySet<string>,
-) {
-  return mentionPubkeys.some((pubkey) =>
-    knownAgentPubkeys.has(pubkey.toLowerCase()),
-  );
-}
-
 export const ChannelPane = React.memo(function ChannelPane({
   activeChannel,
   agentPubkeys,
@@ -212,6 +178,8 @@ export const ChannelPane = React.memo(function ChannelPane({
   isSending,
   isTimelineLoading,
   messages,
+  firstUnreadMessageId = null,
+  unreadCount = 0,
   canResetThreadPanelWidth,
   onCancelEdit,
   onCancelThreadReply,
@@ -255,6 +223,9 @@ export const ChannelPane = React.memo(function ChannelPane({
   threadScrollTargetId,
   threadTypingPubkeys,
   threadReplyTargetMessage,
+  threadUnreadCounts,
+  threadReplyUnreadCounts,
+  threadFirstUnreadReplyId,
   typingPubkeys,
 }: ChannelPaneProps) {
   const timelineScrollRef = React.useRef<HTMLDivElement>(null);
@@ -684,6 +655,8 @@ export const ChannelPane = React.memo(function ChannelPane({
             }
             isLoading={isTimelineLoading}
             messages={visibleMessages}
+            firstUnreadMessageId={firstUnreadMessageId}
+            unreadCount={unreadCount}
             onDelete={onDelete}
             onEdit={onEdit}
             onMarkUnread={onMarkUnread}
@@ -700,6 +673,7 @@ export const ChannelPane = React.memo(function ChannelPane({
             searchMatchingMessageIds={channelFind.matchingMessageIds}
             searchQuery={channelFind.query}
             targetMessageId={targetMessageId}
+            threadUnreadCounts={threadUnreadCounts}
           />
           {isNonMemberView ? (
             <div
@@ -809,6 +783,7 @@ export const ChannelPane = React.memo(function ChannelPane({
                 currentPubkey={currentPubkey}
                 disabled={isComposerDisabled}
                 editTarget={threadEditTarget}
+                firstUnreadReplyId={threadFirstUnreadReplyId}
                 isFollowingThread={isFollowingThread}
                 isSending={isSending}
                 isSinglePanelView={
@@ -837,6 +812,7 @@ export const ChannelPane = React.memo(function ChannelPane({
                 threadHeadVideoReviewContext={threadHeadVideoReviewContext}
                 widthPx={threadPanelWidthPx}
                 threadReplies={threadMessages}
+                threadReplyUnreadCounts={threadReplyUnreadCounts}
                 threadTypingPubkeys={threadTypingPubkeys}
                 toolbarExtraActions={
                   hasThreadComposerBotActivity ? (
diff --git a/desktop/src/features/channels/ui/ChannelScreen.tsx b/desktop/src/features/channels/ui/ChannelScreen.tsx
index beb57c6308..47e774a1da 100644
--- a/desktop/src/features/channels/ui/ChannelScreen.tsx
+++ b/desktop/src/features/channels/ui/ChannelScreen.tsx
@@ -2,6 +2,14 @@ import * as React from "react";
 import { useAppShell } from "@/app/AppShellContext";
 import { useActiveChannelHeader } from "@/features/channels/useActiveChannelHeader";
 import { useChannelPaneHandlers } from "@/features/channels/useChannelPaneHandlers";
+import {
+  buildCreatedAtByMessageId,
+  buildDirectReplyIdsByParentId,
+  collectReplyDescendantIds,
+  directRepliesMaxCreatedAt,
+  subtreeMaxCreatedAt,
+} from "@/features/channels/lib/subtreeCreatedAt";
+import { computeThreadReplyUnreadCounts } from "@/features/channels/lib/threadReplyUnreadCounts";
 import {
   useChannelMembersQuery,
   useJoinChannelMutation,
@@ -37,6 +45,11 @@ import {
   buildThreadPanelDataFromIndex,
   buildThreadPanelIndex,
 } from "@/features/messages/lib/threadPanel";
+import { getThreadReference } from "@/features/messages/lib/threading";
+import {
+  computeChannelUnreadMarker,
+  computeThreadUnreadMarker,
+} from "@/features/messages/lib/unreadMarker";
 import { imetaMediaFromTags } from "@/features/messages/lib/imetaMediaMarkdown";
 import { useFetchOlderMessages } from "@/features/messages/useFetchOlderMessages";
 import { useLoadMissingAncestors } from "@/features/messages/useLoadMissingAncestors";
@@ -82,6 +95,10 @@ export function ChannelScreen({
   const {
     markChannelRead,
     markChannelUnread,
+    getChannelReadAt,
+    getThreadReadAt,
+    markThreadRead,
+    setContextParentResolver,
     openCreateChannel,
     openChannelManagement,
     followThread,
@@ -125,17 +142,107 @@ export function ChannelScreen({
   useChannelSubscription(activeChannel);
   const { fetchOlder, hasOlderMessages, isFetchingOlder } =
     useFetchOlderMessages(activeChannel);
-  const latestActiveMessage =
-    messagesQuery.data?.[messagesQuery.data.length - 1] ?? null;
+  // Newest TOP-LEVEL message only. The channel read-marker must clear the
+  // channel timeline without clearing its threads (NIP-RS Option 1): thread
+  // replies are kind-9 channel events, so taking the last message outright
+  // would advance the channel frontier past unread replies and the hierarchical
+  // effective(thread) = max(thread, channel) would silently clear every thread
+  // badge on channel entry. Scanning from the end for the last message with no
+  // reply tag keeps the frontier at the last top-level message, leaving thread
+  // badges intact until the thread itself is read.
+  const latestActiveMessage = React.useMemo(() => {
+    const messages = messagesQuery.data;
+    if (!messages) return null;
+    for (let index = messages.length - 1; index >= 0; index -= 1) {
+      if (getThreadReference(messages[index].tags).parentId === null) {
+        return messages[index];
+      }
+    }
+    return null;
+  }, [messagesQuery.data]);
+  // No `lastMessageAt` fallback: that timestamp is reply-inclusive (the backend
+  // takes MAX(created_at) over kind-9 events without a parent filter), so using
+  // it when the window has no top-level message would advance the channel
+  // marker past an unread reply and clear its thread/sidebar unread — the exact
+  // regression Fix A prevents. null suppresses the marker advance (markChannelRead
+  // early-returns on markAt === null) until a real top-level position is known.
   const activeReadAt = latestActiveMessage
     ? new Date(latestActiveMessage.created_at * 1_000).toISOString()
-    : (activeChannel?.lastMessageAt ?? null);
+    : null;
+  // Capture the read frontier as it stood the instant this channel was opened,
+  // BEFORE the mark-read effect below advances it to latest. Written during
+  // render (not in an effect) so the value is read prior to any effect for
+  // this commit — the divider must reflect "what was unread on open", not the
+  // post-open frontier. Keyed per channel and recomputed only when the channel
+  // id changes, never when activeReadAt advances, or the divider would vanish
+  // the moment the open marks the channel read.
+  const openFrontierRef = React.useRef(new Map<string, number | null>());
+  if (activeChannelId && !openFrontierRef.current.has(activeChannelId)) {
+    openFrontierRef.current.set(
+      activeChannelId,
+      getChannelReadAt(activeChannelId),
+    );
+  }
+  const openFrontierSeconds = activeChannelId
+    ? (openFrontierRef.current.get(activeChannelId) ?? null)
+    : null;
+  // Channels the user manually marked unread this session. A deliberate
+  // mark-unread has no meaningful "new" boundary inside the timeline — the
+  // open-time snapshot already covers every message — so the pill and divider
+  // would otherwise render nothing while the sidebar dot says unread. Suppress
+  // the marker for such channels to avoid that visible contradiction. The flag
+  // is cleared on re-open (a fresh snapshot is recomputed for the channel).
+  const forcedUnreadRef = React.useRef(new Set<string>());
+  const [, forceUnreadRender] = React.useReducer((n: number) => n + 1, 0);
+  const isActiveChannelForcedUnread =
+    !!activeChannelId && forcedUnreadRef.current.has(activeChannelId);
+  // Drop the forced-unread flag when the user leaves a channel, so reopening
+  // it recomputes a normal marker rather than staying suppressed forever.
+  React.useEffect(() => {
+    const channelId = activeChannelId;
+    if (!channelId) return;
+    return () => {
+      forcedUnreadRef.current.delete(channelId);
+    };
+  }, [activeChannelId]);
+  // Clear the open-time frontier on channel leave so re-visiting captures a
+  // fresh read position. Without this, switching away and back would reuse the
+  // stale frontier from the first open, producing a phantom "New" divider over
+  // already-read messages.
+  React.useEffect(() => {
+    const channelId = activeChannelId;
+    if (!channelId) return;
+    return () => {
+      openFrontierRef.current.delete(channelId);
+    };
+  }, [activeChannelId]);
   React.useEffect(() => {
     if (!activeChannelId || activeChannel?.isMember === false) {
       return;
     }
-    markChannelRead(activeChannelId, activeReadAt);
+    // Passive channel-open: advance the marker to the newest top-level message
+    // only (NIP-RS Option 1). `topLevelOnly` stops the read-state layer folding
+    // in observed thread replies, so opening a channel clears the timeline but
+    // leaves thread badges intact until each thread is opened — and leaves the
+    // channel's sidebar dot lit (the reply is still unread for the channel).
+    markChannelRead(activeChannelId, activeReadAt, { topLevelOnly: true });
   }, [activeChannel?.isMember, activeChannelId, activeReadAt, markChannelRead]);
+  // Install the NIP-RS parent resolver: every `thread:<root>` context evaluated
+  // while this channel is active belongs to it (getThreadReadAt is only ever
+  // called on the active channel's timeline messages), so the parent is always
+  // the active channel. Non-thread keys (channels) have no parent → null, which
+  // degrades effective() to the own term. Cleared on channel leave / unmount so
+  // a stale channel id never becomes the parent of another channel's threads.
+  React.useEffect(() => {
+    if (!activeChannelId) {
+      setContextParentResolver(null);
+      return;
+    }
+    setContextParentResolver((contextId) =>
+      contextId.startsWith("thread:") ? activeChannelId : null,
+    );
+    return () => setContextParentResolver(null);
+  }, [activeChannelId, setContextParentResolver]);
   const {
     activeChannelTitle,
     activeDmAvatarUrl,
@@ -293,34 +400,55 @@ export function ChannelScreen({
     channelId: activeChannelId,
     messages: timelineMessages,
   });
-  const directReplyIdsByParentId = React.useMemo(() => {
-    const map = new Map<string, string[]>();
-    for (const message of timelineMessages) {
-      if (!message.parentId) continue;
-      const currentReplies = map.get(message.parentId) ?? [];
-      currentReplies.push(message.id);
-      map.set(message.parentId, currentReplies);
-    }
-    return map;
-  }, [timelineMessages]);
+  // Oldest-unread top-level message + count, derived from the open-time
+  // frontier snapshot above. Drives the "N new messages" pill and the "New"
+  // divider; both stay put even after the open marks the channel read because
+  // openFrontierSeconds is keyed per channel, not on the live marker.
+  const { firstUnreadMessageId, unreadCount } = React.useMemo(
+    () =>
+      computeChannelUnreadMarker(
+        timelineMessages,
+        openFrontierSeconds,
+        isActiveChannelForcedUnread,
+        currentPubkey,
+      ),
+    [
+      currentPubkey,
+      isActiveChannelForcedUnread,
+      openFrontierSeconds,
+      timelineMessages,
+    ],
+  );
+  const directReplyIdsByParentId = React.useMemo(
+    () => buildDirectReplyIdsByParentId(timelineMessages),
+    [timelineMessages],
+  );
   const getFirstReplyIdForMessage = React.useCallback(
     (messageId: string) => directReplyIdsByParentId.get(messageId)?.[0] ?? null,
     [directReplyIdsByParentId],
   );
   const getReplyDescendantIdsForMessage = React.useCallback(
-    (messageId: string) => {
-      const descendantIds: string[] = [];
-      const pendingIds = [...(directReplyIdsByParentId.get(messageId) ?? [])];
-      while (pendingIds.length > 0) {
-        const currentId = pendingIds.pop();
-        if (!currentId) continue;
-        descendantIds.push(currentId);
-        pendingIds.push(...(directReplyIdsByParentId.get(currentId) ?? []));
-      }
-      return descendantIds;
-    },
+    (messageId: string) =>
+      collectReplyDescendantIds(messageId, directReplyIdsByParentId),
     [directReplyIdsByParentId],
   );
+  const createdAtByMessageId = React.useMemo(
+    () => buildCreatedAtByMessageId(timelineMessages),
+    [timelineMessages],
+  );
+  // Newest createdAt across an expanded branch (the message itself plus every
+  // descendant). Drilling into a branch advances the thread frontier to this,
+  // consuming everything chronologically up to the deepest reply read. Returns
+  // null when the message is absent so the caller skips the read-state write.
+  const getSubtreeMaxCreatedAt = React.useCallback(
+    (messageId: string) =>
+      subtreeMaxCreatedAt(
+        messageId,
+        directReplyIdsByParentId,
+        createdAtByMessageId,
+      ),
+    [createdAtByMessageId, directReplyIdsByParentId],
+  );
   const threadPanelIndex = React.useMemo(
     () => buildThreadPanelIndex(timelineMessages),
     [timelineMessages],
@@ -343,6 +471,175 @@ export function ChannelScreen({
   const openThreadHeadMessage = threadPanelData.threadHead;
   const threadMessages = threadPanelData.visibleReplies;
   const threadReplyTargetMessage = threadPanelData.replyTargetMessage;
+
+  // --- Thread unread state ---
+  // Capture the thread read frontier on open (same pattern as channel frontier).
+  // Keyed per thread root so switching threads captures a fresh frontier.
+  const threadOpenFrontierRef = React.useRef(new Map<string, number | null>());
+  if (
+    openThreadHeadId &&
+    !threadOpenFrontierRef.current.has(openThreadHeadId)
+  ) {
+    threadOpenFrontierRef.current.set(
+      openThreadHeadId,
+      getThreadReadAt(openThreadHeadId),
+    );
+  }
+  const threadOpenFrontierSeconds = openThreadHeadId
+    ? (threadOpenFrontierRef.current.get(openThreadHeadId) ?? null)
+    : null;
+  // Clear the thread frontier when the thread closes so re-opening captures fresh.
+  React.useEffect(() => {
+    const rootId = openThreadHeadId;
+    if (!rootId) return;
+    return () => {
+      threadOpenFrontierRef.current.delete(rootId);
+    };
+  }, [openThreadHeadId]);
+  // Mark thread read when the panel opens, advancing the frontier to the max
+  // createdAt over the head and its DIRECT replies — the content visible
+  // without expanding anything. This mirrors channel-open parity: opening
+  // consumes what you can see, clearing the channel-level badge for unread that
+  // lived in the visible direct replies, while deeper collapsed branches stay
+  // unread until drilled into (expand advances the frontier further from here).
+  // Only persist read state for threads the user has notification interest in
+  // (participated, authored, or followed) to avoid bloating the context blob.
+  React.useEffect(() => {
+    if (!openThreadHeadId) return;
+    if (!isNotifiedForCurrentThread) return;
+    const openReadCeiling = directRepliesMaxCreatedAt(
+      openThreadHeadId,
+      directReplyIdsByParentId,
+      createdAtByMessageId,
+    );
+    if (openReadCeiling === null) return;
+    markThreadRead(openThreadHeadId, openReadCeiling);
+  }, [
+    openThreadHeadId,
+    directReplyIdsByParentId,
+    createdAtByMessageId,
+    markThreadRead,
+    isNotifiedForCurrentThread,
+  ]);
+  // Compute the in-thread "New" divider position from the open-time frontier.
+  const { firstUnreadReplyId: threadFirstUnreadReplyId } = React.useMemo(() => {
+    if (!openThreadHeadId || threadMessages.length === 0) {
+      return { firstUnreadReplyId: null, unreadCount: 0 };
+    }
+    const replies = threadMessages.map((entry) => entry.message);
+    return computeThreadUnreadMarker(
+      replies,
+      threadOpenFrontierSeconds,
+      currentPubkey,
+    );
+  }, [
+    currentPubkey,
+    openThreadHeadId,
+    threadMessages,
+    threadOpenFrontierSeconds,
+  ]);
+  // Per-row subtree unread counts for the in-panel thread summary rows. Scoped
+  // to the open thread's subtree and measured against the open-time frontier
+  // snapshot (threadOpenFrontierSeconds) — the same boundary the in-thread
+  // divider uses (above). The LIVE root marker can't be used here: on
+  // channel-open markChannelRead advances the channel marker to the newest
+  // top-level message, and effective(thread) = max(thread_own, channel_marker),
+  // so a channel marker past the nested replies would zero every badge the
+  // instant the panel opens. The snapshot reflects "what was unread on open."
+  // Expand-clears-badge is preserved independently: it's driven by the
+  // expandedSubtreeReplyIds gate inside computeThreadReplyUnreadCounts, not by
+  // the frontier.
+  const threadReplyUnreadCounts = React.useMemo(
+    () =>
+      openThreadHeadId
+        ? computeThreadReplyUnreadCounts({
+            timelineMessages,
+            subtreeReplyIds: getReplyDescendantIdsForMessage(openThreadHeadId),
+            visibleReplyIds: threadMessages.map((entry) => entry.message.id),
+            expandedReplyIds: expandedThreadReplyIds,
+            expandedSubtreeReplyIds: new Set(
+              [...expandedThreadReplyIds].flatMap((id) =>
+                getReplyDescendantIdsForMessage(id),
+              ),
+            ),
+            frontierSeconds: threadOpenFrontierSeconds,
+            currentPubkey,
+          })
+        : new Map<string, number>(),
+    [
+      openThreadHeadId,
+      threadMessages,
+      timelineMessages,
+      threadOpenFrontierSeconds,
+      expandedThreadReplyIds,
+      getReplyDescendantIdsForMessage,
+      currentPubkey,
+    ],
+  );
+  // Snapshot per-thread read frontiers at channel-open time. Same pattern as
+  // openFrontierRef: captured during render (before the mark-read effect) so
+  // the badge reflects "what was unread on open" rather than the post-advance
+  // frontier. Keyed by activeChannelId → rootId → frontier value.
+  const threadBadgeFrontiersRef = React.useRef(
+    new Map<string, Map<string, number | null>>(),
+  );
+  if (activeChannelId) {
+    let channelFrontiers = threadBadgeFrontiersRef.current.get(activeChannelId);
+    if (!channelFrontiers) {
+      channelFrontiers = new Map();
+      threadBadgeFrontiersRef.current.set(activeChannelId, channelFrontiers);
+    }
+    for (const message of timelineMessages) {
+      if (message.parentId) continue;
+      if (!isNotifiedForThread(message.id)) continue;
+      if (channelFrontiers.has(message.id)) continue;
+      // Only capture for messages that have thread replies
+      const hasReplies = timelineMessages.some(
+        (m) => m.parentId === message.id,
+      );
+      if (!hasReplies) continue;
+      channelFrontiers.set(message.id, getThreadReadAt(message.id));
+    }
+  }
+  // Clear the thread badge frontiers on channel leave (same cleanup as
+  // openFrontierRef) so re-visiting captures fresh snapshots.
+  React.useEffect(() => {
+    const channelId = activeChannelId;
+    if (!channelId) return;
+    return () => {
+      threadBadgeFrontiersRef.current.delete(channelId);
+    };
+  }, [activeChannelId]);
+  // Compute per-thread unread counts for summary rows in the main timeline.
+  // Only compute for threads the user has notification interest in — this
+  // aligns the badge display with the read-state write path. Uses the
+  // snapshotted frontier (threadBadgeFrontiersRef) so badges are stable for
+  // the session and don't flash when markChannelRead advances the channel
+  // marker.
+  const threadUnreadCounts = React.useMemo(() => {
+    const counts = new Map<string, number>();
+    const channelFrontiers = activeChannelId
+      ? threadBadgeFrontiersRef.current.get(activeChannelId)
+      : undefined;
+    for (const message of timelineMessages) {
+      if (message.parentId) continue;
+      if (!isNotifiedForThread(message.id)) continue;
+      const directReplies = timelineMessages.filter(
+        (m) => m.parentId === message.id,
+      );
+      if (directReplies.length === 0) continue;
+      const frontier = channelFrontiers?.get(message.id) ?? null;
+      const { unreadCount } = computeThreadUnreadMarker(
+        directReplies,
+        frontier,
+        currentPubkey,
+      );
+      if (unreadCount > 0) {
+        counts.set(message.id, unreadCount);
+      }
+    }
+    return counts;
+  }, [activeChannelId, currentPubkey, timelineMessages, isNotifiedForThread]);
   const editTargetMessage = React.useMemo(
     () =>
       timelineMessages.find((message) => message.id === editTargetId) ?? null,
@@ -368,6 +665,8 @@ export function ChannelScreen({
     expandedThreadReplyIds,
     getFirstReplyIdForMessage,
     getReplyDescendantIdsForMessage,
+    getSubtreeMaxCreatedAt,
+    markThreadRead,
     openThreadHeadId,
     sendMessageMutation,
     setExpandedThreadReplyIds,
@@ -408,6 +707,10 @@ export function ChannelScreen({
       : undefined;
   const handleMarkUnread = React.useCallback(() => {
     if (!activeChannelId) return;
+    // Mirror the deliberate mark-unread locally so the timeline marker is
+    // suppressed (see forcedUnreadRef above). Re-render so the memo re-runs.
+    forcedUnreadRef.current.add(activeChannelId);
+    forceUnreadRender();
     markChannelUnread(activeChannelId);
   }, [activeChannelId, markChannelUnread]);
   const {
@@ -658,6 +961,8 @@ export function ChannelScreen({
                   profilePanelPubkey={profilePanelPubkey}
                   personaLookup={personaLookup}
                   profiles={messageProfiles}
+                  firstUnreadMessageId={firstUnreadMessageId}
+                  unreadCount={unreadCount}
                   targetMessageId={mainTimelineTargetMessageId}
                   threadHeadMessage={openThreadHeadMessage}
                   threadMessages={threadMessages}
@@ -665,6 +970,9 @@ export function ChannelScreen({
                   threadTypingPubkeys={threadTypingPubkeys}
                   threadReplyTargetMessage={threadReplyTargetMessage}
                   threadScrollTargetId={threadScrollTargetId}
+                  threadUnreadCounts={threadUnreadCounts}
+                  threadReplyUnreadCounts={threadReplyUnreadCounts}
+                  threadFirstUnreadReplyId={threadFirstUnreadReplyId}
                   isJoining={joinChannelMutation.isPending}
                   onJoinChannel={joinChannelMutation.mutateAsync}
                   typingPubkeys={humanTypingPubkeys}
diff --git a/desktop/src/features/channels/unreadReadMarker.test.mjs b/desktop/src/features/channels/unreadReadMarker.test.mjs
new file mode 100644
index 0000000000..fe8211fd3f
--- /dev/null
+++ b/desktop/src/features/channels/unreadReadMarker.test.mjs
@@ -0,0 +1,181 @@
+import assert from "node:assert/strict";
+import test from "node:test";
+
+import { computeChannelUnreadMarker } from "../messages/lib/unreadMarker.ts";
+import {
+  buildChannelThreadRoots,
+  channelUnreadFrontier,
+  resolveChannelReadMarker,
+} from "./useUnreadChannels.ts";
+
+function topLevel(id, createdAt) {
+  return { id, createdAt, author: "a", time: "", body: "", depth: 0 };
+}
+
+// The headline scenario the fix restores: messages arrive while the channel is
+// inactive, the read frontier was captured before them, and on reopen the pill
+// and divider must render. The deleted AppShell effect used to fold those
+// just-arrived timestamps into the frontier, hiding them; with it gone the
+// frontier stays below the new messages.
+test("receiveThenReopen_frontierBelowArrivedMessages_showsDivider", () => {
+  const frontierBeforeReceive = 100;
+  const arrived = [
+    topLevel("seen", 90),
+    topLevel("new-1", 110),
+    topLevel("new-2", 120),
+  ];
+
+  const marker = computeChannelUnreadMarker(arrived, frontierBeforeReceive);
+
+  assert.equal(marker.firstUnreadMessageId, "new-1");
+  assert.equal(marker.unreadCount, 2);
+});
+
+// Regression guard for the read frontier silently clobbering newly received
+// messages: if the marker had advanced to the latest arrival (as the deleted
+// effect did), nothing would be unread.
+test("receiveThenReopen_frontierAtLatestArrival_clobbersDivider", () => {
+  const arrived = [topLevel("a", 90), topLevel("b", 110), topLevel("c", 120)];
+
+  const marker = computeChannelUnreadMarker(arrived, 120);
+
+  assert.equal(marker.firstUnreadMessageId, null);
+  assert.equal(marker.unreadCount, 0);
+});
+
+// An explicit caller timeline position must still advance the read marker. This
+// is the consumer (ChannelScreen) that marks the active channel read with a
+// real position; the fix must not regress it.
+test("resolveChannelReadMarker_realReadAt_advancesMarker", () => {
+  const readAt = "2026-06-12T00:00:00.000Z";
+  const expected = Math.floor(Date.parse(readAt) / 1000);
+
+  const result = resolveChannelReadMarker(readAt, undefined);
+
+  assert.equal(result.markAt, expected);
+  assert.equal(result.clearObserved, false);
+});
+
+// The Esc-to-mark-read shortcut and sidebar mark-read pass a null/stale caller
+// value and rely on the observed-latest fold to mark the channel read. The
+// rejected in-function null-guard would have returned markAt === null here,
+// silently no-opping those user actions. This proves the fold survives.
+test("resolveChannelReadMarker_nullCallerWithObservedLatest_marksViaObserved", () => {
+  const observedLatest = 200;
+
+  const result = resolveChannelReadMarker(null, observedLatest);
+
+  assert.equal(result.markAt, observedLatest);
+  assert.equal(result.clearObserved, true);
+});
+
+// With no caller value and nothing observed there is nothing to mark; the
+// marker resolves to null so markChannelRead short-circuits without writing.
+test("resolveChannelReadMarker_noCallerNoObserved_returnsNull", () => {
+  const result = resolveChannelReadMarker(null, undefined);
+
+  assert.equal(result.markAt, null);
+  assert.equal(result.clearObserved, false);
+});
+
+// --- Fix 2: sidebar dot folds per-thread read markers into the channel frontier ---
+
+function replyItem(channelId, rootId) {
+  return {
+    id: `${channelId}:${rootId}:${Math.random()}`,
+    channelId,
+    tags: [["e", rootId, "", "root"]],
+  };
+}
+
+// rootId for these fixtures is the "root"-marked e-tag.
+const getRootId = (tags) =>
+  tags.find((t) => t[0] === "e" && t[3] === "root")?.[1] ?? null;
+
+test("buildChannelThreadRoots_groupsRootsByChannel", () => {
+  const items = [
+    replyItem("chan-a", "root-1"),
+    replyItem("chan-a", "root-1"), // dedup within a channel
+    replyItem("chan-a", "root-2"),
+    replyItem("chan-b", "root-3"),
+  ];
+  const map = buildChannelThreadRoots(items, getRootId);
+
+  assert.deepEqual([...(map.get("chan-a") ?? [])].sort(), ["root-1", "root-2"]);
+  assert.deepEqual([...(map.get("chan-b") ?? [])], ["root-3"]);
+  assert.equal(map.has("chan-c"), false);
+});
+
+test("buildChannelThreadRoots_skipsItemsWithNoRoot", () => {
+  const items = [{ id: "x", channelId: "chan-a", tags: [["p", "someone"]] }];
+  const map = buildChannelThreadRoots(items, getRootId);
+
+  assert.equal(map.size, 0);
+});
+
+test("channelUnreadFrontier_unopenedThreadReply_dotPersists", () => {
+  // Channel's only unread is a thread reply at t=500. The channel marker sits
+  // at the newest TOP-LEVEL message (t=300, Option-1) and the thread has never
+  // been opened (own marker null). Folded frontier stays at 300 < 500 → unread.
+  const channelMarker = 300;
+  const threadRoots = new Set(["root-1"]);
+  const getThreadOwnMarker = () => null; // never opened
+
+  const frontier = channelUnreadFrontier(
+    channelMarker,
+    threadRoots,
+    getThreadOwnMarker,
+  );
+
+  const latest = 500; // the thread reply timestamp
+  assert.equal(frontier, 300);
+  assert.equal(latest > frontier, true); // dot present (Will-accepted)
+});
+
+test("channelUnreadFrontier_openedThreadReply_dotClears", () => {
+  // Same channel, but the user opened the thread: markThreadRead advanced
+  // thread:root-1's OWN marker to 500 (the reply). Folded frontier rises to
+  // 500 ≥ latest → dot clears, even though the channel marker is still 300.
+  const channelMarker = 300;
+  const threadRoots = new Set(["root-1"]);
+  const getThreadOwnMarker = (rootId) => (rootId === "root-1" ? 500 : null);
+
+  const frontier = channelUnreadFrontier(
+    channelMarker,
+    threadRoots,
+    getThreadOwnMarker,
+  );
+
+  const latest = 500;
+  assert.equal(frontier, 500);
+  assert.equal(latest > frontier, false); // dot cleared
+});
+
+test("channelUnreadFrontier_noThreadRoots_usesChannelMarker", () => {
+  // No thread roots observed (or evicted) → channel marker governs unchanged.
+  assert.equal(
+    channelUnreadFrontier(300, undefined, () => null),
+    300,
+  );
+  assert.equal(
+    channelUnreadFrontier(null, undefined, () => null),
+    null,
+  );
+});
+
+test("channelUnreadFrontier_nullChannelMarker_threadMarkerGoverns", () => {
+  // Channel never marked read but a thread was opened: the thread's own marker
+  // becomes the frontier rather than crashing on the null channel marker.
+  const frontier = channelUnreadFrontier(null, new Set(["root-1"]), () => 500);
+  assert.equal(frontier, 500);
+});
+
+test("channelUnreadFrontier_takesMaxAcrossMultipleThreads", () => {
+  // Two opened threads with different markers → the highest wins.
+  const frontier = channelUnreadFrontier(
+    100,
+    new Set(["root-1", "root-2"]),
+    (rootId) => (rootId === "root-1" ? 400 : 700),
+  );
+  assert.equal(frontier, 700);
+});
diff --git a/desktop/src/features/channels/useChannelPaneHandlers.ts b/desktop/src/features/channels/useChannelPaneHandlers.ts
index 253f70be0b..b1d0fcaa2d 100644
--- a/desktop/src/features/channels/useChannelPaneHandlers.ts
+++ b/desktop/src/features/channels/useChannelPaneHandlers.ts
@@ -22,6 +22,8 @@ export function useChannelPaneHandlers({
   expandedThreadReplyIds,
   getFirstReplyIdForMessage,
   getReplyDescendantIdsForMessage,
+  getSubtreeMaxCreatedAt,
+  markThreadRead,
   openThreadHeadId,
   sendMessageMutation,
   setExpandedThreadReplyIds,
@@ -38,6 +40,8 @@ export function useChannelPaneHandlers({
   expandedThreadReplyIds: ReadonlySet<string>;
   getFirstReplyIdForMessage: (messageId: string) => string | null;
   getReplyDescendantIdsForMessage: (messageId: string) => string[];
+  getSubtreeMaxCreatedAt: (messageId: string) => number | null;
+  markThreadRead: (rootId: string, timestamp: number) => void;
   openThreadHeadId: string | null;
   sendMessageMutation: ReturnType<typeof useSendMessageMutation>;
   setExpandedThreadReplyIds: React.Dispatch<React.SetStateAction<Set<string>>>;
@@ -180,6 +184,17 @@ export function useChannelPaneHandlers({
         return next;
       });
 
+      // Drilling into a branch consumes its unread, persistently: advance the
+      // thread frontier to the branch's newest reply. Monotonic Math.max means
+      // this marks read everything chronologically up to it (channel-open
+      // parity). The open-time snapshot pins the session divider, so it never
+      // moves mid-session.
+      const rootId = openThreadHeadIdRef.current;
+      const subtreeMaxCreatedAt = getSubtreeMaxCreatedAt(message.id);
+      if (rootId && subtreeMaxCreatedAt !== null) {
+        markThreadRead(rootId, subtreeMaxCreatedAt);
+      }
+
       if (firstReplyId) {
         setThreadScrollTargetId(firstReplyId);
       }
@@ -187,6 +202,8 @@ export function useChannelPaneHandlers({
     [
       getFirstReplyIdForMessage,
       getReplyDescendantIdsForMessage,
+      getSubtreeMaxCreatedAt,
+      markThreadRead,
       setExpandedThreadReplyIds,
       setThreadScrollTargetId,
     ],
diff --git a/desktop/src/features/channels/useUnreadChannels.ts b/desktop/src/features/channels/useUnreadChannels.ts
index 7b81fbbb26..d9342c7726 100644
--- a/desktop/src/features/channels/useUnreadChannels.ts
+++ b/desktop/src/features/channels/useUnreadChannels.ts
@@ -213,6 +213,30 @@ function toUnixSeconds(isoOrMs: string | null | undefined): number | null {
   return ms === null ? null : Math.floor(ms / 1_000);
 }
 
+// Resolve where the read marker should land when a channel is marked read.
+// Folds the caller's timeline position together with the newest event this
+// client has observed live (`observedLatest`), so an explicit "mark read" still
+// covers messages that arrived faster than channel metadata — this fold is
+// load-bearing for the Esc shortcut, sidebar mark-read, and empty-channel open,
+// all of which pass a null/stale caller value. `clearObserved` reports whether
+// the resulting marker covers the observed timestamp, signalling the caller to
+// drop its observed refs so the unread memo sees `latest === undefined` until a
+// genuinely newer event arrives.
+export function resolveChannelReadMarker(
+  callerReadAt: string | null | undefined,
+  observedLatest: number | undefined,
+): { markAt: number | null; clearObserved: boolean } {
+  const callerUnix = toUnixSeconds(callerReadAt);
+  const markAt = Math.max(callerUnix ?? 0, observedLatest ?? 0) || null;
+  return {
+    markAt,
+    clearObserved:
+      markAt !== null &&
+      observedLatest !== undefined &&
+      observedLatest <= markAt,
+  };
+}
+
 function setsEqual(a: ReadonlySet<string>, b: ReadonlySet<string>): boolean {
   if (a.size !== b.size) return false;
   for (const item of a) {
@@ -221,10 +245,56 @@ function setsEqual(a: ReadonlySet<string>, b: ReadonlySet<string>): boolean {
   return true;
 }
 
+// Build channelId -> set of thread rootIds observed in that channel, derived
+// from the thread-activity log (the same items that feed latestByChannelRef).
+// Used by the sidebar unread scan to fold per-thread read markers into a
+// channel's effective frontier so opening a thread clears the channel dot.
+export function buildChannelThreadRoots(
+  items: readonly ThreadActivityItem[],
+  getRootId: (tags: string[][]) => string | null,
+): Map<string, Set<string>> {
+  const byChannel = new Map<string, Set<string>>();
+  for (const item of items) {
+    const rootId = getRootId(item.tags);
+    if (rootId === null) continue;
+    let roots = byChannel.get(item.channelId);
+    if (!roots) {
+      roots = new Set<string>();
+      byChannel.set(item.channelId, roots);
+    }
+    roots.add(rootId);
+  }
+  return byChannel;
+}
+
+// The channel's effective read frontier for sidebar-unread purposes: its own
+// channel marker folded with the highest OWN thread marker among its observed
+// thread roots. Using the thread OWN marker (not the hierarchical effective
+// value) is deliberate — the hierarchical resolver maps every thread to the
+// ACTIVE channel, so it would borrow the wrong marker for a background channel.
+// An unread reply in a thread keeps the dot until that thread is opened
+// (advancing the thread marker past the reply); a never-read thread (no own
+// marker) contributes nothing and the channel marker governs.
+export function channelUnreadFrontier(
+  channelMarker: number | null,
+  threadRoots: ReadonlySet<string> | undefined,
+  getThreadOwnMarker: (rootId: string) => number | null,
+): number | null {
+  let frontier = channelMarker;
+  if (threadRoots) {
+    for (const rootId of threadRoots) {
+      const own = getThreadOwnMarker(rootId);
+      if (own !== null && (frontier === null || own > frontier)) {
+        frontier = own;
+      }
+    }
+  }
+  return frontier;
+}
+
 export function useUnreadChannels(
   channels: Channel[],
   activeChannel: Channel | null,
-  activeReadAt?: string | null,
   options: UseUnreadChannelsOptions = {},
 ) {
   const {
@@ -234,20 +304,16 @@ export function useUnreadChannels(
     ...liveUpdateOptions
   } = options;
   const activeChannelId = activeChannel?.id ?? null;
-  const activeChannelLastMessageAt = activeChannel?.lastMessageAt ?? null;
   const normalizedPubkey = pubkey?.toLowerCase() ?? null;
 
-  // Let callers pass `null` to intentionally suppress the optimistic
-  // channel-metadata fallback until a real timeline position is known.
-  const effectiveActiveReadAt =
-    activeReadAt === undefined ? activeChannelLastMessageAt : activeReadAt;
-
   const {
     getEffectiveTimestamp,
     isReady: isReadStateReady,
     markContextRead,
     drainSyncedAdvances,
+    setContextParentResolver,
     readStateVersion,
+    getOwnTimestamp,
   } = useReadState(pubkey, relayClient);
 
   // Observed "latest external trigger event" per channel (unix seconds). This
@@ -336,23 +402,40 @@ export function useUnreadChannels(
     bumpLatestVersion();
   }, [pubkey, relayClient]);
 
+  // `topLevelOnly` is the passive channel-open path (NIP-RS Option 1): the
+  // caller's `readAt` is already the newest TOP-LEVEL message, so the marker
+  // must land exactly there without folding in `observedLatest` (which counts
+  // thread replies) and without clearing observed refs. Leaving the refs intact
+  // keeps the sidebar dot lit for a channel whose only unread is an unopened
+  // thread reply — viewing the channel no longer absorbs the reply, so the dot
+  // persists until an explicit mark-read (Esc, sidebar, mark-all) or a newer
+  // top-level message advances the channel marker past it. Those explicit
+  // "mark read" actions omit the flag and keep the fold, since they mean
+  // "clear everything in this channel."
   const markChannelRead = React.useCallback(
-    (channelId: string, readAt: string | null | undefined) => {
+    (
+      channelId: string,
+      readAt: string | null | undefined,
+      { topLevelOnly = false }: { topLevelOnly?: boolean } = {},
+    ) => {
       if (forcedUnreadRef.current.delete(channelId)) {
         bumpLatestVersion();
       }
-      const callerUnix = toUnixSeconds(readAt);
-      const observedLatest = latestByChannelRef.current.get(channelId);
-      const unixSeconds =
-        Math.max(callerUnix ?? 0, observedLatest ?? 0) || null;
-      if (unixSeconds === null) return;
-      markContextRead(channelId, unixSeconds);
+      const observedLatest = topLevelOnly
+        ? undefined
+        : latestByChannelRef.current.get(channelId);
+      const { markAt, clearObserved } = resolveChannelReadMarker(
+        readAt,
+        observedLatest,
+      );
+      if (markAt === null) return;
+      markContextRead(channelId, markAt);
       // Clear observed-latest refs when the read marker covers them so the
       // unread memo sees `latest === undefined` until a genuinely new event
       // arrives. Without this, `latest > readAt` resolves to `T > T` (false)
       // but the channel lingers in the set when advanceContext's monotonic
       // guard suppresses the readStateVersion bump.
-      if (observedLatest !== undefined && observedLatest <= unixSeconds) {
+      if (clearObserved) {
         latestByChannelRef.current.delete(channelId);
         latestHighPriorityByChannelRef.current.delete(channelId);
         bumpLatestVersion();
@@ -371,21 +454,6 @@ export function useUnreadChannels(
     }
   }, []);
 
-  // Mark the active channel as read when it changes or new messages arrive.
-  // Honours the caller's contract that a null activeReadAt suppresses
-  // read-marking until the timeline reports a real position. Manual
-  // mark-unread state is cleared inside markChannelRead, not here.
-  React.useEffect(() => {
-    if (!isReadStateReady) return;
-    if (!activeChannelId) return;
-    markChannelRead(activeChannelId, effectiveActiveReadAt);
-  }, [
-    activeChannelId,
-    effectiveActiveReadAt,
-    isReadStateReady,
-    markChannelRead,
-  ]);
-
   // Feed the in-session "latest external trigger" map from live channel
   // events. Composes with any caller-supplied onChannelMessage handler.
   // useLiveChannelUpdates already filters this callback to trigger kinds
@@ -763,6 +831,16 @@ export function useUnreadChannels(
       const unread = new Set<string>();
       const highPriority = new Set<string>();
 
+      // Map each channel to the thread roots observed in it, so a channel's
+      // frontier can fold in per-thread read markers (Option A): opening a
+      // thread advances thread:<root> and must clear the channel dot even
+      // though markChannelRead only advances the channel marker to the newest
+      // TOP-LEVEL message.
+      const threadRootsByChannel = buildChannelThreadRoots(
+        threadActivityRef.current,
+        (tags) => getThreadReference(tags).rootId,
+      );
+
       for (const channel of channels) {
         if (channel.id === activeChannelId) continue;
 
@@ -775,7 +853,11 @@ export function useUnreadChannels(
         const latest = latestByChannelRef.current.get(channel.id);
         if (latest === undefined) continue;
 
-        const readAt = getEffectiveTimestamp(channel.id);
+        const readAt = channelUnreadFrontier(
+          getEffectiveTimestamp(channel.id),
+          threadRootsByChannel.get(channel.id),
+          (rootId) => getOwnTimestamp(`thread:${rootId}`),
+        );
         if (readAt !== null && latest <= readAt) continue;
 
         unread.add(channel.id);
@@ -805,6 +887,7 @@ export function useUnreadChannels(
       activeChannelId,
       channels,
       getEffectiveTimestamp,
+      getOwnTimestamp,
       isReadStateReady,
       latestVersion,
       readStateVersion,
@@ -862,6 +945,7 @@ export function useUnreadChannels(
     // should include in memo deps.
     getEffectiveTimestamp,
     readStateVersion,
+    setContextParentResolver,
     participatedRootIds: participatedRootIdsRef.current as ReadonlySet<string>,
     authoredRootIds: authoredRootIdsRef.current as ReadonlySet<string>,
     threadActivityItems: threadActivityRef.current,
diff --git a/desktop/src/features/messages/lib/threadPanel.test.mjs b/desktop/src/features/messages/lib/threadPanel.test.mjs
index 09f0acc66f..f563bf4d48 100644
--- a/desktop/src/features/messages/lib/threadPanel.test.mjs
+++ b/desktop/src/features/messages/lib/threadPanel.test.mjs
@@ -2,10 +2,12 @@ import assert from "node:assert/strict";
 import test from "node:test";
 
 import {
+  buildDescendantStatsByMessageId,
   buildMainTimelineEntries,
   buildThreadPanelData,
   buildThreadPanelDataFromIndex,
   buildThreadPanelIndex,
+  shouldRenderUnreadDivider,
 } from "./threadPanel.ts";
 
 function message(overrides) {
@@ -103,6 +105,130 @@ test("buildThreadPanelData keeps direct comments unindented", () => {
   );
 });
 
+test("shouldRenderUnreadDivider_firstUnreadIsFirstRendered_suppressesDivider", () => {
+  // Fresh/never-read channel: the first message IS the first unread, nothing
+  // above it to separate from.
+  assert.equal(shouldRenderUnreadDivider(0, "a", "a"), false);
+});
+
+test("shouldRenderUnreadDivider_firstUnreadMidTimeline_rendersDivider", () => {
+  // Real read frontier: read messages above, unread starts at index 2.
+  assert.equal(shouldRenderUnreadDivider(2, "c", "c"), true);
+});
+
+test("shouldRenderUnreadDivider_firstUnreadIsFirstOfLaterDay_rendersDivider", () => {
+  // Multi-day timeline where the first unread is the first message of a later
+  // day group but not the first rendered entry overall — divider still marks
+  // the boundary.
+  assert.equal(
+    shouldRenderUnreadDivider(5, "later-day-head", "later-day-head"),
+    true,
+  );
+});
+
+test("shouldRenderUnreadDivider_nonMatchingEntry_noDivider", () => {
+  assert.equal(shouldRenderUnreadDivider(3, "x", "y"), false);
+});
+
+test("shouldRenderUnreadDivider_noUnread_noDivider", () => {
+  assert.equal(shouldRenderUnreadDivider(3, "x", null), false);
+});
+
+function spine(ids) {
+  // root -> ids[0] -> ids[1] -> ... each a single-child reply of the previous.
+  return ids.map((id, index) =>
+    message({
+      id,
+      createdAt: index + 2,
+      parentId: index === 0 ? "root" : ids[index - 1],
+      rootId: "root",
+      depth: index + 1,
+    }),
+  );
+}
+
+function unreadCounts(messages, unreadReplyIds) {
+  const stats = buildDescendantStatsByMessageId(messages, unreadReplyIds);
+  return Object.fromEntries(
+    [...stats].map(([id, stat]) => [id, stat.unreadDescendantCount]),
+  );
+}
+
+test("buildDescendantStatsByMessageId_deepUnreadUnderReadParent_bubblesToEveryAncestor", () => {
+  // root -> r1 -> r2 -> r3 -> r4, only the deepest reply (r4) is unread.
+  // The count must surface on every ancestor on the spine, not just r4's
+  // parent — this is the "deep unread under read parents" bug.
+  const root = message({ id: "root", createdAt: 1 });
+  const messages = [root, ...spine(["r1", "r2", "r3", "r4"])];
+
+  assert.deepEqual(unreadCounts(messages, new Set(["r4"])), {
+    root: 1,
+    r1: 1,
+    r2: 1,
+    r3: 1,
+    r4: 0,
+  });
+});
+
+test("buildDescendantStatsByMessageId_noUnreadReplies_allCountsZero", () => {
+  const root = message({ id: "root", createdAt: 1 });
+  const messages = [root, ...spine(["r1", "r2"])];
+
+  assert.deepEqual(unreadCounts(messages, new Set()), {
+    root: 0,
+    r1: 0,
+    r2: 0,
+  });
+});
+
+test("buildDescendantStatsByMessageId_siblingBranches_countedIndependently", () => {
+  // root has two independent branches: a (a1, unread) and b (b1, read).
+  // The unread must attribute to root + a1's chain, never to the b branch.
+  const root = message({ id: "root", createdAt: 1 });
+  const a1 = message({
+    id: "a1",
+    createdAt: 2,
+    parentId: "root",
+    rootId: "root",
+    depth: 1,
+  });
+  const a2 = message({
+    id: "a2",
+    createdAt: 3,
+    parentId: "a1",
+    rootId: "root",
+    depth: 2,
+  });
+  const b1 = message({
+    id: "b1",
+    createdAt: 4,
+    parentId: "root",
+    rootId: "root",
+    depth: 1,
+  });
+
+  assert.deepEqual(unreadCounts([root, a1, a2, b1], new Set(["a2"])), {
+    root: 1,
+    a1: 1,
+    a2: 0,
+    b1: 0,
+  });
+});
+
+test("buildDescendantStatsByMessageId_multipleUnreadOnSpine_accumulatesOnAncestors", () => {
+  // root -> r1 -> r2 -> r3, with r2 and r3 both unread. Each ancestor counts
+  // every unread descendant below it, so root sees 2 and r2 sees 1.
+  const root = message({ id: "root", createdAt: 1 });
+  const messages = [root, ...spine(["r1", "r2", "r3"])];
+
+  assert.deepEqual(unreadCounts(messages, new Set(["r2", "r3"])), {
+    root: 2,
+    r1: 2,
+    r2: 1,
+    r3: 0,
+  });
+});
+
 // Per-id stabilization: thread rows feed `MessageRow` a depth-normalized copy
 // of each reply. When `timelineMessages` churns (typing/presence) but the
 // reply objects survive by reference, rebuilding the thread panel must hand
diff --git a/desktop/src/features/messages/lib/threadPanel.ts b/desktop/src/features/messages/lib/threadPanel.ts
index 1634f87004..45fe72dfeb 100644
--- a/desktop/src/features/messages/lib/threadPanel.ts
+++ b/desktop/src/features/messages/lib/threadPanel.ts
@@ -26,8 +26,9 @@ export type MainTimelineEntry = {
   summary: TimelineThreadSummary | null;
 };
 
-type ThreadDescendantStats = {
+export type ThreadDescendantStats = {
   descendantCount: number;
+  unreadDescendantCount: number;
   lastReplyAt: number | null;
   recentParticipantsNewestFirst: TimelineThreadSummaryParticipant[];
 };
@@ -106,15 +107,19 @@ function buildDirectChildrenByParentId(messages: TimelineMessage[]) {
   return childrenByParentId;
 }
 
-function buildDescendantStatsByMessageId(
+export function buildDescendantStatsByMessageId(
   messages: TimelineMessage[],
-  messageById: Map<string, TimelineMessage>,
+  unreadReplyIds: ReadonlySet<string> = new Set(),
+  messageById: Map<string, TimelineMessage> = new Map(
+    messages.map((message) => [message.id, message]),
+  ),
 ): Map<string, ThreadDescendantStats> {
   const descendantStatsByMessageId = new Map<string, ThreadDescendantStats>(
     messages.map((message) => [
       message.id,
       {
         descendantCount: 0,
+        unreadDescendantCount: 0,
         lastReplyAt: null,
         recentParticipantsNewestFirst: [],
       },
@@ -143,6 +148,7 @@ function buildDescendantStatsByMessageId(
     let ancestorId = message.parentId ?? null;
     let hops = 0;
     const maxHops = messages.length + 1;
+    const isUnread = unreadReplyIds.has(message.id);
 
     while (ancestorId && hops < maxHops) {
       const ancestorStats = descendantStatsByMessageId.get(ancestorId);
@@ -151,6 +157,9 @@ function buildDescendantStatsByMessageId(
       }
 
       ancestorStats.descendantCount += 1;
+      if (isUnread) {
+        ancestorStats.unreadDescendantCount += 1;
+      }
       ancestorStats.lastReplyAt = Math.max(
         ancestorStats.lastReplyAt ?? 0,
         message.createdAt,
@@ -176,6 +185,7 @@ function buildDescendantStatsByMessageId(
 
 export function buildThreadPanelIndex(
   messages: TimelineMessage[],
+  unreadReplyIds: ReadonlySet<string> = new Set(),
 ): ThreadPanelIndex {
   const messageById = new Map(messages.map((message) => [message.id, message]));
 
@@ -183,6 +193,7 @@ export function buildThreadPanelIndex(
     directChildrenByParentId: buildDirectChildrenByParentId(messages),
     descendantStatsByMessageId: buildDescendantStatsByMessageId(
       messages,
+      unreadReplyIds,
       messageById,
     ),
     messageById,
@@ -274,8 +285,12 @@ function buildVisibleThreadReplies(params: {
 
 export function buildMainTimelineEntries(
   messages: TimelineMessage[],
+  unreadReplyIds: ReadonlySet<string> = new Set(),
 ): MainTimelineEntry[] {
-  const { descendantStatsByMessageId } = buildThreadPanelIndex(messages);
+  const { descendantStatsByMessageId } = buildThreadPanelIndex(
+    messages,
+    unreadReplyIds,
+  );
 
   return messages
     .filter(
@@ -293,6 +308,21 @@ export function buildMainTimelineEntries(
     });
 }
 
+/**
+ * Whether the unread "New" divider should render above the entry at `index`.
+ * The divider marks a read/unread boundary, so it only makes sense when there
+ * is a rendered message above the first unread. When the first unread is the
+ * first rendered top-level entry (index 0) — the fresh/never-read channel case
+ * — there is nothing above it to separate from, so the divider is suppressed.
+ */
+export function shouldRenderUnreadDivider(
+  index: number,
+  messageId: string,
+  firstUnreadMessageId: string | null,
+): boolean {
+  return index > 0 && messageId === firstUnreadMessageId;
+}
+
 export function buildThreadPanelDataFromIndex(
   index: ThreadPanelIndex,
   openThreadHeadId: string | null,
diff --git a/desktop/src/features/messages/lib/unreadMarker.test.mjs b/desktop/src/features/messages/lib/unreadMarker.test.mjs
new file mode 100644
index 0000000000..34ae291eee
--- /dev/null
+++ b/desktop/src/features/messages/lib/unreadMarker.test.mjs
@@ -0,0 +1,239 @@
+import assert from "node:assert/strict";
+import test from "node:test";
+
+import {
+  computeChannelUnreadMarker,
+  computeThreadUnreadMarker,
+} from "./unreadMarker.ts";
+
+function topLevel(id, createdAt) {
+  return { id, createdAt, author: "a", time: "", body: "", depth: 0 };
+}
+
+function reply(id, createdAt, parentId) {
+  return { id, createdAt, author: "a", time: "", body: "", depth: 1, parentId };
+}
+
+test("computeChannelUnreadMarker_emptyTimeline_returnsNoUnread", () => {
+  const marker = computeChannelUnreadMarker([], 100);
+  assert.equal(marker.firstUnreadMessageId, null);
+  assert.equal(marker.unreadCount, 0);
+});
+
+test("computeChannelUnreadMarker_nullFrontier_marksEveryTopLevelUnread", () => {
+  const messages = [topLevel("a", 10), topLevel("b", 20), topLevel("c", 30)];
+  const marker = computeChannelUnreadMarker(messages, null);
+  assert.equal(marker.firstUnreadMessageId, "a");
+  assert.equal(marker.unreadCount, 3);
+});
+
+test("computeChannelUnreadMarker_frontierBelowFirst_allUnread", () => {
+  const messages = [topLevel("a", 10), topLevel("b", 20)];
+  const marker = computeChannelUnreadMarker(messages, 5);
+  assert.equal(marker.firstUnreadMessageId, "a");
+  assert.equal(marker.unreadCount, 2);
+});
+
+test("computeChannelUnreadMarker_frontierBetweenMessages_marksOldestAfterFrontier", () => {
+  const messages = [topLevel("a", 10), topLevel("b", 20), topLevel("c", 30)];
+  const marker = computeChannelUnreadMarker(messages, 15);
+  assert.equal(marker.firstUnreadMessageId, "b");
+  assert.equal(marker.unreadCount, 2);
+});
+
+test("computeChannelUnreadMarker_frontierAtMessageTimestamp_isInclusive", () => {
+  // A message whose createdAt equals the frontier is considered read
+  // (strictly greater-than is unread), matching the read-marker semantics.
+  const messages = [topLevel("a", 10), topLevel("b", 20)];
+  const marker = computeChannelUnreadMarker(messages, 20);
+  assert.equal(marker.firstUnreadMessageId, null);
+  assert.equal(marker.unreadCount, 0);
+});
+
+test("computeChannelUnreadMarker_frontierAtLatest_returnsNoUnread", () => {
+  const messages = [topLevel("a", 10), topLevel("b", 20)];
+  const marker = computeChannelUnreadMarker(messages, 100);
+  assert.equal(marker.firstUnreadMessageId, null);
+  assert.equal(marker.unreadCount, 0);
+});
+
+test("computeChannelUnreadMarker_threadRepliesExcluded_onlyTopLevelCounted", () => {
+  // Thread replies (with parentId) are out of scope for the channel divider.
+  const messages = [
+    topLevel("root", 10),
+    reply("r1", 25, "root"),
+    topLevel("b", 30),
+  ];
+  const marker = computeChannelUnreadMarker(messages, 15);
+  assert.equal(marker.firstUnreadMessageId, "b");
+  assert.equal(marker.unreadCount, 1);
+});
+
+test("computeChannelUnreadMarker_unreadAfterReadReplies_picksTopLevel", () => {
+  // A newer reply does not become the divider target even if it is unread.
+  const messages = [topLevel("a", 10), topLevel("b", 20), reply("r1", 50, "a")];
+  const marker = computeChannelUnreadMarker(messages, 15);
+  assert.equal(marker.firstUnreadMessageId, "b");
+  assert.equal(marker.unreadCount, 1);
+});
+
+test("computeChannelUnreadMarker_suppressed_returnsNoMarkerDespiteUnread", () => {
+  // Manually marking the channel unread suppresses the in-timeline marker so
+  // the pill/divider do not contradict the sidebar dot. Messages that would
+  // otherwise be unread (frontier below them) produce nothing when suppressed.
+  const messages = [topLevel("a", 10), topLevel("b", 20)];
+  const marker = computeChannelUnreadMarker(messages, 5, true);
+  assert.equal(marker.firstUnreadMessageId, null);
+  assert.equal(marker.unreadCount, 0);
+});
+
+test("computeChannelUnreadMarker_suppressedNeverReadChannel_returnsNoMarker", () => {
+  // Suppression overrides the never-read (null frontier) case too.
+  const messages = [topLevel("a", 10), topLevel("b", 20)];
+  const marker = computeChannelUnreadMarker(messages, null, true);
+  assert.equal(marker.firstUnreadMessageId, null);
+  assert.equal(marker.unreadCount, 0);
+});
+
+// --- computeThreadUnreadMarker tests ---
+
+test("computeThreadUnreadMarker_emptyReplies_returnsNoUnread", () => {
+  const marker = computeThreadUnreadMarker([], 100);
+  assert.equal(marker.firstUnreadReplyId, null);
+  assert.equal(marker.unreadCount, 0);
+});
+
+test("computeThreadUnreadMarker_nullFrontier_marksAllRepliesUnread", () => {
+  const replies = [
+    { id: "r1", createdAt: 10 },
+    { id: "r2", createdAt: 20 },
+    { id: "r3", createdAt: 30 },
+  ];
+  const marker = computeThreadUnreadMarker(replies, null);
+  assert.equal(marker.firstUnreadReplyId, "r1");
+  assert.equal(marker.unreadCount, 3);
+});
+
+test("computeThreadUnreadMarker_frontierBetweenReplies_countsAfterFrontier", () => {
+  const replies = [
+    { id: "r1", createdAt: 10 },
+    { id: "r2", createdAt: 20 },
+    { id: "r3", createdAt: 30 },
+  ];
+  const marker = computeThreadUnreadMarker(replies, 15);
+  assert.equal(marker.firstUnreadReplyId, "r2");
+  assert.equal(marker.unreadCount, 2);
+});
+
+test("computeThreadUnreadMarker_frontierAtReplyTimestamp_isRead", () => {
+  // A reply whose createdAt equals the frontier is considered read (strictly >).
+  const replies = [
+    { id: "r1", createdAt: 10 },
+    { id: "r2", createdAt: 20 },
+  ];
+  const marker = computeThreadUnreadMarker(replies, 20);
+  assert.equal(marker.firstUnreadReplyId, null);
+  assert.equal(marker.unreadCount, 0);
+});
+
+test("computeThreadUnreadMarker_frontierAboveAll_returnsNoUnread", () => {
+  const replies = [
+    { id: "r1", createdAt: 10 },
+    { id: "r2", createdAt: 20 },
+  ];
+  const marker = computeThreadUnreadMarker(replies, 100);
+  assert.equal(marker.firstUnreadReplyId, null);
+  assert.equal(marker.unreadCount, 0);
+});
+
+test("computeThreadUnreadMarker_frontierBelowAll_allUnread", () => {
+  const replies = [
+    { id: "r1", createdAt: 10 },
+    { id: "r2", createdAt: 20 },
+  ];
+  const marker = computeThreadUnreadMarker(replies, 5);
+  assert.equal(marker.firstUnreadReplyId, "r1");
+  assert.equal(marker.unreadCount, 2);
+});
+
+test("computeThreadUnreadMarker_singleReplyUnread_countsOne", () => {
+  const replies = [
+    { id: "r1", createdAt: 10 },
+    { id: "r2", createdAt: 20 },
+    { id: "r3", createdAt: 30 },
+  ];
+  const marker = computeThreadUnreadMarker(replies, 25);
+  assert.equal(marker.firstUnreadReplyId, "r3");
+  assert.equal(marker.unreadCount, 1);
+});
+
+test("computeThreadUnreadMarker_emptyRepliesNullFrontier_returnsNoUnread", () => {
+  const marker = computeThreadUnreadMarker([], null);
+  assert.equal(marker.firstUnreadReplyId, null);
+  assert.equal(marker.unreadCount, 0);
+});
+
+// --- Self-authored skip tests ---
+
+test("computeChannelUnreadMarker_selfAuthored_skipsOwnMessages", () => {
+  const messages = [
+    { ...topLevel("a", 10), pubkey: "me" },
+    { ...topLevel("b", 20), pubkey: "other" },
+    { ...topLevel("c", 30), pubkey: "me" },
+  ];
+  const marker = computeChannelUnreadMarker(messages, 5, false, "me");
+  assert.equal(marker.firstUnreadMessageId, "b");
+  assert.equal(marker.unreadCount, 1);
+});
+
+test("computeChannelUnreadMarker_allSelfAuthored_returnsNoUnread", () => {
+  const messages = [
+    { ...topLevel("a", 10), pubkey: "me" },
+    { ...topLevel("b", 20), pubkey: "me" },
+  ];
+  const marker = computeChannelUnreadMarker(messages, 5, false, "me");
+  assert.equal(marker.firstUnreadMessageId, null);
+  assert.equal(marker.unreadCount, 0);
+});
+
+test("computeChannelUnreadMarker_noPubkey_countsNormally", () => {
+  // When currentPubkey is not provided, all messages count.
+  const messages = [
+    { ...topLevel("a", 10), pubkey: "me" },
+    { ...topLevel("b", 20), pubkey: "other" },
+  ];
+  const marker = computeChannelUnreadMarker(messages, 5);
+  assert.equal(marker.firstUnreadMessageId, "a");
+  assert.equal(marker.unreadCount, 2);
+});
+
+test("computeThreadUnreadMarker_selfAuthored_skipsOwnReplies", () => {
+  const replies = [
+    { id: "r1", createdAt: 10, pubkey: "me" },
+    { id: "r2", createdAt: 20, pubkey: "other" },
+    { id: "r3", createdAt: 30, pubkey: "me" },
+  ];
+  const marker = computeThreadUnreadMarker(replies, 5, "me");
+  assert.equal(marker.firstUnreadReplyId, "r2");
+  assert.equal(marker.unreadCount, 1);
+});
+
+test("computeThreadUnreadMarker_allSelfAuthored_returnsNoUnread", () => {
+  const replies = [
+    { id: "r1", createdAt: 10, pubkey: "me" },
+    { id: "r2", createdAt: 20, pubkey: "me" },
+  ];
+  const marker = computeThreadUnreadMarker(replies, 5, "me");
+  assert.equal(marker.firstUnreadReplyId, null);
+  assert.equal(marker.unreadCount, 0);
+});
+
+test("computeThreadUnreadMarker_noPubkey_countsNormally", () => {
+  const replies = [
+    { id: "r1", createdAt: 10, pubkey: "me" },
+    { id: "r2", createdAt: 20, pubkey: "other" },
+  ];
+  const marker = computeThreadUnreadMarker(replies, 5);
+  assert.equal(marker.firstUnreadReplyId, "r1");
+  assert.equal(marker.unreadCount, 2);
+});
diff --git a/desktop/src/features/messages/lib/unreadMarker.ts b/desktop/src/features/messages/lib/unreadMarker.ts
new file mode 100644
index 0000000000..34b67c527d
--- /dev/null
+++ b/desktop/src/features/messages/lib/unreadMarker.ts
@@ -0,0 +1,125 @@
+import type { TimelineMessage } from "@/features/messages/types";
+
+/**
+ * Identifies the first unread top-level channel message relative to a read
+ * frontier captured when the channel was opened.
+ *
+ * "Unread" is defined against the open-time frontier, not the live read
+ * marker: opening a channel immediately advances the live marker to latest,
+ * so the divider must be computed from the snapshot taken before that
+ * advance. Thread replies (messages with a parent) are out of scope here —
+ * the channel divider marks top-level messages only.
+ */
+export type ChannelUnreadMarker = {
+  /** Event id of the oldest unread top-level message, or null if none. */
+  firstUnreadMessageId: string | null;
+  /** Count of unread top-level messages at or after the first unread one. */
+  unreadCount: number;
+};
+
+const EMPTY_MARKER: ChannelUnreadMarker = {
+  firstUnreadMessageId: null,
+  unreadCount: 0,
+};
+
+/**
+ * @param messages Timeline messages in chronological order.
+ * @param frontierSeconds Read frontier in unix seconds captured at channel
+ *   open. `null` means the channel was never read, so every top-level message
+ *   counts as unread.
+ * @param suppressed When true, the channel was manually marked unread this
+ *   session; there is no meaningful in-timeline boundary, so no marker is
+ *   produced regardless of the frontier.
+ * @param currentPubkey When provided, messages authored by this pubkey are
+ *   never counted as unread (the user knows about their own posts).
+ */
+export function computeChannelUnreadMarker(
+  messages: TimelineMessage[],
+  frontierSeconds: number | null,
+  suppressed = false,
+  currentPubkey?: string,
+): ChannelUnreadMarker {
+  if (suppressed) {
+    return EMPTY_MARKER;
+  }
+
+  let firstUnreadMessageId: string | null = null;
+  let unreadCount = 0;
+
+  for (const message of messages) {
+    if (message.parentId) {
+      continue;
+    }
+    if (currentPubkey && message.pubkey === currentPubkey) {
+      continue;
+    }
+    const isUnread =
+      frontierSeconds === null || message.createdAt > frontierSeconds;
+    if (!isUnread) {
+      continue;
+    }
+    if (firstUnreadMessageId === null) {
+      firstUnreadMessageId = message.id;
+    }
+    unreadCount += 1;
+  }
+
+  return firstUnreadMessageId === null
+    ? EMPTY_MARKER
+    : { firstUnreadMessageId, unreadCount };
+}
+
+/**
+ * Thread-scoped unread marker. Counts replies newer than the thread read
+ * frontier and identifies the first unread reply.
+ *
+ * Unlike the channel marker, every entry is a reply (no parentId filter) and
+ * there is no suppression mechanism.
+ */
+export type ThreadUnreadMarker = {
+  /** Event id of the oldest unread reply, or null if none. */
+  firstUnreadReplyId: string | null;
+  /** Count of unread replies at or after the first unread one. */
+  unreadCount: number;
+};
+
+const EMPTY_THREAD_MARKER: ThreadUnreadMarker = {
+  firstUnreadReplyId: null,
+  unreadCount: 0,
+};
+
+/**
+ * @param replies Thread replies in chronological order.
+ * @param frontierSeconds Read frontier in unix seconds captured at thread
+ *   open. `null` means the thread was never read, so every reply counts as
+ *   unread.
+ * @param currentPubkey When provided, replies authored by this pubkey are
+ *   never counted as unread (the user knows about their own posts).
+ */
+export function computeThreadUnreadMarker(
+  replies: Pick<TimelineMessage, "id" | "createdAt" | "pubkey">[],
+  frontierSeconds: number | null,
+  currentPubkey?: string,
+): ThreadUnreadMarker {
+  let firstUnreadReplyId: string | null = null;
+  let unreadCount = 0;
+
+  for (const reply of replies) {
+    if (currentPubkey && reply.pubkey === currentPubkey) {
+      continue;
+    }
+    const isUnread =
+      frontierSeconds === null || reply.createdAt > frontierSeconds;
+    if (!isUnread) {
+      continue;
+    }
+    if (firstUnreadReplyId === null) {
+      firstUnreadReplyId = reply.id;
+    }
+    unreadCount += 1;
+  }
+
+  return firstUnreadReplyId === null
+    ? EMPTY_THREAD_MARKER
+    : { firstUnreadReplyId, unreadCount };
+}
diff --git a/desktop/src/features/messages/ui/MessageThreadPanel.tsx b/desktop/src/features/messages/ui/MessageThreadPanel.tsx
index 18cb78379b..ab61b070a0 100644
--- a/desktop/src/features/messages/ui/MessageThreadPanel.tsx
+++ b/desktop/src/features/messages/ui/MessageThreadPanel.tsx
@@ -29,6 +29,7 @@ import { MessageComposer } from "./MessageComposer";
 import { MessageRow } from "./MessageRow";
 import { MessageThreadSummaryRow } from "./MessageThreadSummaryRow";
 import { TypingIndicatorRow } from "./TypingIndicatorRow";
+import { UnreadDivider } from "./UnreadDivider";
 import { useComposerHeightPadding } from "./useComposerHeightPadding";
 import { useTimelineScrollManager } from "./useTimelineScrollManager";
 import { selectDeferredListRenderState } from "@/features/messages/lib/timelineSnapshot";
@@ -40,6 +41,8 @@ type MessageThreadPanelProps = {
   channelName: string;
   currentPubkey?: string;
   disabled?: boolean;
+  /** Event id of the first unread reply, or null/undefined if all read. */
+  firstUnreadReplyId?: string | null;
   layout?: "standalone" | "split";
   editTarget?: {
     author: string;
@@ -75,6 +78,8 @@ type MessageThreadPanelProps = {
   scrollTargetId: string | null;
   threadHead: TimelineMessage | null;
   threadReplies: MainTimelineEntry[];
+  /** Subtree unread counts for collapsed summary rows, keyed by reply id. */
+  threadReplyUnreadCounts?: ReadonlyMap<string, number>;
   threadTypingPubkeys: string[];
   threadHeadVideoReviewContext?: VideoReviewContext;
   toolbarExtraActions?: React.ReactNode;
@@ -268,6 +273,7 @@ export function MessageThreadPanel({
   channelName,
   currentPubkey,
   disabled = false,
+  firstUnreadReplyId,
   layout = "standalone",
   editTarget,
   isSending,
@@ -294,6 +300,7 @@ export function MessageThreadPanel({
   threadHead,
   threadHeadVideoReviewContext,
   threadReplies,
+  threadReplyUnreadCounts,
   threadTypingPubkeys,
   toolbarExtraActions,
   widthPx,
@@ -430,7 +437,9 @@ export function MessageThreadPanel({
               )}
               data-render-pending={isRepliesPending ? "true" : undefined}
             >
-              {deferredThreadReplies.map((entry) => {
+              {deferredThreadReplies.map((entry, index) => {
+                const showUnreadDivider =
+                  index > 0 && entry.message.id === firstUnreadReplyId;
                 return (
                   <div
                     className={cn(
@@ -440,6 +449,7 @@ export function MessageThreadPanel({
                     )}
                     key={entry.message.renderKey ?? entry.message.id}
                   >
+                    {showUnreadDivider ? <UnreadDivider /> : null}
                     <MessageRow
                       agentPubkeys={agentPubkeys}
                       channelId={channelId}
@@ -468,6 +478,9 @@ export function MessageThreadPanel({
                         message={entry.message}
                         onOpenThread={onExpandReplies}
                         summary={entry.summary}
+                        unreadCount={threadReplyUnreadCounts?.get(
+                          entry.message.id,
+                        )}
                       />
                     ) : null}
                   </div>
diff --git a/desktop/src/features/messages/ui/MessageThreadSummaryRow.tsx b/desktop/src/features/messages/ui/MessageThreadSummaryRow.tsx
index 3ab72ceeb7..41d2837dfc 100644
--- a/desktop/src/features/messages/ui/MessageThreadSummaryRow.tsx
+++ b/desktop/src/features/messages/ui/MessageThreadSummaryRow.tsx
@@ -48,12 +48,14 @@ export function MessageThreadSummaryRow({
   onOpenThread,
   showDepthGuides = true,
   summary,
+  unreadCount,
 }: {
   depth?: number;
   message: TimelineMessage;
   onOpenThread: (message: TimelineMessage) => void;
   showDepthGuides?: boolean;
   summary: TimelineThreadSummary;
+  unreadCount?: number;
 }) {
   const visibleDepth = Math.min(Math.max(depth, 0), 6);
   const indentPx =
@@ -121,6 +123,14 @@ export function MessageThreadSummaryRow({
             <span className="font-medium transition-colors group-hover:text-foreground">
               {summary.replyCount} {replyLabel}
             </span>
+            {unreadCount != null && unreadCount > 0 ? (
+              <span
+                className="ml-1.5 inline-flex h-4 min-w-4 items-center justify-center rounded-full bg-primary px-1 text-[10px] font-semibold leading-none text-primary-foreground"
+                data-testid="thread-unread-badge"
+              >
+                {unreadCount}
+              </span>
+            ) : null}
             {summary.lastReplyAt ? (
               <>
                 <span className="mx-1 font-normal text-muted-foreground/50">
diff --git a/desktop/src/features/messages/ui/MessageTimeline.tsx b/desktop/src/features/messages/ui/MessageTimeline.tsx
index 27f025bc84..31dd17e970 100644
--- a/desktop/src/features/messages/ui/MessageTimeline.tsx
+++ b/desktop/src/features/messages/ui/MessageTimeline.tsx
@@ -1,5 +1,5 @@
 import * as React from "react";
-import { ArrowDown, Hash } from "lucide-react";
+import { ArrowDown, ArrowUp, Hash } from "lucide-react";
 
 import { getDmParticipantPreview } from "@/features/channels/lib/dmParticipantDisplay";
 import type { TimelineMessage } from "@/features/messages/types";
@@ -72,6 +72,12 @@ type MessageTimelineProps = {
   searchQuery?: string;
   targetMessageId?: string | null;
   onTargetReached?: (messageId: string) => void;
+  /** Event id of the oldest unread top-level message at channel open, or null. */
+  firstUnreadMessageId?: string | null;
+  /** Count of unread top-level messages at channel open. */
+  unreadCount?: number;
+  /** Per-thread unread counts keyed by thread root id. */
+  threadUnreadCounts?: ReadonlyMap<string, number>;
 };
 
 type ChannelIntroAction = {
@@ -136,6 +142,9 @@ export const MessageTimeline = React.memo(function MessageTimeline({
   searchQuery,
   targetMessageId = null,
   onTargetReached,
+  firstUnreadMessageId = null,
+  unreadCount = 0,
+  threadUnreadCounts,
 }: MessageTimelineProps) {
   const internalScrollRef = React.useRef<HTMLDivElement>(null);
   const scrollContainerRef = externalScrollRef ?? internalScrollRef;
@@ -166,6 +175,7 @@ export const MessageTimeline = React.memo(function MessageTimeline({
     newMessageCount,
     restoreScrollPosition,
     scrollToBottom,
+    scrollToMessage,
     syncScrollState,
   } = useTimelineScrollManager({
     channelId,
@@ -176,6 +186,39 @@ export const MessageTimeline = React.memo(function MessageTimeline({
     targetMessageId,
   });
 
+  // The unread pill is a transient, per-open affordance: dismiss it once the
+  // user acts on it (jumps to the oldest unread) or catches up by reaching the
+  // bottom of the timeline. Reset when the channel changes so a freshly opened
+  // channel shows its own pill.
+  const [isUnreadPillDismissed, setIsUnreadPillDismissed] =
+    React.useState(false);
+  // Track whether the pill has been shown at least once this channel visit.
+  // This prevents the dismiss effect from firing on mount (when isAtBottom
+  // initializes as true) before the pill ever renders.
+  const hasShownPillRef = React.useRef(false);
+  // biome-ignore lint/correctness/useExhaustiveDependencies: reset on channel switch only
+  React.useEffect(() => {
+    setIsUnreadPillDismissed(false);
+    hasShownPillRef.current = false;
+  }, [channelId]);
+  React.useEffect(() => {
+    if (isAtBottom && hasShownPillRef.current) {
+      setIsUnreadPillDismissed(true);
+    }
+  }, [isAtBottom]);
+  const showUnreadPill =
+    !isUnreadPillDismissed &&
+    unreadCount > 0 &&
+    firstUnreadMessageId !== null &&
+    !isLoading;
+  if (showUnreadPill) hasShownPillRef.current = true;
+  const handleJumpToOldestUnread = React.useCallback(() => {
+    setIsUnreadPillDismissed(true);
+    if (firstUnreadMessageId) {
+      scrollToMessage(firstUnreadMessageId);
+    }
+  }, [firstUnreadMessageId, scrollToMessage]);
+
   // Scroll to the active search match when it changes.
   const prevSearchActiveRef = React.useRef<string | null>(null);
   // biome-ignore lint/correctness/useExhaustiveDependencies: scrollContainerRef is a stable React ref
@@ -228,6 +271,26 @@ export const MessageTimeline = React.memo(function MessageTimeline({
   return (
     <TooltipProvider delayDuration={200}>
       <div className="relative flex min-h-0 min-w-0 flex-1 flex-col overflow-hidden">
+        {showUnreadPill ? (
+          <div
+            className={cn(
+              "pointer-events-none absolute inset-x-0 z-20 flex justify-center px-4",
+              channelChrome.top,
+            )}
+          >
+            <Button
+              className="pointer-events-auto h-7 min-h-7 gap-1.5 rounded-full border-primary/40 bg-primary/10 px-2.5 text-[11px] font-medium text-primary shadow-xs backdrop-blur-sm hover:bg-primary/20 [&_svg]:size-3.5"
+              data-testid="message-unread-pill"
+              onClick={handleJumpToOldestUnread}
+              size="sm"
+              type="button"
+              variant="outline"
+            >
+              <ArrowUp aria-hidden />
+              {`${unreadCount} new message${unreadCount === 1 ? "" : "s"}`}
+            </Button>
+          </div>
+        ) : null}
         <div
           className={cn(
             "absolute inset-0 overflow-y-auto overflow-x-hidden overscroll-contain px-4 pt-1 [overflow-anchor:none] sm:px-6",
@@ -413,6 +476,7 @@ export const MessageTimeline = React.memo(function MessageTimeline({
                     channelName={channelName}
                     channelType={channelType}
                     currentPubkey={currentPubkey}
+                    firstUnreadMessageId={firstUnreadMessageId}
                     followThreadById={followThreadById}
                     highlightedMessageId={highlightedMessageId}
                     isFollowingThreadById={isFollowingThreadById}
@@ -430,6 +494,7 @@ export const MessageTimeline = React.memo(function MessageTimeline({
                     searchActiveMessageId={searchActiveMessageId}
                     searchMatchingMessageIds={searchMatchingMessageIds}
                     searchQuery={searchQuery}
+                    threadUnreadCounts={threadUnreadCounts}
                     unfollowThreadById={unfollowThreadById}
                   />
                 </div>
diff --git a/desktop/src/features/messages/ui/TimelineMessageList.tsx b/desktop/src/features/messages/ui/TimelineMessageList.tsx
index caf6a42eb5..758b904d2c 100644
--- a/desktop/src/features/messages/ui/TimelineMessageList.tsx
+++ b/desktop/src/features/messages/ui/TimelineMessageList.tsx
@@ -1,7 +1,10 @@
 import * as React from "react";
 
 import { formatDayHeading } from "@/features/messages/lib/dateFormatters";
-import { buildMainTimelineEntries } from "@/features/messages/lib/threadPanel";
+import {
+  buildMainTimelineEntries,
+  shouldRenderUnreadDivider,
+} from "@/features/messages/lib/threadPanel";
 import {
   buildVideoReviewCommentsByRootId,
   buildVideoReviewContextForMessage,
@@ -16,6 +19,7 @@ import { DayDivider } from "./DayDivider";
 import { MessageRow } from "./MessageRow";
 import { MessageThreadSummaryRow } from "./MessageThreadSummaryRow";
 import { SystemMessageRow } from "./SystemMessageRow";
+import { UnreadDivider } from "./UnreadDivider";
 
 type TimelineMessageListProps = {
   agentPubkeys?: ReadonlySet<string>;
@@ -23,6 +27,8 @@ type TimelineMessageListProps = {
   channelName?: string;
   channelType?: ChannelType | null;
   currentPubkey?: string;
+  /** Event id of the oldest unread top-level message; renders a "New" divider above it. */
+  firstUnreadMessageId?: string | null;
   followThreadById?: (rootId: string) => void;
   highlightedMessageId?: string | null;
   isFollowingThreadById?: (rootId: string) => boolean;
@@ -55,6 +61,8 @@ type TimelineMessageListProps = {
   searchMatchingMessageIds?: Set<string>;
   /** The current find-in-channel query string. */
   searchQuery?: string;
+  /** Per-thread unread counts keyed by thread root id. */
+  threadUnreadCounts?: ReadonlyMap<string, number>;
 };
 
 export const TimelineMessageList = React.memo(function TimelineMessageList({
@@ -63,6 +71,7 @@ export const TimelineMessageList = React.memo(function TimelineMessageList({
   channelName,
   channelType,
   currentPubkey,
+  firstUnreadMessageId = null,
   followThreadById,
   highlightedMessageId = null,
   isFollowingThreadById,
@@ -80,6 +89,7 @@ export const TimelineMessageList = React.memo(function TimelineMessageList({
   searchActiveMessageId = null,
   searchMatchingMessageIds,
   searchQuery,
+  threadUnreadCounts,
   unfollowThreadById,
 }: TimelineMessageListProps) {
   const entries = React.useMemo(
@@ -158,6 +168,16 @@ export const TimelineMessageList = React.memo(function TimelineMessageList({
       dayGroups.push(currentDayGroup);
     }
 
+    // The unread "New" divider only marks a read/unread boundary when there is
+    // a message above the first unread. When the first unread is the first
+    // rendered top-level entry (fresh/never-read channel), there is nothing
+    // above to separate from, so it is suppressed.
+    if (shouldRenderUnreadDivider(i, message.id, firstUnreadMessageId)) {
+      currentDayGroup?.elements.push(
+        <UnreadDivider key={`unread-${messageRenderKey}`} />,
+      );
+    }
+
     if (message.kind === KIND_SYSTEM_MESSAGE) {
       const footer = messageFooters?.[message.id] ?? null;
       currentDayGroup?.elements.push(
@@ -227,6 +247,7 @@ export const TimelineMessageList = React.memo(function TimelineMessageList({
             onOpenThread={onReply}
             showDepthGuides={false}
             summary={summary}
+            unreadCount={threadUnreadCounts?.get(message.id)}
           />
           {footer}
         </div>,
diff --git a/desktop/src/features/messages/ui/UnreadDivider.tsx b/desktop/src/features/messages/ui/UnreadDivider.tsx
new file mode 100644
index 0000000000..5994f08fc2
--- /dev/null
+++ b/desktop/src/features/messages/ui/UnreadDivider.tsx
@@ -0,0 +1,20 @@
+/**
+ * Inline "New" divider rendered directly above the oldest unread top-level
+ * message, mirroring Slack's read/unread boundary. Computed from the
+ * channel's read frontier as it stood when the channel was opened.
+ */
+export function UnreadDivider() {
+  return (
+    <section
+      aria-label="New messages"
+      className="relative flex items-center py-1"
+      data-testid="message-unread-divider"
+    >
+      <div className="h-px flex-1 bg-primary/40" />
+      <span className="shrink-0 px-2 text-[10px] font-semibold uppercase tracking-[0.04em] text-primary">
+        New
+      </span>
+      <div className="h-px flex-1 bg-primary/40" />
+    </section>
+  );
+}
diff --git a/desktop/src/features/messages/ui/useTimelineScrollManager.ts b/desktop/src/features/messages/ui/useTimelineScrollManager.ts
index a9ee2485af..f8077efffc 100644
--- a/desktop/src/features/messages/ui/useTimelineScrollManager.ts
+++ b/desktop/src/features/messages/ui/useTimelineScrollManager.ts
@@ -346,6 +346,55 @@ export function useTimelineScrollManager({
     unpinFromBottom,
   ]);
 
+  // biome-ignore lint/correctness/useExhaustiveDependencies: timelineRef is a stable React ref — its identity never changes
+  const scrollToMessage = React.useCallback(
+    (messageId: string) => {
+      const timeline = timelineRef.current;
+      if (!timeline) {
+        return false;
+      }
+
+      const targetElement = timeline.querySelector<HTMLElement>(
+        `[data-message-id="${messageId}"]`,
+      );
+      if (!targetElement) {
+        return false;
+      }
+
+      unpinFromBottom(timeline.scrollTop);
+      setHighlightedMessageId(messageId);
+      setNewMessageCount(0);
+
+      const alignToTarget = (remainingFrames: number) => {
+        targetElement.scrollIntoView({
+          block: "center",
+          behavior: "auto",
+        });
+        previousScrollTopRef.current = timeline.scrollTop;
+
+        if (remainingFrames > 0) {
+          requestAnimationFrame(() => {
+            alignToTarget(remainingFrames - 1);
+          });
+          return;
+        }
+
+        onTargetReached?.(messageId);
+      };
+
+      alignToTarget(2);
+
+      window.setTimeout(() => {
+        setHighlightedMessageId((current) =>
+          current === messageId ? null : current,
+        );
+      }, 2_000);
+
+      return true;
+    },
+    [onTargetReached, unpinFromBottom],
+  );
+
   // biome-ignore lint/correctness/useExhaustiveDependencies: timelineRef is a stable React ref — its identity never changes
   React.useEffect(() => {
     if (!targetMessageId) {
@@ -367,52 +416,12 @@ export function useTimelineScrollManager({
       return;
     }
 
-    const timeline = timelineRef.current;
-    if (!timeline) {
-      return;
-    }
-
-    const targetElement = timeline.querySelector<HTMLElement>(
-      `[data-message-id="${targetMessageId}"]`,
-    );
-    if (!targetElement) {
+    if (!scrollToMessage(targetMessageId)) {
       return;
     }
 
     handledTargetMessageIdRef.current = targetMessageId;
-    unpinFromBottom(timeline.scrollTop);
-    setHighlightedMessageId(targetMessageId);
-    setNewMessageCount(0);
-
-    const alignToTarget = (remainingFrames: number) => {
-      targetElement.scrollIntoView({
-        block: "center",
-        behavior: "auto",
-      });
-      previousScrollTopRef.current = timeline.scrollTop;
-
-      if (remainingFrames > 0) {
-        requestAnimationFrame(() => {
-          alignToTarget(remainingFrames - 1);
-        });
-        return;
-      }
-
-      onTargetReached?.(targetMessageId);
-    };
-
-    alignToTarget(2);
-
-    const timeout = window.setTimeout(() => {
-      setHighlightedMessageId((current) =>
-        current === targetMessageId ? null : current,
-      );
-    }, 2_000);
-
-    return () => {
-      window.clearTimeout(timeout);
-    };
-  }, [isLoading, messages, onTargetReached, targetMessageId, unpinFromBottom]);
+  }, [isLoading, messages, scrollToMessage, targetMessageId]);
 
   return {
     bottomAnchorRef,
@@ -422,6 +431,7 @@ export function useTimelineScrollManager({
     newMessageCount,
     restoreScrollPosition,
     scrollToBottom,
+    scrollToMessage,
     syncScrollState,
   };
 }
diff --git a/desktop/src/testing/e2eBridge.ts b/desktop/src/testing/e2eBridge.ts
index b8afdef205..69bc2f5144 100644
--- a/desktop/src/testing/e2eBridge.ts
+++ b/desktop/src/testing/e2eBridge.ts
@@ -580,6 +580,7 @@ declare global {
       kind?: number;
       mentionPubkeys?: string[];
       extraTags?: string[][];
+      createdAt?: number;
     }) => RelayEvent;
     __BUZZ_E2E_EMIT_MOCK_TYPING__?: (input: {
       channelName: string;
@@ -1216,6 +1217,38 @@ const mockChannels: MockChannel[] = [
       createMockMember(BOB_PUBKEY, "member", 1000),
     ],
   }),
+  // Reproduces the all-replies-window regression (NIP-RS Fix A): a busy
+  // single-thread channel whose top-level root has scrolled past the history
+  // limit. `last_message_at` is a far-future timestamp standing in for the
+  // backend's reply-inclusive MAX(created_at) — it is NEWER than any top-level
+  // message the window can load, so falling back to it would advance the
+  // channel marker past unread replies. Seeded as its own channel so existing
+  // channels' unread state is undisturbed.
+  createMockChannel({
+    id: "fa11bac0-0000-4000-8000-000000000012",
+    name: "all-replies",
+    channel_type: "stream",
+    visibility: "open",
+    description: "Single-thread channel with the root past the history limit",
+    topic: null,
+    purpose: null,
+    last_message_at: new Date("2999-01-01T00:00:00.000Z").toISOString(),
+    archived_at: null,
+    created_by: ALICE_PUBKEY,
+    topic_set_by: null,
+    topic_set_at: null,
+    purpose_set_by: null,
+    purpose_set_at: null,
+    topic_required: false,
+    max_members: null,
+    nip29_group_id: null,
+    created_minutes_ago: 1400,
+    updated_minutes_ago: 1400,
+    members: [
+      createMockMember(ALICE_PUBKEY, "owner", 1400),
+      createMockMember(MOCK_IDENTITY_PUBKEY, "member", 1300),
+    ],
+  }),
   createMockChannel({
     id: "b5e2f8a1-3c44-5912-9e67-4a8d1f2b3c4e",
     name: "design",
@@ -2304,6 +2337,7 @@ function emitMockChannelMessage(
   kind?: number,
   mentionPubkeys?: string[],
   extraTags?: string[][],
+  createdAt?: number,
 ) {
   const eventKind = kind ?? 9;
   if (!parentEventId) {
@@ -2313,7 +2347,7 @@ function emitMockChannelMessage(
       pubkey ?? DEFAULT_MOCK_IDENTITY.pubkey,
     );
     if (extraTags) tags.push(...extraTags);
-    const event = createMockEvent(eventKind, content, tags, pubkey);
+    const event = createMockEvent(eventKind, content, tags, pubkey, createdAt);
     recordMockMessage(channelId, event);
     emitMockLiveEvent(channelId, event);
     return event;
@@ -2338,7 +2372,13 @@ function emitMockChannelMessage(
     mentionPubkeys,
   );
   if (extraTags) tags.push(...extraTags);
-  const event = createMockEvent(eventKind, content, tags, authorPubkey);
+  const event = createMockEvent(
+    eventKind,
+    content,
+    tags,
+    authorPubkey,
+    createdAt,
+  );
   recordMockMessage(channelId, event);
   emitMockLiveEvent(channelId, event);
   return event;
@@ -5824,6 +5864,7 @@ export function maybeInstallE2eTauriMocks() {
     kind,
     mentionPubkeys,
     extraTags,
+    createdAt,
   }) => {
     const channel = mockChannels.find(
       (candidate) => candidate.name === channelName,
@@ -5840,6 +5881,7 @@ export function maybeInstallE2eTauriMocks() {
       kind,
       mentionPubkeys,
       extraTags,
+      createdAt,
     );
   };
   window.__BUZZ_E2E_EMIT_MOCK_TYPING__ = ({ channelName, pubkey }) => {
diff --git a/desktop/tests/e2e/mentions.spec.ts b/desktop/tests/e2e/mentions.spec.ts
index bf403352ad..7829746cff 100644
--- a/desktop/tests/e2e/mentions.spec.ts
+++ b/desktop/tests/e2e/mentions.spec.ts
@@ -84,6 +84,15 @@ async function waitForMockLiveSubscription(
     .toBe(true);
 }
 
+// The channel timeline renders off a `useDeferredValue` snapshot that lags the
+// latest `messages` by a commit; the list wrapper carries
+// `data-render-pending="true"` while that commit is in flight and drops the
+// attribute once it settles. Poll for its absence before asserting on
+// freshly-sent content so the assertion does not race the deferred commit.
+async function waitForTimelineSettled(page: import("@playwright/test").Page) {
+  await expect(page.locator("[data-render-pending]")).toHaveCount(0);
+}
+
 test("@ trigger shows unified autocomplete with agents first", async ({
   page,
 }) => {
@@ -991,6 +1000,8 @@ test("mention text is highlighted in sent messages", async ({ page }) => {
   await page.keyboard.type(suffix);
   await page.getByTestId("send-message").click();
 
+  await waitForTimelineSettled(page);
+
   const mentionChip = page
     .getByTestId("message-row")
     .last()
diff --git a/desktop/tests/e2e/messaging.spec.ts b/desktop/tests/e2e/messaging.spec.ts
index a31313b8ca..756f53bcd6 100644
--- a/desktop/tests/e2e/messaging.spec.ts
+++ b/desktop/tests/e2e/messaging.spec.ts
@@ -404,7 +404,7 @@ test("opens a single-level thread panel with inline expansion", async ({
   const siblingReply = `Sibling threaded reply ${timestamp}`;
   const nestedReply = `Nested threaded reply ${timestamp}`;
   const nestedReplyFromBob = `Nested reply from Bob ${timestamp}`;
-  const nestedReplyVisibleTopMaxPx = 280;
+  const nestedReplyVisibleTopMaxPx = 300;
   const fillerReplies = Array.from(
     { length: 14 },
     (_, index) => `Thread filler reply ${index} ${timestamp}`,
diff --git a/desktop/tests/e2e/thread-unread-screenshots.spec.ts b/desktop/tests/e2e/thread-unread-screenshots.spec.ts
new file mode 100644
index 0000000000..5f8fe4dbc2
--- /dev/null
+++ b/desktop/tests/e2e/thread-unread-screenshots.spec.ts
@@ -0,0 +1,776 @@
+import { expect, test } from "@playwright/test";
+
+import { TEST_IDENTITIES, installMockBridge } from "../helpers/bridge";
+
+const SHOTS = "test-results/thread-unread";
+
+async function waitForMockLiveSubscription(
+  page: import("@playwright/test").Page,
+  channelName: string,
+) {
+  await expect
+    .poll(async () => {
+      return page.evaluate(
+        ({ ch }) =>
+          (
+            window as Window & {
+              __BUZZ_E2E_HAS_MOCK_LIVE_SUBSCRIPTION__?: (input: {
+                channelName: string;
+              }) => boolean;
+            }
+          ).__BUZZ_E2E_HAS_MOCK_LIVE_SUBSCRIPTION__?.({ channelName: ch }) ??
+          false,
+        { ch: channelName },
+      );
+    })
+    .toBe(true);
+}
+
+function emitMockMessage(
+  page: import("@playwright/test").Page,
+  channelName: string,
+  content: string,
+  options?: {
+    parentEventId?: string;
+    pubkey?: string;
+    createdAt?: number;
+    mentionPubkeys?: string[];
+  },
+) {
+  return page.evaluate(
+    ({ ch, msg, parentEventId, pubkey, ts, mentionPubkeys }) => {
+      return (
+        window as Window & {
+          __BUZZ_E2E_EMIT_MOCK_MESSAGE__?: (input: {
+            channelName: string;
+            content: string;
+            parentEventId?: string | null;
+            pubkey?: string;
+            createdAt?: number;
+            mentionPubkeys?: string[];
+          }) => { id: string; created_at: number; pubkey: string };
+        }
+      ).__BUZZ_E2E_EMIT_MOCK_MESSAGE__?.({
+        channelName: ch,
+        content: msg,
+        parentEventId: parentEventId ?? undefined,
+        pubkey: pubkey ?? undefined,
+        createdAt: ts,
+        mentionPubkeys: mentionPubkeys ?? undefined,
+      });
+    },
+    {
+      ch: channelName,
+      msg: content,
+      parentEventId: options?.parentEventId ?? null,
+      pubkey: options?.pubkey ?? TEST_IDENTITIES.alice.pubkey,
+      ts: options?.createdAt,
+      mentionPubkeys: options?.mentionPubkeys,
+    },
+  );
+}
+
+// Unread thread replies must be dated strictly after the read frontier captured
+// when the thread was last open. A minute ahead ensures they land past it.
+const UNREAD_OFFSET_SECONDS = 60;
+
+function unreadTimestamp() {
+  return Math.floor(Date.now() / 1000) + UNREAD_OFFSET_SECONDS;
+}
+
+// The pubkey the mock bridge logs in as (mirrors `e2eBridge`'s self identity).
+// Mentioning it clears the notify gate so an external reply lights the sidebar
+// dot without the user having to participate in the thread first.
+const SELF_PUBKEY = "deadbeef".repeat(8);
+
+// Nested replies are collapsed behind a summary row that carries the parent's
+// id (data-thread-head-id). Expanding one level renders that reply's direct
+// children, so the rendered count MUST grow after the click — asserting that
+// ties the test to genuine rendered depth: a no-op expansion fails here rather
+// than passing silently. A level can reveal several children at once (a
+// branch), so the check is "grew", not "grew by one".
+async function expandReply(
+  page: import("@playwright/test").Page,
+  replyId: string,
+) {
+  const replies = page
+    .getByTestId("message-thread-replies")
+    .getByTestId("message-row");
+  const before = await replies.count();
+  await page.locator(`[data-thread-head-id="${replyId}"]`).click();
+  await expect.poll(() => replies.count()).toBeGreaterThan(before);
+}
+
+test.describe("thread unread indicator screenshots", () => {
+  test("01-thread-unread-badge", async ({ page }) => {
+    await installMockBridge(page);
+    await page.goto("/");
+
+    // Open general — catch-up adds mock-general-welcome to authoredRootIds
+    await page.getByTestId("channel-general").click();
+    await expect(page.getByTestId("chat-title")).toHaveText("general");
+    await waitForMockLiveSubscription(page, "general");
+
+    // Emit an initial reply so the thread summary row appears
+    await emitMockMessage(page, "general", "First reply to welcome", {
+      parentEventId: "mock-general-welcome",
+      pubkey: TEST_IDENTITIES.alice.pubkey,
+      createdAt: Math.floor(Date.now() / 1000) - 10,
+    });
+
+    // Open the thread to establish a read frontier, then close it
+    const threadSummary = page.getByTestId("message-thread-summary").first();
+    await expect(threadSummary).toBeVisible();
+    await threadSummary.click();
+    await expect(page.getByTestId("message-thread-panel")).toBeVisible();
+    await page.getByTestId("message-thread-close").click();
+    await expect(page.getByTestId("message-thread-panel")).not.toBeVisible();
+
+    // Switch away so general becomes inactive
+    await page.getByTestId("channel-random").click();
+    await expect(page.getByTestId("chat-title")).toHaveText("random");
+
+    // Emit new thread replies (these will be unread)
+    const base = unreadTimestamp();
+    for (let i = 0; i < 3; i++) {
+      await emitMockMessage(page, "general", `Unread reply ${i + 1}`, {
+        parentEventId: "mock-general-welcome",
+        pubkey: TEST_IDENTITIES.alice.pubkey,
+        createdAt: base + i,
+      });
+    }
+
+    // Switch back — thread summary should show unread badge
+    await page.getByTestId("channel-general").click();
+    await expect(page.getByTestId("chat-title")).toHaveText("general");
+
+    const badge = page.getByTestId("thread-unread-badge");
+    await expect(badge).toBeVisible();
+    await expect(badge).toContainText("3");
+
+    await page.screenshot({
+      path: `${SHOTS}/01-thread-unread-badge.png`,
+    });
+  });
+
+  test("02-thread-new-divider", async ({ page }) => {
+    await installMockBridge(page);
+    await page.goto("/");
+
+    await page.getByTestId("channel-general").click();
+    await expect(page.getByTestId("chat-title")).toHaveText("general");
+    await waitForMockLiveSubscription(page, "general");
+
+    // Emit an initial reply so the thread summary appears
+    await emitMockMessage(page, "general", "Earlier reply", {
+      parentEventId: "mock-general-welcome",
+      pubkey: TEST_IDENTITIES.alice.pubkey,
+      createdAt: Math.floor(Date.now() / 1000) - 10,
+    });
+
+    // Open thread to establish frontier, then close
+    const threadSummary = page.getByTestId("message-thread-summary").first();
+    await expect(threadSummary).toBeVisible();
+    await threadSummary.click();
+    await expect(page.getByTestId("message-thread-panel")).toBeVisible();
+    await page.getByTestId("message-thread-close").click();
+    await expect(page.getByTestId("message-thread-panel")).not.toBeVisible();
+
+    // Switch away
+    await page.getByTestId("channel-random").click();
+    await expect(page.getByTestId("chat-title")).toHaveText("random");
+
+    // Emit new unread replies
+    const base = unreadTimestamp();
+    for (let i = 0; i < 2; i++) {
+      await emitMockMessage(page, "general", `New reply ${i + 1}`, {
+        parentEventId: "mock-general-welcome",
+        pubkey: TEST_IDENTITIES.alice.pubkey,
+        createdAt: base + i,
+      });
+    }
+
+    // Switch back and open the thread panel
+    await page.getByTestId("channel-general").click();
+    await expect(page.getByTestId("chat-title")).toHaveText("general");
+    await page.getByTestId("message-thread-summary").first().click();
+    await expect(page.getByTestId("message-thread-panel")).toBeVisible();
+
+    // The unread divider should appear above the first unread reply
+    // (not at index 0 since there's a read reply before the unread ones)
+    const divider = page.getByTestId("message-unread-divider");
+    await expect(divider).toBeVisible();
+    await divider.scrollIntoViewIfNeeded();
+    await page.waitForTimeout(300);
+
+    await page.screenshot({
+      path: `${SHOTS}/02-thread-new-divider.png`,
+    });
+  });
+
+  test("03-thread-no-badge-casual-browse", async ({ page }) => {
+    await installMockBridge(page);
+    await page.goto("/");
+
+    await page.getByTestId("channel-general").click();
+    await expect(page.getByTestId("chat-title")).toHaveText("general");
+    await waitForMockLiveSubscription(page, "general");
+
+    // Emit a root message from alice (tyler has NO stake in this thread)
+    const rootEvent = await emitMockMessage(
+      page,
+      "general",
+      "Alice starts a discussion",
+      {
+        pubkey: TEST_IDENTITIES.alice.pubkey,
+        createdAt: Math.floor(Date.now() / 1000) - 30,
+      },
+    );
+
+    // Emit replies from bob to alice's thread (tyler still has no stake)
+    const base = unreadTimestamp();
+    for (let i = 0; i < 2; i++) {
+      await emitMockMessage(page, "general", `Bob chimes in ${i + 1}`, {
+        parentEventId: rootEvent!.id,
+        pubkey: TEST_IDENTITIES.bob.pubkey,
+        createdAt: base + i,
+      });
+    }
+
+    // Wait for thread summary to render
+    await page.waitForTimeout(500);
+
+    // The thread summary should NOT show an unread badge — tyler has no
+    // notification interest in alice's thread (not participated/authored/followed)
+    const badges = page.getByTestId("thread-unread-badge");
+    await expect(badges).toHaveCount(0);
+
+    await page.screenshot({
+      path: `${SHOTS}/03-thread-no-badge-casual-browse.png`,
+    });
+  });
+
+  test("04-thread-deep-nested-unread", async ({ page }) => {
+    await installMockBridge(page);
+    await page.goto("/");
+
+    await page.getByTestId("channel-general").click();
+    await expect(page.getByTestId("chat-title")).toHaveText("general");
+    await waitForMockLiveSubscription(page, "general");
+
+    // Build a genuinely nested branch by chaining parentEventId: each reply's
+    // id becomes the next reply's parent, so threadPanel increments depth per
+    // level and renders progressive indentation. The first three levels are
+    // dated in the past — they are the "already read" structure.
+    const past = Math.floor(Date.now() / 1000) - 60;
+    const r1 = await emitMockMessage(
+      page,
+      "general",
+      "Kicking off the design",
+      {
+        parentEventId: "mock-general-welcome",
+        pubkey: TEST_IDENTITIES.alice.pubkey,
+        createdAt: past,
+      },
+    );
+    const r2 = await emitMockMessage(
+      page,
+      "general",
+      "Replying one level down",
+      {
+        parentEventId: r1!.id,
+        pubkey: TEST_IDENTITIES.bob.pubkey,
+        createdAt: past + 1,
+      },
+    );
+    // A sibling at r1's level so the tree reads as a branching discussion.
+    await emitMockMessage(page, "general", "Separate angle on the same point", {
+      parentEventId: r1!.id,
+      pubkey: TEST_IDENTITIES.charlie.pubkey,
+      createdAt: past + 2,
+    });
+    const r3 = await emitMockMessage(page, "general", "Going deeper still", {
+      parentEventId: r2!.id,
+      pubkey: TEST_IDENTITIES.alice.pubkey,
+      createdAt: past + 3,
+    });
+
+    // Open the thread on the welcome root, expand the read structure
+    // (r1 → r2; r3 is a leaf until r4/r5 arrive), then close. This sets the
+    // read frontier over everything that currently exists.
+    const summary = page.getByTestId("message-thread-summary").first();
+    await expect(summary).toBeVisible();
+    await summary.click();
+    await expect(page.getByTestId("message-thread-panel")).toBeVisible();
+    await expandReply(page, r1!.id);
+    await expandReply(page, r2!.id);
+    await page.getByTestId("message-thread-close").click();
+    await expect(page.getByTestId("message-thread-panel")).not.toBeVisible();
+
+    // Switch away, then emit the deeper replies past the frontier — these are
+    // the unread ones living inside the nested structure.
+    await page.getByTestId("channel-random").click();
+    await expect(page.getByTestId("chat-title")).toHaveText("random");
+
+    const base = unreadTimestamp();
+    const r4 = await emitMockMessage(page, "general", "New nested follow-up", {
+      parentEventId: r3!.id,
+      pubkey: TEST_IDENTITIES.bob.pubkey,
+      createdAt: base,
+    });
+    await emitMockMessage(page, "general", "Deepest unread reply", {
+      parentEventId: r4!.id,
+      pubkey: TEST_IDENTITIES.alice.pubkey,
+      createdAt: base + 1,
+    });
+
+    // Switch back, open the thread, and expand every level down to the
+    // unread tail. Each expandReply asserts a row appeared, so green here
+    // means the nesting genuinely rendered — not just that a divider exists.
+    await page.getByTestId("channel-general").click();
+    await expect(page.getByTestId("chat-title")).toHaveText("general");
+    await page.getByTestId("message-thread-summary").first().click();
+    await expect(page.getByTestId("message-thread-panel")).toBeVisible();
+    await expandReply(page, r1!.id);
+    await expandReply(page, r2!.id);
+    await expandReply(page, r3!.id);
+    await expandReply(page, r4!.id);
+
+    // Fully expanded: r1, r2, sibling, r3, r4, r5 — six rendered replies.
+    const replies = page
+      .getByTestId("message-thread-replies")
+      .getByTestId("message-row");
+    await expect(replies).toHaveCount(6);
+
+    const divider = page.getByTestId("message-unread-divider");
+    await expect(divider).toBeVisible();
+    await divider.scrollIntoViewIfNeeded();
+    await page.waitForTimeout(300);
+
+    await page.screenshot({
+      path: `${SHOTS}/04-thread-deep-nested-unread.png`,
+    });
+  });
+
+  test("05-thread-in-panel-subtree-badge", async ({ page }) => {
+    await installMockBridge(page);
+    await page.goto("/");
+
+    await page.getByTestId("channel-general").click();
+    await expect(page.getByTestId("chat-title")).toHaveText("general");
+    await waitForMockLiveSubscription(page, "general");
+
+    // A branch p (with a child c) plus a leaf sibling of p, all dated in the
+    // past so they form the "already read" structure. p keeps a child, so its
+    // in-panel row renders as a collapsible summary that can carry a subtree
+    // badge; the leaf sibling proves the panel shows other rows too.
+    const past = Math.floor(Date.now() / 1000) - 60;
+    const p = await emitMockMessage(page, "general", "Branch parent", {
+      parentEventId: "mock-general-welcome",
+      pubkey: TEST_IDENTITIES.alice.pubkey,
+      createdAt: past,
+    });
+    const c = await emitMockMessage(page, "general", "Child of branch parent", {
+      parentEventId: p!.id,
+      pubkey: TEST_IDENTITIES.bob.pubkey,
+      createdAt: past + 1,
+    });
+    await emitMockMessage(page, "general", "Sibling branch at top level", {
+      parentEventId: "mock-general-welcome",
+      pubkey: TEST_IDENTITIES.charlie.pubkey,
+      createdAt: past + 2,
+    });
+
+    // Open the thread to snapshot the read frontier over the existing
+    // structure, then close. p stays collapsed — its summary row must remain a
+    // collapsed branch for the subtree badge to render.
+    const summary = page.getByTestId("message-thread-summary").first();
+    await expect(summary).toBeVisible();
+    await summary.click();
+    await expect(page.getByTestId("message-thread-panel")).toBeVisible();
+    await page.getByTestId("message-thread-close").click();
+    await expect(page.getByTestId("message-thread-panel")).not.toBeVisible();
+
+    // Switch away, then emit two unread replies deep under p (children of c) —
+    // p's subtree gains unread descendants while p itself stays collapsed.
+    await page.getByTestId("channel-random").click();
+    await expect(page.getByTestId("chat-title")).toHaveText("random");
+
+    const base = unreadTimestamp();
+    const c2 = await emitMockMessage(
+      page,
+      "general",
+      "Unread under the branch",
+      {
+        parentEventId: c!.id,
+        pubkey: TEST_IDENTITIES.alice.pubkey,
+        createdAt: base,
+      },
+    );
+    await emitMockMessage(page, "general", "Another unread under the branch", {
+      parentEventId: c2!.id,
+      pubkey: TEST_IDENTITIES.bob.pubkey,
+      createdAt: base + 1,
+    });
+
+    // Switch back and open the panel WITHOUT expanding p. The collapsed p row
+    // must show its subtree unread count (the two unread descendants).
+    await page.getByTestId("channel-general").click();
+    await expect(page.getByTestId("chat-title")).toHaveText("general");
+    await page.getByTestId("message-thread-summary").first().click();
+    await expect(page.getByTestId("message-thread-panel")).toBeVisible();
+
+    // p renders as a collapsed summary row (it has a child); the sibling is a
+    // leaf and renders as a plain row, not a summary. Gate on p's summary row
+    // first — green here means the branch genuinely rendered, so the badge
+    // assertion below is read off a real collapsed row, not an empty panel.
+    const inPanelSummaries = page
+      .getByTestId("message-thread-replies")
+      .getByTestId("message-thread-summary");
+    await expect(inPanelSummaries).toHaveCount(1);
+
+    // Scope to message-thread-replies: this is the in-panel per-branch badge,
+    // NOT the depth-0 channel-timeline badge that lives outside the container.
+    // Against pre-2.5 code the in-panel badge was hard-0, so this fails there.
+    const inPanelBadge = page
+      .getByTestId("message-thread-replies")
+      .getByTestId("thread-unread-badge");
+    await expect(inPanelBadge).toBeVisible();
+    await expect(inPanelBadge).toContainText("2");
+
+    await page.screenshot({
+      path: `${SHOTS}/05-thread-in-panel-subtree-badge.png`,
+    });
+
+    // Expanding p marks its whole subtree read; the descendant-inclusive gate
+    // (Phase 2.5) drops the badge from p and every revealed row beneath it.
+    await expandReply(page, p!.id);
+    await expect(inPanelBadge).toHaveCount(0);
+
+    await page.screenshot({
+      path: `${SHOTS}/06-thread-expand-clears-subtree-badge.png`,
+    });
+  });
+
+  test("06-in-panel-badge-bumps-on-live-reply", async ({ page }) => {
+    await installMockBridge(page);
+    await page.goto("/");
+
+    await page.getByTestId("channel-general").click();
+    await expect(page.getByTestId("chat-title")).toHaveText("general");
+    await waitForMockLiveSubscription(page, "general");
+
+    // Collapsed branch p with one read child, plus an unread descendant so the
+    // in-panel subtree badge starts at a known count.
+    const past = Math.floor(Date.now() / 1000) - 60;
+    const p = await emitMockMessage(page, "general", "Branch parent", {
+      parentEventId: "mock-general-welcome",
+      pubkey: TEST_IDENTITIES.alice.pubkey,
+      createdAt: past,
+    });
+    const c = await emitMockMessage(page, "general", "Child of branch parent", {
+      parentEventId: p!.id,
+      pubkey: TEST_IDENTITIES.bob.pubkey,
+      createdAt: past + 1,
+    });
+
+    const summary = page.getByTestId("message-thread-summary").first();
+    await expect(summary).toBeVisible();
+    await summary.click();
+    await expect(page.getByTestId("message-thread-panel")).toBeVisible();
+    await page.getByTestId("message-thread-close").click();
+    await expect(page.getByTestId("message-thread-panel")).not.toBeVisible();
+
+    await page.getByTestId("channel-random").click();
+    await expect(page.getByTestId("chat-title")).toHaveText("random");
+
+    const base = unreadTimestamp();
+    await emitMockMessage(page, "general", "First unread under branch", {
+      parentEventId: c!.id,
+      pubkey: TEST_IDENTITIES.alice.pubkey,
+      createdAt: base,
+    });
+
+    // Reopen WITHOUT expanding p: badge shows the single unread descendant.
+    await page.getByTestId("channel-general").click();
+    await expect(page.getByTestId("chat-title")).toHaveText("general");
+    await page.getByTestId("message-thread-summary").first().click();
+    await expect(page.getByTestId("message-thread-panel")).toBeVisible();
+
+    const inPanelBadge = page
+      .getByTestId("message-thread-replies")
+      .getByTestId("thread-unread-badge");
+    await expect(inPanelBadge).toBeVisible();
+    await expect(inPanelBadge).toContainText("1");
+
+    // A live reply from another author lands under the open, collapsed branch.
+    // The live root marker did NOT advance (panel open ≠ branch expanded), so
+    // the badge must bump to 2 on the same tick — readStateVersion-driven
+    // recompute is what makes this fire live rather than on a later re-render.
+    await emitMockMessage(page, "general", "Second unread under branch", {
+      parentEventId: c!.id,
+      pubkey: TEST_IDENTITIES.bob.pubkey,
+      createdAt: base + 1,
+    });
+    await expect(inPanelBadge).toContainText("2");
+
+    await page.screenshot({
+      path: `${SHOTS}/07-in-panel-badge-bumps-on-live-reply.png`,
+    });
+  });
+
+  test("07-expand-clears-own-branch-badge-sibling-survives", async ({
+    page,
+  }) => {
+    await installMockBridge(page);
+    await page.goto("/");
+
+    await page.getByTestId("channel-general").click();
+    await expect(page.getByTestId("chat-title")).toHaveText("general");
+    await waitForMockLiveSubscription(page, "general");
+
+    // Two collapsed sibling branches, each with one read child. branchOld will
+    // gain a chronologically EARLIER unread reply; branchNew a LATER one.
+    const past = Math.floor(Date.now() / 1000) - 120;
+    const branchOld = await emitMockMessage(page, "general", "Older branch", {
+      parentEventId: "mock-general-welcome",
+      pubkey: TEST_IDENTITIES.alice.pubkey,
+      createdAt: past,
+    });
+    const oldChild = await emitMockMessage(page, "general", "Old child", {
+      parentEventId: branchOld!.id,
+      pubkey: TEST_IDENTITIES.bob.pubkey,
+      createdAt: past + 1,
+    });
+    const branchNew = await emitMockMessage(page, "general", "Newer branch", {
+      parentEventId: "mock-general-welcome",
+      pubkey: TEST_IDENTITIES.charlie.pubkey,
+      createdAt: past + 2,
+    });
+    const newChild = await emitMockMessage(page, "general", "New child", {
+      parentEventId: branchNew!.id,
+      pubkey: TEST_IDENTITIES.alice.pubkey,
+      createdAt: past + 3,
+    });
+
+    const summary = page.getByTestId("message-thread-summary").first();
+    await expect(summary).toBeVisible();
+    await summary.click();
+    await expect(page.getByTestId("message-thread-panel")).toBeVisible();
+    await page.getByTestId("message-thread-close").click();
+    await expect(page.getByTestId("message-thread-panel")).not.toBeVisible();
+
+    await page.getByTestId("channel-random").click();
+    await expect(page.getByTestId("chat-title")).toHaveText("random");
+
+    // Each branch gains its own unread reply. Badges are computed against the
+    // open-time frozen frontier snapshot, and expand-clear is driven by the
+    // per-branch `expandedSubtreeReplyIds` gate — NOT a cross-branch live
+    // marker sweep. So expanding one branch clears only its OWN badge; the
+    // sibling's badge survives until that branch is expanded too.
+    const base = unreadTimestamp();
+    await emitMockMessage(page, "general", "Unread in older branch", {
+      parentEventId: oldChild!.id,
+      pubkey: TEST_IDENTITIES.alice.pubkey,
+      createdAt: base,
+    });
+    await emitMockMessage(page, "general", "Unread in newer branch", {
+      parentEventId: newChild!.id,
+      pubkey: TEST_IDENTITIES.bob.pubkey,
+      createdAt: base + 30,
+    });
+
+    await page.getByTestId("channel-general").click();
+    await expect(page.getByTestId("chat-title")).toHaveText("general");
+    await page.getByTestId("message-thread-summary").first().click();
+    await expect(page.getByTestId("message-thread-panel")).toBeVisible();
+
+    // Both collapsed branches carry an unread badge before any expand.
+    const inPanelBadges = page
+      .getByTestId("message-thread-replies")
+      .getByTestId("thread-unread-badge");
+    await expect(inPanelBadges).toHaveCount(2);
+
+    await page.screenshot({
+      path: `${SHOTS}/08-two-sibling-badges-before-expand.png`,
+    });
+
+    // Expand the LATER branch. Only its OWN badge clears, via the
+    // `expandedSubtreeReplyIds` gate against the frozen open-time frontier.
+    // The older sibling's badge SURVIVES — the design does not sweep across
+    // branches off a live marker.
+    await expandReply(page, branchNew!.id);
+    await expect(inPanelBadges).toHaveCount(1);
+
+    await page.screenshot({
+      path: `${SHOTS}/09-expand-clears-own-branch-sibling-survives.png`,
+    });
+
+    // Expanding the older branch clears the last remaining badge.
+    await expandReply(page, branchOld!.id);
+    await expect(inPanelBadges).toHaveCount(0);
+
+    await page.screenshot({
+      path: `${SHOTS}/10-both-branches-expanded-all-cleared.png`,
+    });
+  });
+
+  // Regression guard for the Option-1 channel-marker fix: viewing a channel
+  // marks ONLY its top-level timeline read, never its thread replies. Before
+  // the fix, the channel marker advanced past the newest reply on every view,
+  // so the hierarchical effective(thread)=max(thread,channel) cleared the
+  // badge the instant the channel was re-entered. This walks open -> badge
+  // present -> leave -> RE-ENTER -> badge STILL present. Without the top-level
+  // filter on activeReadAt this fails on the second entry.
+  test("10-thread-badge-survives-channel-reentry", async ({ page }) => {
+    await installMockBridge(page);
+    await page.goto("/");
+
+    await page.getByTestId("channel-general").click();
+    await expect(page.getByTestId("chat-title")).toHaveText("general");
+    await waitForMockLiveSubscription(page, "general");
+
+    // Read frontier over an initial reply, then close the thread.
+    await emitMockMessage(page, "general", "First reply to welcome", {
+      parentEventId: "mock-general-welcome",
+      pubkey: TEST_IDENTITIES.alice.pubkey,
+      createdAt: Math.floor(Date.now() / 1000) - 10,
+    });
+    const threadSummary = page.getByTestId("message-thread-summary").first();
+    await expect(threadSummary).toBeVisible();
+    await threadSummary.click();
+    await expect(page.getByTestId("message-thread-panel")).toBeVisible();
+    await page.getByTestId("message-thread-close").click();
+    await expect(page.getByTestId("message-thread-panel")).not.toBeVisible();
+
+    // Leave, emit unread replies, return — badge appears (same as test 01).
+    await page.getByTestId("channel-random").click();
+    await expect(page.getByTestId("chat-title")).toHaveText("random");
+    const base = unreadTimestamp();
+    for (let i = 0; i < 3; i++) {
+      await emitMockMessage(page, "general", `Unread reply ${i + 1}`, {
+        parentEventId: "mock-general-welcome",
+        pubkey: TEST_IDENTITIES.alice.pubkey,
+        createdAt: base + i,
+      });
+    }
+    await page.getByTestId("channel-general").click();
+    await expect(page.getByTestId("chat-title")).toHaveText("general");
+    const badge = page.getByTestId("thread-unread-badge");
+    await expect(badge).toBeVisible();
+    await expect(badge).toContainText("3");
+
+    // The crux: the first entry above marked the channel read WHILE the unread
+    // replies were present. Leave and re-enter WITHOUT opening the thread. If
+    // the channel marker had absorbed the replies, the badge would be gone now.
+    await page.getByTestId("channel-random").click();
+    await expect(page.getByTestId("chat-title")).toHaveText("random");
+    await page.getByTestId("channel-general").click();
+    await expect(page.getByTestId("chat-title")).toHaveText("general");
+    await expect(badge).toBeVisible();
+    await expect(badge).toContainText("3");
+
+    await page.screenshot({
+      path: `${SHOTS}/10-thread-badge-survives-channel-reentry.png`,
+    });
+  });
+
+  // Pins the Fix-A sidebar consequence Will approved on the record: a channel
+  // whose ONLY unread is an unopened thread reply KEEPS its sidebar dot after
+  // the channel is viewed. Channel-open advances the marker over top-level
+  // messages only and does NOT clear observed-latest, so the reply still counts
+  // as unread for the sidebar. A future change that re-folds replies into the
+  // channel-view marker would drop the dot on view and fail here.
+  test("11-sidebar-dot-persists-after-channel-view", async ({ page }) => {
+    await installMockBridge(page);
+    await page.goto("/");
+
+    // Open general and read its thread frontier, so the only thing that can be
+    // unread afterward is a NEW reply — not the channel timeline.
+    await page.getByTestId("channel-general").click();
+    await expect(page.getByTestId("chat-title")).toHaveText("general");
+    await waitForMockLiveSubscription(page, "general");
+    await emitMockMessage(page, "general", "First reply to welcome", {
+      parentEventId: "mock-general-welcome",
+      pubkey: TEST_IDENTITIES.alice.pubkey,
+      createdAt: Math.floor(Date.now() / 1000) - 10,
+    });
+    const threadSummary = page.getByTestId("message-thread-summary").first();
+    await expect(threadSummary).toBeVisible();
+    await threadSummary.click();
+    await expect(page.getByTestId("message-thread-panel")).toBeVisible();
+    await page.getByTestId("message-thread-close").click();
+
+    // Leave, emit an unread reply (thread-reply-only unread), then RE-ENTER
+    // general so the channel-open marker fires while the reply is unread.
+    await page.getByTestId("channel-random").click();
+    await expect(page.getByTestId("chat-title")).toHaveText("random");
+    await emitMockMessage(page, "general", "Unread reply", {
+      parentEventId: "mock-general-welcome",
+      pubkey: TEST_IDENTITIES.alice.pubkey,
+      createdAt: unreadTimestamp(),
+    });
+    await page.getByTestId("channel-general").click();
+    await expect(page.getByTestId("chat-title")).toHaveText("general");
+
+    // The crux: leave general. Its sidebar dot must remain — viewing the
+    // channel did NOT absorb the unopened thread reply (Fix A).
+    await page.getByTestId("channel-random").click();
+    await expect(page.getByTestId("chat-title")).toHaveText("random");
+    await expect(page.getByTestId("channel-unread-general")).toBeVisible();
+
+    await page.screenshot({
+      path: `${SHOTS}/11-sidebar-dot-persists.png`,
+    });
+  });
+
+  // Regression guard for the all-replies window: when the loaded window holds
+  // ONLY thread replies (the top-level root has scrolled past the history
+  // limit), `latestActiveMessage` is null and `activeReadAt` must NOT fall back
+  // to the channel's `lastMessageAt` — that value is reply-inclusive (a reply's
+  // own timestamp), so advancing the channel marker to it silently absorbs the
+  // unread reply and clears the dot, defeating Fix A. The fix nulls the
+  // fallback so the marker advance is suppressed until a real top-level
+  // position is known; this pins the dot's survival in that window.
+  //
+  // The `all-replies` fixture carries a far-future `lastMessageAt` (standing in
+  // for the backend's reply-inclusive MAX) with no top-level message in its
+  // window — so the buggy fallback would advance the marker past the reply.
+  test("12-sidebar-dot-survives-all-replies-window", async ({ page }) => {
+    await installMockBridge(page);
+    await page.goto("/");
+
+    // Emit ONE reply whose parent root is NOT in the window (orphan parent id),
+    // so the loaded window is all-replies: no top-level message exists for
+    // `latestActiveMessage` to find. The reply mentions the current user so it
+    // clears the notify gate and lights the sidebar dot — the observable this
+    // test asserts on. (Any notify trigger works; a mention is the simplest.
+    // The bug is independent of why the reply is notified.)
+    await page.getByTestId("channel-general").click();
+    await expect(page.getByTestId("chat-title")).toHaveText("general");
+    await waitForMockLiveSubscription(page, "all-replies");
+    await emitMockMessage(page, "all-replies", "Orphan reply mentioning you", {
+      parentEventId: "mock-root-scrolled-past-window",
+      pubkey: TEST_IDENTITIES.alice.pubkey,
+      mentionPubkeys: [SELF_PUBKEY],
+      createdAt: unreadTimestamp(),
+    });
+    await expect(page.getByTestId("channel-unread-all-replies")).toBeVisible();
+
+    // View all-replies while the reply is unread. The all-replies window forces
+    // the `activeReadAt` fallback; the bug would advance the channel marker to
+    // the far-future `lastMessageAt` and clear the dot.
+    await page.getByTestId("channel-all-replies").click();
+    await expect(page.getByTestId("chat-title")).toHaveText("all-replies");
+
+    // The crux: leave the channel. Its sidebar dot must remain — the reply is
+    // still unread, and viewing the all-replies window must not absorb it.
+    await page.getByTestId("channel-general").click();
+    await expect(page.getByTestId("chat-title")).toHaveText("general");
+    await expect(page.getByTestId("channel-unread-all-replies")).toBeVisible();
+
+    await page.screenshot({
+      path: `${SHOTS}/12-sidebar-dot-all-replies.png`,
+    });
+  });
+});
diff --git a/desktop/tests/e2e/unread-pill-screenshots.spec.ts b/desktop/tests/e2e/unread-pill-screenshots.spec.ts
new file mode 100644
index 0000000000..80239c440f
--- /dev/null
+++ b/desktop/tests/e2e/unread-pill-screenshots.spec.ts
@@ -0,0 +1,231 @@
+import { expect, test } from "@playwright/test";
+
+import { TEST_IDENTITIES, installMockBridge } from "../helpers/bridge";
+
+const SHOTS = "test-results/unread-pill";
+
+async function waitForMockLiveSubscription(
+  page: import("@playwright/test").Page,
+  channelName: string,
+) {
+  await expect
+    .poll(async () => {
+      return page.evaluate(
+        ({ ch }) =>
+          (
+            window as Window & {
+              __BUZZ_E2E_HAS_MOCK_LIVE_SUBSCRIPTION__?: (input: {
+                channelName: string;
+              }) => boolean;
+            }
+          ).__BUZZ_E2E_HAS_MOCK_LIVE_SUBSCRIPTION__?.({ channelName: ch }) ??
+          false,
+        { ch: channelName },
+      );
+    })
+    .toBe(true);
+}
+
+function emitMockMessage(
+  page: import("@playwright/test").Page,
+  channelName: string,
+  content: string,
+  createdAt?: number,
+) {
+  return page.evaluate(
+    ({ ch, msg, pubkey, ts }) => {
+      (
+        window as Window & {
+          __BUZZ_E2E_EMIT_MOCK_MESSAGE__?: (input: {
+            channelName: string;
+            content: string;
+            pubkey: string;
+            createdAt?: number;
+          }) => unknown;
+        }
+      ).__BUZZ_E2E_EMIT_MOCK_MESSAGE__?.({
+        channelName: ch,
+        content: msg,
+        pubkey,
+        createdAt: ts,
+      });
+    },
+    {
+      ch: channelName,
+      msg: content,
+      pubkey: TEST_IDENTITIES.alice.pubkey,
+      ts: createdAt,
+    },
+  );
+}
+
+// Unread messages must be created strictly after the read frontier captured
+// when the channel was last open. The frontier is captured at the current
+// second on open, and computeChannelUnreadMarker uses a strict
+// `createdAt > frontier` predicate — so emitting at the same wall-clock second
+// leaves the messages on the read side and the pill/divider never render.
+// Dating them a minute ahead puts them deterministically past the frontier.
+const UNREAD_OFFSET_SECONDS = 60;
+
+function unreadTimestamp() {
+  return Math.floor(Date.now() / 1000) + UNREAD_OFFSET_SECONDS;
+}
+
+// Emit `count` unread messages to general, staggered one second apart so they
+// sort deterministically and all land strictly past the read frontier.
+async function emitUnreadMessages(
+  page: import("@playwright/test").Page,
+  count: number,
+) {
+  const base = unreadTimestamp();
+  for (let index = 0; index < count; index += 1) {
+    await emitMockMessage(
+      page,
+      "general",
+      `Unread message ${index + 1}`,
+      base + index,
+    );
+  }
+}
+
+// Scroll the timeline up so the viewport is no longer pinned to the bottom.
+// The pill auto-dismisses once the user reaches the bottom of the timeline, so
+// it only stays rendered while scrolled up — which is the state these shots
+// need to capture. Scrolling part-way (rather than to the very top) keeps real
+// message context on screen instead of the channel's empty-state intro.
+async function scrollTimelineUp(page: import("@playwright/test").Page) {
+  await page.getByTestId("message-timeline").evaluate((el) => {
+    el.scrollTop = Math.floor(el.scrollHeight * 0.35);
+  });
+  await page.waitForTimeout(300);
+}
+
+test.describe("unread pill & divider screenshots", () => {
+  test("01-unread-pill-visible", async ({ page }) => {
+    await installMockBridge(page);
+    await page.goto("/");
+
+    // Open general, then switch to random so general becomes inactive
+    await page.getByTestId("channel-general").click();
+    await expect(page.getByTestId("chat-title")).toHaveText("general");
+    await waitForMockLiveSubscription(page, "general");
+
+    await page.getByTestId("channel-random").click();
+    await expect(page.getByTestId("chat-title")).toHaveText("random");
+
+    await emitUnreadMessages(page, 20);
+
+    // Switch back to general — pill should appear
+    await page.getByTestId("channel-general").click();
+    await expect(page.getByTestId("chat-title")).toHaveText("general");
+
+    // Scroll up so the unreads sit below the fold: the pill is the
+    // "jump to oldest unread" affordance and only stays on screen while the
+    // user is scrolled away from the bottom of the timeline.
+    await scrollTimelineUp(page);
+
+    const pill = page.getByTestId("message-unread-pill");
+    await expect(pill).toBeVisible();
+    await expect(pill).toContainText("20 new messages");
+
+    await page.screenshot({
+      path: `${SHOTS}/01-unread-pill-visible.png`,
+    });
+  });
+
+  test("02-unread-divider-visible", async ({ page }) => {
+    await installMockBridge(page);
+    await page.goto("/");
+
+    await page.getByTestId("channel-general").click();
+    await expect(page.getByTestId("chat-title")).toHaveText("general");
+    await waitForMockLiveSubscription(page, "general");
+
+    await page.getByTestId("channel-random").click();
+    await expect(page.getByTestId("chat-title")).toHaveText("random");
+
+    await emitUnreadMessages(page, 3);
+
+    await page.getByTestId("channel-general").click();
+    await expect(page.getByTestId("chat-title")).toHaveText("general");
+
+    const divider = page.getByTestId("message-unread-divider");
+    await expect(divider).toBeVisible();
+
+    // Scroll the divider into view for a clear screenshot
+    await divider.scrollIntoViewIfNeeded();
+    await page.waitForTimeout(300);
+
+    await page.screenshot({
+      path: `${SHOTS}/02-unread-divider-visible.png`,
+    });
+  });
+
+  test("03-pill-dismissed-after-scroll", async ({ page }) => {
+    await installMockBridge(page);
+    await page.goto("/");
+
+    await page.getByTestId("channel-general").click();
+    await expect(page.getByTestId("chat-title")).toHaveText("general");
+    await waitForMockLiveSubscription(page, "general");
+
+    await page.getByTestId("channel-random").click();
+    await expect(page.getByTestId("chat-title")).toHaveText("random");
+
+    await emitUnreadMessages(page, 20);
+
+    await page.getByTestId("channel-general").click();
+    await expect(page.getByTestId("chat-title")).toHaveText("general");
+
+    // Scroll up so the pill is showing, matching scenario 01's starting state.
+    await scrollTimelineUp(page);
+
+    const pill = page.getByTestId("message-unread-pill");
+    await expect(pill).toBeVisible();
+
+    // Click the pill to jump to the oldest unread, which dismisses it.
+    await pill.click();
+
+    // Pill should be dismissed
+    await expect(pill).toHaveCount(0);
+
+    // Divider should still be visible
+    const divider = page.getByTestId("message-unread-divider");
+    await expect(divider).toBeVisible();
+
+    await page.screenshot({
+      path: `${SHOTS}/03-pill-dismissed-after-scroll.png`,
+    });
+  });
+
+  test("04-mark-unread-suppresses-pill", async ({ page }) => {
+    await installMockBridge(page);
+    await page.goto("/");
+
+    await page.getByTestId("channel-general").click();
+    await expect(page.getByTestId("chat-title")).toHaveText("general");
+
+    // Mark channel unread via context menu on the sidebar item
+    await page.getByTestId("channel-general").click({ button: "right" });
+    await page.getByText("Mark unread").click();
+
+    // Switch away and back to re-open the channel
+    await page.getByTestId("channel-random").click();
+    await expect(page.getByTestId("chat-title")).toHaveText("random");
+
+    // The unread indicator only renders on inactive channels, so it appears
+    // once general is no longer the active channel.
+    await expect(page.getByTestId("channel-unread-general")).toBeVisible();
+
+    await page.getByTestId("channel-general").click();
+    await expect(page.getByTestId("chat-title")).toHaveText("general");
+
+    // Pill and divider should NOT appear (suppressed for forced-unread)
+    await expect(page.getByTestId("message-unread-pill")).toHaveCount(0);
+    await expect(page.getByTestId("message-unread-divider")).toHaveCount(0);
+
+    await page.screenshot({
+      path: `${SHOTS}/04-mark-unread-suppresses-pill.png`,
+    });
+  });
+});
diff --git a/docs/nips/NIP-RS.md b/docs/nips/NIP-RS.md
index 153dec85ac..9dbf714ebc 100644
--- a/docs/nips/NIP-RS.md
+++ b/docs/nips/NIP-RS.md
@@ -158,9 +158,10 @@ A thread is unread iff `latestReplyAt > effective(thread:<root>)`, where
 `latestReplyAt` is the `created_at` of the thread's most recent reply. Because
 this rule is a `max()` over the same grow-only registers defined in the Merge
 Rule, it remains a monotone state-based CvRDT — no change to the merge rule is
-required. Marking a channel read therefore clears unread state on all of its
-threads for free, since each thread's effective frontier inherits the channel
-term.
+required. Marking a channel read clears unread state on any thread whose most
+recent reply predates the channel frontier, since each thread's effective
+frontier inherits the channel term; threads with replies newer than the channel
+frontier remain unread until the thread itself is read.
 
 If the thread root event (and therefore its parent channel) cannot be resolved
 from the event graph, `effective(thread:<root>)` degrades to
@@ -172,11 +173,11 @@ Marking a thread read MUST advance only its own `thread:<root>` context. It MUST
 NOT advance the parent channel context. Otherwise, reading a single thread would
 silently mark later top-level channel messages as read. Marking a channel read
 advances only the channel context (which the hierarchical rule then propagates
-to threads at read time). Because the channel frontier is the parent term for
-every thread, marking a channel read SHOULD advance the channel context to the
-maximum `created_at` across all messages in the channel, including thread
-replies — otherwise threads whose `latestReplyAt` exceeds the newest top-level
-message remain unread and the channel read does not clear them.
+to threads at read time). The channel context SHOULD advance to the maximum
+`created_at` across the channel's top-level messages only, NOT including thread
+replies. This keeps a thread unread when its `latestReplyAt` exceeds the newest
+top-level message: opening a channel clears the channel timeline but leaves its
+threads' unread state intact until each thread is read.
 
 ##### Eviction
 
