You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
When a channel's unread activity is a thread reply, nothing in the app tells you which thread. The sidebar says the channel is unread; you open it and get a timeline of thread summary rows with no visual difference between the one holding unread replies and the rest. The badge is a true signal pointing at an unreachable target.
This is not hypothetical. On my account (desktop 0.5.17) a DM sat at "1 unread" for ten days. The unread event was a thread reply from 2026-08-10 under a root from 2026-08-09. I opened that DM many times, saw "nothing new" each time, and assumed the badge was broken. It wasn't. I only found the message after querying the relay directly for the channel's raw events.
There is a count today — ({unreadCount} new) on the thread summary row (desktop/src/features/messages/ui/MessageThreadSummaryRow.tsx:248-252). Two things stop it from answering the question:
It carries no emphasis. It renders as plain parenthesised text in the same run as N replies, with no weight, colour, dot or pill of its own. Finding it is a reading task over every summary row in the channel, not a glance.
It only exists for roots inside the loaded window. The counts come from computeThreadBadgeCounts(timelineMessages, …) (desktop/src/features/channels/ui/useChannelUnreadState.ts:350-359), which walks the loaded timeline. A thread whose root is below the pagination boundary cannot render an unread count at all — and since the timeline orders by root creation date (Sort channel threads by most recent reply, not creation date #4585), a long-running thread is precisely the kind that ends up down there.
The asymmetry that makes it stick
The channel badge and the in-app evidence for it have different lifetimes, in the wrong direction:
The startup catch-up REQ has no lower bound — since: readAt + 1 (desktop/src/features/channels/useUnreadChannels.ts:608-616). It will rediscover arbitrarily old activity on every launch.
The Inbox thread-activity store — the one surface that does list unread thread activity thread-by-thread — is capped: MAX_ACTIVITY_ITEMS = 100 (desktop/src/features/channels/threadActivityStorage.ts:15, applied by slice in both readActivityFromStorage and addThreadActivityItems).
Measured on my account at the time the badge was showing: the activity store held exactly 100 items, oldest 2026-08-16. The event raising the badge was from 2026-08-10 — six days below the oldest retained pointer. So the badge outlived every in-app trace of what raised it, in both the channel (window-scoped) and the Inbox (count-capped).
Proposed solution
Give the unread count its own affordance on the thread summary row.refine(desktop): surface unread thread cues #3948 already built exactly this — "promotes the thread summary's muted unread text into a legible N new pill" plus a continuous accent spanning the parent message and its summary, presentation-only over existing state. It got a positive review ("the pill is cool and stands out more") and was then closed by its author on 2026-08-17 as a stale branch, with the stated intent to re-propose the smallest still-useful change. I could not find an open issue tracking that intent, which is why this one exists.
Make the thread-level unread signal survive the window. A count that only renders for loaded roots can't be the channel's answer to "where is it". Either compute the per-thread count from read state rather than from timelineMessages, or give the channel an explicit navigation affordance (jump to oldest unread thread). refine(desktop): surface unread thread cues #3948's red-team pass rejected a floating jump pill for real reasons — channel-wide dismissal state could hide a second unread thread — so the presentation slice in (1) is the safer first step, and this is the follow-up.
Alternatives considered
Sort channel threads by most recent reply, not creation date #4585 (sort threads by most recent reply). Genuinely mitigates this: a thread with a fresh unread reply floats to the top, where its (N new) is both computed and visible. It does not replace this issue — a read-but-recently-active thread also floats, so ordering alone still doesn't mark which row is unread — but the two compose well, and either one alone would have saved me the ten days.
Explicit mark-as-read. The sidebar context menu clears the badge, but by burying the message rather than surfacing it. That is the workaround I ended up using, and it is the wrong outcome: the unread message was real and worth reading.
Buzz version: 0.5.17 (official signed macOS build)
OS: macOS 26.6.2 (arm64)
Filed by Bumble, an agent working in @rmichelena's Buzz workspace. Code references verified against upstream/main at 417eea2; measurements taken from his machine's local read state.
Motivation
When a channel's unread activity is a thread reply, nothing in the app tells you which thread. The sidebar says the channel is unread; you open it and get a timeline of thread summary rows with no visual difference between the one holding unread replies and the rest. The badge is a true signal pointing at an unreachable target.
This is not hypothetical. On my account (desktop 0.5.17) a DM sat at "1 unread" for ten days. The unread event was a thread reply from 2026-08-10 under a root from 2026-08-09. I opened that DM many times, saw "nothing new" each time, and assumed the badge was broken. It wasn't. I only found the message after querying the relay directly for the channel's raw events.
There is a count today —
({unreadCount} new)on the thread summary row (desktop/src/features/messages/ui/MessageThreadSummaryRow.tsx:248-252). Two things stop it from answering the question:N replies, with no weight, colour, dot or pill of its own. Finding it is a reading task over every summary row in the channel, not a glance.computeThreadBadgeCounts(timelineMessages, …)(desktop/src/features/channels/ui/useChannelUnreadState.ts:350-359), which walks the loaded timeline. A thread whose root is below the pagination boundary cannot render an unread count at all — and since the timeline orders by root creation date (Sort channel threads by most recent reply, not creation date #4585), a long-running thread is precisely the kind that ends up down there.The asymmetry that makes it stick
The channel badge and the in-app evidence for it have different lifetimes, in the wrong direction:
since: readAt + 1(desktop/src/features/channels/useUnreadChannels.ts:608-616). It will rediscover arbitrarily old activity on every launch.MAX_ACTIVITY_ITEMS = 100(desktop/src/features/channels/threadActivityStorage.ts:15, applied by slice in bothreadActivityFromStorageandaddThreadActivityItems).Measured on my account at the time the badge was showing: the activity store held exactly 100 items, oldest 2026-08-16. The event raising the badge was from 2026-08-10 — six days below the oldest retained pointer. So the badge outlived every in-app trace of what raised it, in both the channel (window-scoped) and the Inbox (count-capped).
Proposed solution
Give the unread count its own affordance on the thread summary row. refine(desktop): surface unread thread cues #3948 already built exactly this — "promotes the thread summary's muted unread text into a legible
N newpill" plus a continuous accent spanning the parent message and its summary, presentation-only over existing state. It got a positive review ("the pill is cool and stands out more") and was then closed by its author on 2026-08-17 as a stale branch, with the stated intent to re-propose the smallest still-useful change. I could not find an open issue tracking that intent, which is why this one exists.Make the thread-level unread signal survive the window. A count that only renders for loaded roots can't be the channel's answer to "where is it". Either compute the per-thread count from read state rather than from
timelineMessages, or give the channel an explicit navigation affordance (jump to oldest unread thread). refine(desktop): surface unread thread cues #3948's red-team pass rejected a floating jump pill for real reasons — channel-wide dismissal state could hide a second unread thread — so the presentation slice in (1) is the safer first step, and this is the follow-up.Alternatives considered
(N new)is both computed and visible. It does not replace this issue — a read-but-recently-active thread also floats, so ordering alone still doesn't mark which row is unread — but the two compose well, and either one alone would have saved me the ten days.Version and platform
Filed by Bumble, an agent working in @rmichelena's Buzz workspace. Code references verified against
upstream/mainat 417eea2; measurements taken from his machine's local read state.