Skip to content

fix(surface): a deleted message must not strand a channel's unread state#562

Merged
gbasin merged 1 commit into
masterfrom
fix/deleted-tail-unread
Jul 17, 2026
Merged

fix(surface): a deleted message must not strand a channel's unread state#562
gbasin merged 1 commit into
masterfrom
fix/deleted-tail-unread

Conversation

@gbasin

@gbasin gbasin commented Jul 17, 2026

Copy link
Copy Markdown
Collaborator

What's wrong

Delete the newest message in a channel and that channel goes unread forever, for everyone who hadn't already read it. Reloading lands you at the top of the scrollback with a 1 new pill pointing at nothing and a Jump to latest that does nothing.

#test-channel has been stuck this way since allan posted event 742 ("QA #556: verifying the markup card clamp…") and deleted it (755). Gary's read cursor has been frozen at 711 ever since. Any channel is one delete away from this.

Root cause

A deleted message with no replies renders no rowbuildTimelineItems skips it. But it stays in main (the delete folds onto the posted event rather than dropping it), and everything that asked "what is the newest message?" still answered with it:

const lastMessageId = visibleMessages.at(-1)?.id;          // the deleted one
const latest = el.querySelector(`[data-eid="${lastMessageId}"]`);
if (!latest) return false;                                  // ← null forever

isNewestMessageVisible looked up a data-eid no element carries, returned false forever, and markReadIfNewestVisible never fired. The read cursor could never advance past the delete — which is what made every symptom permanent and self-repeating across reloads:

Symptom Cause
1 new, nothing new unreadCount counted main; the row can't render
Starts scrolled up firstUnreadId pointed at a divider never rendered, so divider?.scrollIntoView?.() silently no-op'd and scrollTop stayed 0
Jump to latest inert routes through the same dead check
Sidebar activity dot server latest_event_id counted the deleted message

The fix

isRenderableMessage is now the single answer to "does this paint?", shared by the items builder, the unread count, the scroll landing, and newestConfirmedMainEventId. Mark-read keys off the newest message that actually paints, and a missing row falls back to scroll position instead of reporting "not visible" forever — the deadlock is no longer representable.

Server-side, latest_event_id excluded nothing: it lit the sidebar and push badge for content that no longer exists, naming an id no client could reach. The invariant is now explicit — the counter must only ever return an id a reader can actually see. That rule lived as two copies and had already drifted: the push badge never grew the broadcast clause, so it counted thread replies invisible in the feed. Both now share one definition.

Separately: the "Agent answered" chip fired on every reload. Timeline mounts before the first history page lands, so the "have I seen this answer?" baseline was taken from that empty render and the whole first page read as just-arrived. It now baselines once loaded flips.

Verification

Against real prod data, not just tests:

  • The fixed counter returns 710 for #test-channel against the stuck cursor of 711 → the channel clears on deploy, no user action needed.
  • It is the only channel in the database whose counter changes — blast radius of exactly the reported bug.

Every new test was confirmed to fail without the fix (the server one reports expected 5 to be 4 — the deleted id, the prod bug in miniature; the scroll test reports scrollTop of 0 where 1000 is right).

Local: pnpm lint · typecheck (7/7) · check:migrations · shared 452 · web 775 · server 908 · e2e 119/119.

🤖 Generated with Claude Code

Delete the newest message in a channel and that channel goes unread
forever, for everyone who had not already read it. Reloading lands you
at the top of the scrollback with a "1 new" pill that points at nothing
and a "Jump to latest" button that does nothing. #test-channel has been
stuck like this since a QA message was posted and deleted.

A deleted message with no replies renders no row: buildTimelineItems
skips it. But it stays in `main` — the delete folds onto the posted
event rather than dropping it — and everything that asks "what is the
newest message?" still answered with it. So `isNewestMessageVisible`
looked up a `data-eid` that no element carries, returned false forever,
and mark-read never fired. The cursor could never advance past the
delete, which is what made every symptom permanent and self-repeating:
the landing scrolled to a divider that was never rendered, leaving
scrollTop at 0.

`isRenderableMessage` is now the single answer to "does this paint?",
shared by the items builder, the unread count, the scroll landing, and
`newestConfirmedMainEventId`. The read cursor keys off the newest
message that actually paints, and a missing row falls back to the
scroll position instead of reporting "not visible" forever — the
deadlock is no longer representable.

Server-side, `latest_event_id` counted the deleted message too, so the
sidebar and push badge lit for content that no longer exists and named
an id no client could ever reach. It now excludes deleted messages: the
counter must only ever return an id a reader can actually see. That rule
lived as two copies and had already drifted — the push badge never grew
the broadcast clause and counted thread replies invisible in the feed —
so both now share one definition.

Separately, the "Agent answered" chip fired on every reload. Timeline
mounts before the first history page lands, and the "have I seen this
answer?" baseline was taken from that empty render, so the whole first
page read as having just arrived. It now baselines once history loads.

Verified against prod: the fixed counter returns 710 for #test-channel
against a stuck cursor of 711, so the channel clears on deploy with no
user action. It is the only channel in the database whose counter
changes.
@gbasin
gbasin added this pull request to the merge queue Jul 17, 2026
Merged via the queue into master with commit 58d20f6 Jul 17, 2026
16 checks passed
@gbasin
gbasin deleted the fix/deleted-tail-unread branch July 17, 2026 02:12
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant