Repository navigation
fix(messages): show "New messages" in channels longer than one page - #3071
Conversation
wesbillman
left a comment
There was a problem hiding this comment.
🤖 Star Lord’s automated source review via Wes’s account, integrating Rocket, Mantis and Groot.
No actionable findings at head ed6d147de52a68444fe5949d7ce67e3640541f89 against base/merge-base 40e6ccb597cf9d8b47f9692de8285458cc304017.
Reviewed the unread producer and snapshot publication, bounded repair/reset lifecycle, timeline entry ownership and divider placement, and the changed regression-test source. The change reuses the existing repair and preserves the once-per-visit boundary. The documented sample limits remain: this does not establish an exact unread boundary beyond retained evidence.
Source-only review: no tests, builds or apps were run, and no live-relay behavior was exercised. The PR description and both screenshots were inspected; the comparison video was not visually reviewed. The author’s reported test results were not independently reproduced, including the stated lack of browser/wider-suite reruns after the final amend.
kalvinnchau
left a comment
There was a problem hiding this comment.
🤖 One remaining limitation in the paged-entry divider fix.
Read a channel with more than 20 top-level messages to the bottom, switch away, let three messages arrive and come back: the new rows were there but nothing marked where you stopped. The channel window loads 20 rows on entry, so such a channel always enters with `hasMore`, and the entry boundary refused to settle on anything when history was incomplete. Channels under one page showed the line; busy channels never did. The guard existed because the first unread loaded row need not be the first unread row when unloaded history sits above the window. The timeline now places the line with history above the entry rows only when the unread store can vouch for it, through two optional channel-snapshot fields in unread.ts: - `oldestUnreadId` names the oldest unread message the timeline shows (a top-level post or broadcast reply, in rendered order). It ignores own messages, quiet agent rows and non-broadcast replies, follows reads and clears when nothing is unread. It comes from the same bounded evidence as `observedCount`, so missing history can make a later message the oldest one known. - `sampled` says a repair has read every channel in unread scope since evidence was last emptied (cache clear or capacity overflow) or a channel entered that scope. Later repairs (reconnect, backstop, refresh) only add evidence and leave it set, so they publish no extra snapshots. It lives on the store's snapshot because the store owns the repair; `sync()` reports the read-state module. `equal()` compares both so subscribers see them change. When entry history is incomplete, ChannelTimeline waits for `sampled`, then settles with no line unless `oldestUnreadId` is a row loaded on entry with a read entry row above it. Entry history is complete only when the visit captured a ready, verified, idle window with no more history, so visits that entered a cached, refreshing or still-paging window take this path too: a short channel entered from cache could not show the line before and now can under these rules. The line still stays put for the visit as older pages load, rows arrive and the reader scrolls. This narrows a race; it does not close it. Every relay read, the window's own page included, feeds the store and marks its evidence observed, so the store's oldest known unread message can sit in the window only because it has seen nothing above it. Two guards cover different parts of that: - The `sampled` wait covers the store not having repaired yet since startup, a cache clear, an overflow or a channel becoming accessible. A Chromium probe that held the repair read and re-entered a 25-message channel drew the line at message 5 while messages 0-4 were unread too. With the wait in place and the read-row guard disabled, the same probe showed no line while the repair was held and none after it returned (oldest unread: message 0). A cache clear used to leave a connected session without a repair until the 15-minute backstop or a reconnect; clear() now starts one at once, as a newly accessible channel does, so the line, unread counts and Inbox activity come back promptly. An overflow still waits for the next recovery tick. The wait does nothing about a repair that finished with a short sample. The line can now appear a moment after entry, and a host that never runs the repair never places it with history above the window. - The read-row guard covers a repair batch that filled its 500-row limit and stopped inside the window, leaving the first loaded row the oldest unread message known, but only for ordinary rows: catch-up's `activity:` mark reads all of them up to a timestamp. It never reads mentions, broadcasts, broadcast replies or messages marked unread; only their own `msg:` or `thread:` marks do. A short sample can still hide an unread one of those above the window, and the line then lands below it. A row read only by its own mark says nothing about the unloaded rows above it either. docs/unread.md records this residual. The cost is no line when the unread messages begin exactly at the first loaded row. Complete-history visits still place the line at the first unread entry row, but no longer wait on `hasMore` or `historyLimited`: a window that gains `hasMore` after entry (live arrivals trimming it) now settles from the entry rows rather than staying pending. The pending boundary now filters rows to the entry set before classifying them, so rows from older pages are not looked up on every render. `UnreadSnapshot` is re-exported through the FOUNDATION type entry `src/plugins/author.ts`. This adds two optional fields in unread.ts and does not edit author.ts, the same shape as the earlier `row` field. Help text now describes the rule instead of the old limitation, and docs/unread.md documents both fields with the bounded-evidence caveat. Tests: the restore fixture's snapshot derives `oldestUnreadId` from every row it built, loaded or not, and reports `sampled` unless a test clears it. New restore cases place the line inside incomplete history (presented and late mount, stable while older rows page in) and omit it at a first loaded row with no read row before it. A repair-wait case pins the guard's limits: with the oldest unread in the window below a receipt-read row and the repair unfinished there is no line; once the repair finishes there is none if it found an older unread row, and the line sits at the loaded row if it did not, which the store cannot tell from a sample that stopped short. The pinned "outside the entry window" case keeps two variants; its third flips: a remote read that lands before the timeline is presented and leaves a loaded row the oldest unread now places the line there. That depends on mount timing: the same read after the timeline has settled (the second variant) leaves no line, because the boundary is captured once per visit. unread.test.ts covers `oldestUnreadId` and `sampled`: absent with only window rows and during the first repair, kept with no snapshot change through a refresh, dropped by a cache clear and restored by the repair the clear starts, and dropped for a channel entering scope until its repair finishes; when that happens mid-repair, subscribers see the reset at once and it lasts until the restarted repair finishes. unread-startup.test.ts checks an overflow clears it. The clear cases of three startup tests pinned that nothing is read after a clear; they now expect the restart and still check that the abandoned repair dispatches and publishes nothing more. An Inbox test that expected activity to stay hidden after a clear now checks it is not reported as nothing to read before the repair returns, and that the repair brings it back. The idle-feed Refresh test passed for the wrong reason once the restart returned the mention before Refresh; it now shows that mention returning on its own while the cleared feed stays idle and offers Refresh instead of a spinner, and a feed-only reaction row returning only after Refresh. A browser case seeds 25 messages, reads to the bottom, receives three from a peer and asserts the line sits above the first; it fails on main and passes here. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Signed-off-by: mtoohey <mtoohey@squareup.com> Signed-off-by: Matt Toohey <contact@matttoohey.com>
…its for the repair The entry boundary waits for the unread store's `sampled` flag when entry history is incomplete. Meanwhile the reader is already at the bottom, so after the 300 ms dwell catch-up read every loaded row and observe read the unread ones. Either advanced the read revision, and the boundary's revision check then settled the visit with no line before the repair could place one. The reads also erased the oldest-unread evidence the boundary waits for, so removing the revision check alone would not have helped: the boundary has to settle first and reading follow. This is the normal startup state, where Buzz restores a cached channel while the startup repair is still running, so the divider was lost on nearly every launch. The `sampled` branch of the boundary candidate now reports `awaitingRepair`, and the timeline passes `enabled: marksRead && !awaitingRepair` to `useReading`. That branch is reached only after every earlier gate passed, so the hold applies exactly when the repair is the sole blocker; re-enabling runs the hook's effect, which schedules dwell at once. A failed repair or an evidence overflow marks the snapshot stale, which an earlier gate catches, so the hold ends with the repair either way; a read-intent change from elsewhere still settles the visit with no line and releases reading. The store only repairs once asked, so the timeline calls `ensure()` while holding: in the desktop shell the sidebar, Inbox and Channels pages already asked and a finished repair answers at once, and a host that mounts a timeline alone no longer holds reading forever. Tradeoff: rows dwelt on during the hold and left before it lifts are not marked read by that dwell; the next dwell reads them. Tests: the restore fixture gains an opt-in `reading` knob (a frontier-sync read handle whose catch-up and observe mocks clear the unread rows, advance the revision and publish, as the store does), a stubbed `ensure`, `setFreshness`, and a focused-reader dwell recipe. Three new cases enter a channel with history above the window while the repair is unfinished: the hold leaves catch-up and observe uncalled and the line absent, then places the line and catches up once `sampled` is set; a stale repair releases reading with no line; a revision change from elsewhere settles with no line and releases reading. All three fail on the previous commit, where catch-up fires at 300 ms and the line never appears. Docs: docs/unread.md records the hold and when it ends; the help guide says the divider is settled before the channel marks anything read. Signed-off-by: Matt Toohey <contact@matttoohey.com> Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
ed6d147 to
b7934cb
Compare
wesbillman
left a comment
There was a problem hiding this comment.
🤖 Star Lord automated source review via Wes’s account, integrating Rocket’s lifecycle, Mantis’s correctness and Groot’s product/test review.
Reviewed the own-reading hold follow-up at b7934cb80ac5d4fcc227f219988092fd5b2009d9 against base/merge-base 5296c33d0ac5b49c99c2bfc8560a7fc4a878d216. The original feature patch is unchanged from the earlier reviewed revision. One P2 finding is attached inline.
Optional documentation clarification: docs/buzz-help.md:328–330 promises that the divider settles before the channel marks anything read, but only the unread-repair wait holds reading. Scope that sentence to the repair wait, as docs/unread.md does.
Source-only: no tests, builds or apps run, and no runtime reproduction. Reviewed the changed test assertions and call paths; author-reported test results were not independently verified. The current PR description was inspected; the two unchanged screenshots retain the earlier inspection, while the comparison video was not visually inspected. The documented bounded-history and once-per-visit limitations remain. This is a COMMENT review, not approval or merge authorization.
kazemek21
left a comment
There was a problem hiding this comment.
Kyle reviewed this PR and the Nexus review-tracker notes. Approved with the non-blocking suggestion below.
Posted by Codex (gpt-6.1-sol) on behalf of kylez
|
anyway to make the new messages divider a different color? I think this will be hard to identify in light or dark theme |
I agree but trying to minimise changes before launch and design team is avoiding last minute changes. It'd be good to raise it in the design feedback channel @matheuskiser |
…nt-progress-rows * origin/main: (53 commits) fix(search): make Command F search the Bestie conversation (#3084) fix(plugins): keep failed update checks on the plugin's Settings row instead of a toast (#3088) Show single-agent Stop on the activity bubble (#3140) fix: support explicit hosted browser origins (#3149) fix: keep Codex tool namespace descriptions bounded (#3128) fix(messages): show "New messages" in channels longer than one page (#3071) Keep shared agent guidance generic (#3134) fix(typing): a thread reply also ends the agent's channel-level typing (#3148) Stream protected evidence across agent runtimes (#2882) feat(identity): consolidate key and account controls (#3069) Add portrait-colored foil and animated agent card reveal (#3144) NEXUS-4190: Disclose unavailable saved Solo choices (#2544) docs: mark the plugin API as preview and say which parts are steady (#3129) Open the app with a saved key; check the account's key in the background (#3064) fix(federated-identity): name only communities that require NIP-FI as affected by a switch (#3072) feat(pairing): signed in with Builderlab, say to get the app instead of pairing (#3039) Show Add agent button on shared agent previews (#3131) NEXUS-4224: Preserve peer unread intent during automatic reads (#2641) fix(plugins): review a pinned link to an installed plugin's source as an update (#3024) perf(profiles): resume verified-profile batches after a planned renewal (#2293) ... Signed-off-by: Gil Birman <gil@squareup.com> # Conflicts: # docs/buzz-help.md
…3169) Follow-up to #3071, which made a channel longer than one page wait for the unread store's repair before placing the "New messages" line and held the channel's own reading meanwhile. This addresses the three review threads left open when that PR merged. ## Problem 1. **A failed or overflowed repair held reading forever** (Kalvin, Wes). The hold keyed on `sampled` alone. A repair that failed, overflowed its evidence budget, or was abandoned by a dropped connection or an access change left `sampled` unset with `requested` already true, so `ensure()` started nothing. Once later live evidence made the store observed again, the timeline held reading until some unrelated repair happened to succeed. 2. **Any read row counted as the witness above the line** (kazemek21). A row read only by its own `msg:` receipt (dwell short of the bottom) says nothing about the rows above it, so a short repair sample could place the line below a row whose predecessors were still unread. 3. **buzz-help overstated the hold** (Wes's review summary). It said the divider settles before the channel marks anything read, as if that applied to every visit. ## Fix 1. Channel snapshots now report `sampling` while a repair that can set `sampled` is reading, about to restart, or not yet requested. The timeline holds reading only while it is set; otherwise the visit settles with no line and reading resumes, including when the channel is entered after such a repair already ended. `sampling` stays set across the gap between a repair settling and its restart (`repairAgain`), so the hold does not flicker. 2. `attention()` reports `prefixRead` when a channel-wide read reaches the row: the channel's read frontier, the `activity:` catch-up mark for an ordinary top-level message, or the retention floor. Only such a row lets the line sit below it. 3. `docs/buzz-help.md` scopes the wait to the history check and says reading resumes if that check fails; it also says the line is absent when the rows above were only read one by one. `docs/unread.md` and the code comments record both behaviours and the remaining residual. ## Testing Run with Hermit via `bin/pnpm`, on b76ba40 (parent 5cf1713, current `main`). | Scope | Result | |---|---| | `ChannelTimeline.restore.test.tsx`, `unread.test.ts`, `unread-startup.test.ts`, `use-reading.test.ts`, `use-reading.dm.test.ts` | 5 files, 422 passed | | `vitest related` on `unread.ts` and `ChannelTimeline.tsx` | 240 files, 7,143 passed (run on the parent c4b8ecd before the fast-forward to 5cf1713; that commit touches only Codex/MCP files) | | `tsc --noEmit`, Biome, `git diff --check` | clean | | Chromium `tests/browser/new-message-boundary.spec.mjs` | 4 passed, including the 25-message channel | | Not run | WebKit journey, the full suite, the native app, manual testing | Each new regression test fails without its fix: - Reverting the timeline's `sampling` release fails 3 tests: the failed-repair case where the store becomes observed again before any dwell, and entry after an already-failed and an already-overflowed repair. - Reverting the `prefixRead` check fails the receipt-only witness case. - Dropping the `repairAgain` term fails the store test's restart-gap assertion. - Removing `sampling` and `prefixRead` from the store fails the 3 store tests, including the startup test that walks failure, retry and overflow against a live session. ## Known limits - A channel read part-way by scrolling (rows read only by their own `msg:` marks) now shows no line on return. The line needs a row above it that a channel-wide read reached. - Unread mentions, broadcasts, broadcast replies and marked-unread messages above the loaded window remain the documented residual: catch-up's `activity:` mark never reads them, so a short sample can still hide one. - In the startup test harness, the first queried roster revokes access and abandons an in-flight repair without restarting it. That store behaviour predates this change; `sampling` now reports it correctly. Possible follow-up, not changed here. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Signed-off-by: Matt Toohey <contact@matttoohey.com> Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
…rminal-font-picker * 'main' of https://github.com/block/buzz-app: (168 commits) Unify Membership sharing and live roster updates (#3147) fix(activity): show one working cue per agent request (#3166) Introduce opt-in first-agent setup (#2804) fix(codex): drop the security-review sign-in prompt from harness defaults (#3188) feat(releases): publish desktop download manifest (#3154) fix(mcp): preserve current attachments across edits (#2937) fix(agents): call the Claude and Codex first-run download "the integration" (#3165) chore(goose): bump bundled goose-acp to v1.58.1 (#3161) Revert "ci: use larger GitHub runners for Rust and browser journeys (#3090)" (#3180) Show Goose's unset thinking level as Default, not Off (#3124) ci(release): publish stable and beta desktop releases from v tags (#3150) fix(messages): end the New messages hold when no repair can sample (#3169) fix(search): make Command F search the Bestie conversation (#3084) fix(plugins): keep failed update checks on the plugin's Settings row instead of a toast (#3088) Show single-agent Stop on the activity bubble (#3140) fix: support explicit hosted browser origins (#3149) fix: keep Codex tool namespace descriptions bounded (#3128) fix(messages): show "New messages" in channels longer than one page (#3071) Keep shared agent guidance generic (#3134) fix(typing): a thread reply also ends the agent's channel-level typing (#3148) ... Signed-off-by: damienrj <damien@squareup.com> # Conflicts: # src-tauri/Cargo.toml
Problem
The "New messages" divider from #2083 never appeared in a channel with more than 20 top-level messages, which is most channels anyone is behind in. The channel window loads 20 rows on entry, so such a channel always enters with
hasMore, and the entry boundary refused to settle while history was incomplete. The guard existed for a real reason: with unloaded rows above the window, the first unread loaded row need not be the first unread row.Repro (fixture relay,
readState: true,historyCounts: { alpha: 25, beta: 15 }):Same read state, same three arrivals; only the history length differs.
Fix
oldestUnreadIdon the channel snapshot (src/features/relay/unread.ts): names the oldest unread message the timeline shows (a top-level post or broadcast reply, in rendered order). It ignores own messages, quiet agent rows and non-broadcast replies, follows reads, and clears when nothing is unread. It comes from the same bounded evidence asobservedCount.equal()compares it so subscribers see it move.src/features/messages/ChannelTimeline.tsx): when entry history is incomplete, the boundary settles with no line unlessoldestUnreadIdis a row loaded on entry. Complete-history visits still place the line at the first unread entry row, but no longer wait onhasMoreorhistoryLimited, so a window that gainshasMoreafter entry (live arrivals trimming it) settles from the entry rows instead of staying pending. The pending boundary filters rows to the entry set before classifying them. The line still stays put for the visit as older pages load, rows arrive and the reader scrolls.MessageManagement.tsxis untouched;entryHistoryCompletemeans what it meant.sampledwait: a new snapshot flag saying a repair has read every channel in unread scope since evidence was last emptied (cache clear or capacity overflow) or a channel entered that scope. Later repairs (reconnect, backstop, refresh) only add evidence and leave it set, so they publish no extra snapshots. Incomplete-history visits wait for it before settling. Without it, a Chromium probe that held the repair read and re-entered a 25-message channel drew the line at message 5 while messages 0–4 were unread too.activity:mark reads all ordinary rows up to a timestamp, so a read ordinary row vouches for the rows above it.clear()used to leave a connected session without a repair until the 15-minute backstop or a reconnect; it now starts one at once, as a newly accessible channel does. An overflow still waits for the next recovery tick.docs/buzz-help.mddescribes the rule instead of the old limitation;docs/unread.mddocuments both fields with the bounded-evidence caveat.Known limits
stale, including a stale reconnect), the hold is released and reading resumes with no divider for that visit.msg:/thread:marks, never byactivity:. A repair sample that stopped short can still hide one of them, and the line then lands below it. Recorded indocs/unread.md.Reviewer notes
UnreadSnapshotis re-exported through the FOUNDATION type entrysrc/plugins/author.ts. This adds two optional fields inunread.tsand does not editauthor.ts, the same shape as feat(unread): opening a channel quiets its sidebar row #2998'srowfield. Named here so the rule is applied knowingly.compute()inunread.ts. TheoldestUnreadIdhunks will conflict with whichever lands second; this one is about 50 lines to re-apply. feat(messages): jump channel timelines to a date #2049 moves the divider JSX and fix(messages): keep older messages reachable while scrolling #2652 touches a nearby effect; neither overlaps the boundary logic.Testing
tsc --noEmit, Biome on changed filesChannelTimeline.restore,unread,MessageManagement,ChannelTimeline)tests/browser/new-message-boundary.spec.mjsThe new browser case seeds 25 messages, reads to the bottom, receives three from a peer and asserts the line sits above the first; it fails on main and passes here. New restore cases place the line inside incomplete history (presented and late mount, stable while older rows page in) and omit it at a first loaded row with no read row before it.
unread.test.tsandunread-startup.test.tscoveroldestUnreadIdandsampledthrough refresh, clear, overflow and a channel entering scope mid-repair.Not run: the native app via
just desktop, the notification and Inbox DM entry paths, and the restart after a clear against a real relay. The final amend changed only tests and the publish order of one reset, so the browser spec and wider suites were not re-run after it.Before and after
Same Playwright flow against the fixture relay (
readState: true,historyCounts: { alpha: 25, beta: 15 }) on maina905c4865(left) and this branch (right). Around 8–13 s #Alpha (25 messages) is reopened after three peer messages: the left has nothing above "Later message 1", the right shows the "New messages" line between message 24 and "Later message 1". From about 15 s #Beta (15 messages) shows the line on both sides.before-after-side-by-side.mp4
a905c4865)🤖 Generated with Claude Code