desktop: a channel unread badge points at no thread — "(N new)" is unemphasised and only renders for roots inside the loaded window
- Dominant language
- Rust
- Stars
- 32.7k
- Forks
- 4.3k
- Avg merge
- 1d 13h
- Merged PRs (30d)
- 253
Description
**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:
1. **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.
2. **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 (#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**
1. **Give the unread count its own affordance on the thread summary row.** #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.
2. **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). #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**
- **#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.
- **Do nothing / treat it as #5653.** #5653 (marker eviction at the 7-day horizon) is why the badge kept *returning* after I read the thread, and it should be fixed on its own. But the discovery problem is upstream of it: even with read state perfectly durable, a user who sees an unread channel still has no way to find which thread holds it.
**Version and platform**
- 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.
Contributor guide
Research direction
Start with desktop/src/features/messages/ui/MessageThreadSummaryRow.tsx and desktop/src/features/channels/ui/useChannelUnreadState.ts, then review #3948 and the existing computeThreadBadgeCounts path. Trace how read state, timeline pagination, and threadActivityStorage.ts interact. Done means unread thread activity is visibly identifiable and remains discoverable when its root is outside the loaded window.
Written by the indexing model from the issue text.
Assessment
- Tech stack
- typescript
- Domain
- desktop, frontend
- Issue type
- Bug
- Difficulty
- 5/5
- Estimated time
- Over a week
- Activity status
- Active
- Clarity
- Mostly clear
- Newbie friendliness
- 35/100