fix(surface): a deleted message must not strand a channel's unread state#562
Merged
Conversation
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.
This was referenced Jul 17, 2026
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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 newpill pointing at nothing and aJump to latestthat does nothing.#test-channelhas been stuck this way since allan posted event742("QA #556: verifying the markup card clamp…") and deleted it (755). Gary's read cursor has been frozen at711ever since. Any channel is one delete away from this.Root cause
A deleted message with no replies renders no row —
buildTimelineItemsskips it. But it stays inmain(the delete folds onto the posted event rather than dropping it), and everything that asked "what is the newest message?" still answered with it:isNewestMessageVisiblelooked up adata-eidno element carries, returnedfalseforever, andmarkReadIfNewestVisiblenever fired. The read cursor could never advance past the delete — which is what made every symptom permanent and self-repeating across reloads:1 new, nothing newunreadCountcountedmain; the row can't renderfirstUnreadIdpointed at a divider never rendered, sodivider?.scrollIntoView?.()silently no-op'd andscrollTopstayed0Jump to latestinertlatest_event_idcounted the deleted messageThe fix
isRenderableMessageis now the single answer to "does this paint?", shared by the items builder, the unread count, the scroll landing, andnewestConfirmedMainEventId. 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_idexcluded 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 thebroadcastclause, 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
loadedflips.Verification
Against real prod data, not just tests:
#test-channelagainst the stuck cursor of 711 → the channel clears on deploy, no user action needed.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 reportsscrollTopof0where1000is right).Local:
pnpm lint·typecheck(7/7) ·check:migrations· shared 452 · web 775 · server 908 · e2e 119/119.🤖 Generated with Claude Code