Skip to content

desktop: a channel unread badge points at no thread — "(N new)" is unemphasised and only renders for roots inside the loaded window #6428

Description

@rmichelena

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 (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 boundsince: 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. 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.

  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). 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.
  • Do nothing / treat it as fix(desktop): rank read-marker eviction by read recency #5653. fix(desktop): rank read-marker eviction by read recency #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.

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions