Repository navigation
Conversation
kalvinnchau
left a comment
There was a problem hiding this comment.
🤖 One maintainability finding; no functional or security blocker established.
Trim the branch to avoid overlap with #2337 (Inbox badge) and #2401 (unread discovery). Keep Solo flat-row grouping, the toolbar badge description, and Inbox selection-restore regression tests. Co-authored-by: BasilBot <399581b6165ab01343e3a1324774b1024d76942aea563468f93c975e5ee5193c@buzz.block.builderlab.xyz> Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Signed-off-by: tulsi <tulsi@block.xyz>
Trim the branch to avoid overlap with #2337 (Inbox badge) and #2401 (unread discovery). Keep Solo flat-row grouping, the toolbar badge description, and Inbox selection-restore regression tests. Co-authored-by: BasilBot <399581b6165ab01343e3a1324774b1024d76942aea563468f93c975e5ee5193c@buzz.block.builderlab.xyz> Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Signed-off-by: tulsi <tulsi@block.xyz>
…pen conversation, describe toolbar badges (#2275) ## Summary AIDA-2353. This PR has been cut down so it doesn't overlap two open PRs: - **#2337** owns the Inbox unread count. tulsi left design feedback there. - **#2401** and its stack own finding replies in your threads and old roots. The badge, older-thread discovery and the 30-day discovery window that used to be here were dropped in favor of those PRs. What's left is what neither of them covers, plus one reading fix from beta feedback: - **Solo chats in Inbox.** Ordinary incoming top-level responses in a Solo chat appear as one flat conversation under All. They stay out of DMs, matching the sidebar's separate Solo and Direct messages sections. Its nested threads stay separate, and agent-only coordination and system events stay out. A fresh Solo response reopens an archived flat row, the same way ordinary DM activity does. Opening the flat row reads it in one save (`markTimelineRead`): the channel catch-up mark covers ordinary responses, as at the live bottom, and mentions get individual marks, so thread rows stay unread. (`unread.ts` evidence filter and `markTimelineRead`, `InboxItem.timeline`, `archive.ts` ordinary-activity check.) - **Toolbar page badges are announced.** In the toolbar placement a page's badge sits beside its icon-only button, not inside it. `PageNavigation` now points the button's `aria-describedby` at the badge, so focusing the button announces its count. This applies to #2337's Inbox count and Reminders' due count. - **Live replies in the open conversation are read.** Reported in beta feedback: a conversation open in the Inbox reader kept showing unread after a new reply arrived. Opening a row leaves focus on the list, and the reader's focus gate (`use-reading.ts`) only granted side-pane reading to Channels' plain visits (`data-reading-thread`, #1835). `PageTabs` now sets the same grant while the page shows its own content in the main pane and a selected tab in the side pane. A tab moved to the main pane still reads only by its own focus, and the reply must still be in view. New `inbox-live-read.spec.mjs` fails without the fix. - **Selection survives page navigation (tests only).** This behavior is already on main. New regression coverage: - `inbox-restoration.spec.mjs` (real shell) leaves Inbox for Messages and Reminders and comes back. - An RTL check that restoring the page doesn't replay the row's read. ## Tests - Full Vitest, `tsc --noEmit` and `biome check` pass. - `inbox-live-read` passes 10/10 repeated in Chromium and WebKit, and fails in both without the `PageTabs` change. - Chromium: `inbox-live-read`, `inbox-restoration`, `inbox`, `thread-unread` and `page-placement` pass. Vitest for `bundled/channels`, `bundled/inbox` and `use-reading` passes. - Toolbar description: - Chromium's AX tree (via CDP) reports name "Inbox" and description "3 unread conversations". - Playwright's `toHaveAccessibleDescription` passes in WebKit. - jsdom's accname reads the wrapper text instead, so the unit test asserts the `aria-describedby` association. ## Checklist - Agent review: an independent review of the reading grant found no defects (closed or kept-mounted panes, inert retained tabs and main-pane tabs never get the grant; only Inbox uses `ui.Tabs`). - Agent exercise: Playwright journeys above, in Chromium and WebKit. - Human test: tulsi tested Solo rows under All and the open-conversation read in the desktop dev app (2026-11-04). buzz-review-completed ## Not covered - Older Solo activity outside the retained unread evidence isn't fetched here. #2401's per-channel windows cover it. 🤖 Generated with [Claude Code](https://claude.com/claude-code) --------- Signed-off-by: tulsi <tulsi@block.xyz> Co-authored-by: BasilBot <399581b6165ab01343e3a1324774b1024d76942aea563468f93c975e5ee5193c@buzz.block.builderlab.xyz> Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
47e9a80 to
7bf54b5
Compare
…across repairs, from one budget total PR #2401 review, two comments on src/features/relay/unread.ts. kalvinnchau (P3): WINDOW_BUDGET = 3072 and PARTICIPATION_BUDGET = 1024 encoded the split of EVENT_BUDGET = 4096, declared 1,000 lines away, so changing the total alone broke the split. EVENT_BUDGET now sits with the window and participation constants: PARTICIPATION_BUDGET is a quarter of it and WINDOW_BUDGET the rest. The values are unchanged. kazemek21 (P2): the participation budget bounded only what one repair admits. Replies already retained cost a repair nothing, so each repair of busy remembered threads added up to 1,024 more. Pinned evidence went last, so on a full budget it evicted ordinary evidence and refused new ordinary evidence: a quiet channel's mention left, and a fresh mention was refused even after Refresh. The bound now applies where retained evidence lives, in `accept`'s eviction: - Once the event budget is full, the viewer's threads' evidence beyond PARTICIPATION_BUDGET events goes first. An ordinary event evicts that excess; a pinned event competes within the threads' share. Within the share the order is unchanged: ordinary evidence goes first, the busiest channel's oldest, and a channel left with none is unknown. - The threads' evidence is pooled by thread, as ordinary evidence is by channel: the busiest thread's oldest goes first. A single pinned pool, oldest first, empties the oldest threads once the share is full, and drops a remembered mention, an old one-event thread, as soon as newer replies arrive. The participation pass gives each thread its newest unread reply first; oldest-first retention undid that on the next repair. - `pools()` derives each event's pool key once and totals the threads' count from their pools. On a full budget, 300 single-event live accepts took 475-496 ms, against HEAD's 566-677 ms (load 55-65). The byte budget is unchanged: under byte pressure alone, the threads' evidence still goes last. Docs: docs/inbox.md and docs/unread.md say the remembered threads go last while they hold at most a quarter of a full budget, and that beyond that the busiest thread's oldest goes first. Test (unread-repair): "30 remembered threads of 200 replies, repaired again and again, keep a quarter of a full budget: a quiet channel's mentions are kept and admitted, and every thread keeps its row". One thread a channel, its replies read, so the windows return nothing and only the thread reads add the threads' evidence. Six repairs, a fresh mention with Refresh, then 4,096 live events in a third channel. - Before (HEAD's unread.ts, soft assertions, one run): the mention is gone and quiet unknown from repair 5; 21 of 30 thread rows remain; the fresh mention is refused after Refresh (quiet null, expected 2); the live channel is refused whole (null, expected 3072). - With the share but one pinned pool, oldest first: repair 5 loses the mention. - After: passes in about 0.8 s. The live channel holds 3,072, WINDOW_BUDGET, and the threads 1,024, the mentions among them; all 30 rows remain. Once balanced, about six repairs in, 7 to 9 of the 4,096 retained IDs change per repair as equally full threads trade a reply. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Signed-off-by: Matt Toohey <contact@matttoohey.com>
7bf54b5 to
c7de758
Compare
…across repairs, from one budget total PR #2401 review, two comments on src/features/relay/unread.ts. kalvinnchau (P3): WINDOW_BUDGET = 3072 and PARTICIPATION_BUDGET = 1024 encoded the split of EVENT_BUDGET = 4096, declared 1,000 lines away, so changing the total alone broke the split. EVENT_BUDGET now sits with the window and participation constants: PARTICIPATION_BUDGET is a quarter of it and WINDOW_BUDGET the rest. The values are unchanged. kazemek21 (P2): the participation budget bounded only what one repair admits. Replies already retained cost a repair nothing, so each repair of busy remembered threads added up to 1,024 more. Pinned evidence went last, so on a full budget it evicted ordinary evidence and refused new ordinary evidence: a quiet channel's mention left, and a fresh mention was refused even after Refresh. The bound now applies where retained evidence lives, in `accept`'s eviction: - Once the event budget is full, the viewer's threads' evidence beyond PARTICIPATION_BUDGET events goes first. An ordinary event evicts that excess; a pinned event competes within the threads' share. Within the share the order is unchanged: ordinary evidence goes first, the busiest channel's oldest, and a channel left with none is unknown. - The threads' evidence is pooled by thread, as ordinary evidence is by channel: the busiest thread's oldest goes first. A single pinned pool, oldest first, empties the oldest threads once the share is full, and drops a remembered mention, an old one-event thread, as soon as newer replies arrive. The participation pass gives each thread its newest unread reply first; oldest-first retention undid that on the next repair. - `pools()` derives each event's pool key once and totals the threads' count from their pools. On a full budget, 300 single-event live accepts took 475-496 ms, against HEAD's 566-677 ms (load 55-65). The byte budget is unchanged: under byte pressure alone, the threads' evidence still goes last. Docs: docs/inbox.md and docs/unread.md say the remembered threads go last while they hold at most a quarter of a full budget, and that beyond that the busiest thread's oldest goes first. Test (unread-repair): "30 remembered threads of 200 replies, repaired again and again, keep a quarter of a full budget: a quiet channel's mentions are kept and admitted, and every thread keeps its row". One thread a channel, its replies read, so the windows return nothing and only the thread reads add the threads' evidence. Six repairs, a fresh mention with Refresh, then 4,096 live events in a third channel. - Before (HEAD's unread.ts, soft assertions, one run): the mention is gone and quiet unknown from repair 5; 21 of 30 thread rows remain; the fresh mention is refused after Refresh (quiet null, expected 2); the live channel is refused whole (null, expected 3072). - With the share but one pinned pool, oldest first: repair 5 loses the mention. - After: passes in about 0.8 s. The live channel holds 3,072, WINDOW_BUDGET, and the threads 1,024, the mentions among them; all 30 rows remain. Once balanced, about six repairs in, 7 to 9 of the 4,096 retained IDs change per repair as equally full threads trade a reply. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Signed-off-by: Matt Toohey <contact@matttoohey.com>
c7de758 to
01f0b61
Compare
…across repairs, from one budget total PR #2401 review, two comments on src/features/relay/unread.ts. kalvinnchau (P3): WINDOW_BUDGET = 3072 and PARTICIPATION_BUDGET = 1024 encoded the split of EVENT_BUDGET = 4096, declared 1,000 lines away, so changing the total alone broke the split. EVENT_BUDGET now sits with the window and participation constants: PARTICIPATION_BUDGET is a quarter of it and WINDOW_BUDGET the rest. The values are unchanged. kazemek21 (P2): the participation budget bounded only what one repair admits. Replies already retained cost a repair nothing, so each repair of busy remembered threads added up to 1,024 more. Pinned evidence went last, so on a full budget it evicted ordinary evidence and refused new ordinary evidence: a quiet channel's mention left, and a fresh mention was refused even after Refresh. The bound now applies where retained evidence lives, in `accept`'s eviction: - Once the event budget is full, the viewer's threads' evidence beyond PARTICIPATION_BUDGET events goes first. An ordinary event evicts that excess; a pinned event competes within the threads' share. Within the share the order is unchanged: ordinary evidence goes first, the busiest channel's oldest, and a channel left with none is unknown. - The threads' evidence is pooled by thread, as ordinary evidence is by channel: the busiest thread's oldest goes first. A single pinned pool, oldest first, empties the oldest threads once the share is full, and drops a remembered mention, an old one-event thread, as soon as newer replies arrive. The participation pass gives each thread its newest unread reply first; oldest-first retention undid that on the next repair. - `pools()` derives each event's pool key once and totals the threads' count from their pools. On a full budget, 300 single-event live accepts took 475-496 ms, against HEAD's 566-677 ms (load 55-65). The byte budget is unchanged: under byte pressure alone, the threads' evidence still goes last. Docs: docs/inbox.md and docs/unread.md say the remembered threads go last while they hold at most a quarter of a full budget, and that beyond that the busiest thread's oldest goes first. Test (unread-repair): "30 remembered threads of 200 replies, repaired again and again, keep a quarter of a full budget: a quiet channel's mentions are kept and admitted, and every thread keeps its row". One thread a channel, its replies read, so the windows return nothing and only the thread reads add the threads' evidence. Six repairs, a fresh mention with Refresh, then 4,096 live events in a third channel. - Before (HEAD's unread.ts, soft assertions, one run): the mention is gone and quiet unknown from repair 5; 21 of 30 thread rows remain; the fresh mention is refused after Refresh (quiet null, expected 2); the live channel is refused whole (null, expected 3072). - With the share but one pinned pool, oldest first: repair 5 loses the mention. - After: passes in about 0.8 s. The live channel holds 3,072, WINDOW_BUDGET, and the threads 1,024, the mentions among them; all 30 rows remain. Once balanced, about six repairs in, 7 to 9 of the 4,096 retained IDs change per repair as equally full threads trade a reply. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Signed-off-by: Matt Toohey <contact@matttoohey.com>
kalvinnchau
left a comment
There was a problem hiding this comment.
🤖 Three test-quality findings: the modeled relay rejects valid split reads, a startup mock hides assertion failures, and admission cases miss the rule they intend to exercise. No production defect established.
…nels A thread the viewer replied in, was mentioned in or follows vanished from the Inbox once busier channels' traffic aged it out of the single global evidence read (`#h` over up to 128 channels, limit 500, about 3.6 h of a busy roster). Nothing could recover it: membership lookups were suppressed for read replies, and a read reply whose root had left the sample fell back to a row keyed by its own ID. This commit is the production tree of 4d36397 on missing-messages-in- inbox (PR #1697, frozen at 34fc406), the reviewed and live-probed tip of the first fifteen commits there (34ffd57..4d36397), taken file for file onto main. The commits after it in this series forward-port later fixes; their bodies say what each changes. Repair now reads: - one window per joined channel, since the older of its read mark and catch-up mark (the last week when never read), sized to the roster so a first run fits the budget; - the viewer's authored page, and on a device's first repair a keyset walk of their authored history (up to 6 pages of 500), with the newest authored time kept as the walk mark; - the viewer's remembered threads: the 50 most recently read or written in whole, the rest (up to 1,000 remembered) one filter per root since the newest reply the viewer wrote or read there, passing over that boundary reply by ID; - main's roster sample (newest 500 a 128-channel chunk) behind the windows, so the evidence is a superset of main's. Windows, the authored page and thread reads ride up to 128 filters a request (`isUnreadWindowBatch`), two requests in flight. A request that fails is read once more; a response over the reader's 8 MiB budget or the native host's 16 MiB cap is split. A repair in which nothing lands fails as before; one that drops some reads ends `observed` with `partial: true`, and the Inbox shows "Some recent activity could not be checked." with Refresh. Retained evidence no longer resets when the budget fills: the busiest channel's oldest ordinary evidence is evicted first, the viewer's threads last, and a channel left with nothing is unknown rather than zero. Each asked thread holds one slot of the 1,024-reply participation budget, filled with its newest unread reply first. Lookup witnesses feed the running repair, or a background follow-up after it. Participation is remembered per relay and viewer in IndexedDB (`buzz-thread-participation-v1` here; the next commits rename it). Known at this commit on main: main's fourteen #1860 tests in unread-startup.test.ts fail, and the branch's fixtures predate main's 30-day read floor. Later commits in this series retire and fix them. session.ts (FOUNDATION): two lines wire the participation store into createUnread, approved by Matt Toohey for this project. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Signed-off-by: Matt Toohey <contact@matttoohey.com>
…ants Layer 1's share of 34fc406 on missing-messages-in-inbox, the trim of PR #1697's tests: the same tests are removed from the snapshot's test files by name, and nothing else changes. Kept tests keep the snapshot's bodies, which assert layer 1's behaviour; 34fc406's bodies of the same tests also assert the device record and catch-up the later PRs add. Removed (each covered by a kept test, as 34fc406's body maps them): unread-repair.test.ts: - the roster sample keeps a read mention older than its channel's mark and a mention older than a never-read channel's week - an edit or deletion of a pinned message is admitted once pinned evidence fills the budget - a window that times out once is read again in its slot and the repair completes - a window dropped after its retry leaves its channel unknown and the Inbox partial, with the viewer's thread still read - reads the viewer's page with 127 windows first, remembered and newly taught threads next, then the remaining windows and the roster sample - a thread read's full-page split goes ahead of the remaining windows - a full page's split rereads count each reply once against the participation budget - each thread in a landing is given its newest unread reply before the busiest one takes the rest - a dropped read releases its thread's reserved slot to the reads still to land - remembers threads the viewer took part in, not every message they wrote - a followed thread is remembered as of the follow and refetched - the Inbox leaves loading once the viewer's page and remembered threads land, observing until the windows do - a read reply outside the viewer's conversations fetches only its root, never a membership lookup - a remembered thread the viewer's deleted reply joined leaves the Inbox after restart - a dropped walk page leaves the walk to the next repair without marking channels unchecked - a cold thread's read is inclusive of its newest time, so a peer reply in the same second as the viewer's is recovered - a cold thread's boundary reply that is still unread returns to a warm repair and shows unread, and a new reply in the same second joins it - a cold thread's reply marked unread below its mark moves the mark back to it, and the mark stays there across launches until the mark is cleared - for (const [name, target] of [ - a lookup feeding a thread whose mark was just lowered cannot re-advance it before another thread's mark is saved - a cold thread whose page comes back full is read whole, losing none of its replies - a dropped cold read leaves its thread to the next repair and the Inbox partial, with its channel still observed - a remembered thread in a channel the roster lacks is kept through a save and read again when the channel returns - a thread the walk teaches in a channel the roster lacks is remembered, not read, so the walk mark does not pass it - clearing the owner while a walk page reads retires the walk: no further page, thread read or save - a lookup that finds the viewer's reply while the windows read puts the thread's read ahead of the remaining windows unread-lookups.test.ts: - a lookup that lands as the repair's last read does still gets its thread read, in a follow-up - a follow-up read that fails is dropped without a retry, and the thread is still remembered - a follow-up pre-empted by a new repair leaves the thread it fed to that repair unread-startup.test.ts: - a relay that accepts reads but answers none costs one read per request after the first drop unread-lookups.test.ts also drops its `Priority` and `ReadOptions` import, which only a removed test used. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Signed-off-by: Matt Toohey <contact@matttoohey.com>
…tions on it, not every retained event Forward-port 1 of the split (#note:c2f2f5fd): 5a52257 on missing-messages-in-inbox, which came after the layer-1 tip. `inbox()` and the thread-activity snapshot folded every retained event for each channel (`foldMessages(channel, "", [...events.values()])`), so a recompute cost channels × retained events. Layer 1 retains several times main's events over more channels; on the live relay `inbox()` reached 42 ms a recompute warm (main 7.7 ms). - unread.ts: `indexEvidence()` also builds `byTarget`, the retained edits and deletions by the event they reference, and `foldable()` returns a channel's content, the overlays on it and the deletions of those, whatever channel they name. main's per-channel `folded()` cache (#2366) folds that. The fold reads overlays two levels deep and skips deleted rows, so its rows are unchanged. Layer 1 holds every edit it is sent, where the later layer holds each sender's newest; `foldable` gathers all of them either way, as the fold read them before. - fold.ts: a row's thread reference is parsed only for a pending reply, which only the channel view passes. Output is unchanged. Tests: - unread-fold.test.ts, as on 34fc406 (already written against main's read floor and folded-row cache): with 2,000 retained events across 80 channels, each fold is handed only its channel's 23 events. With the fold handed every retained event again it fails: "expected Set{ …(1999) } to deeply equal Set{ …(22) }". - fold.test.ts: rows with no pending replies equal those with an empty or unrelated set, with and without `includeReplies` (a guard). 5a52257 measured `inbox()` at 31 → 10 ms a recompute on this fixture. Not re-measured here. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Signed-off-by: Matt Toohey <contact@matttoohey.com>
…next recovery tick reads it again Forward-port 2 of the split (#note:c2f2f5fd): the layer-1 part of dd306db on missing-messages-in-inbox (PR #1697 review, kalvinnchau, P3). Since #2252 the session's minute recovery tick repairs unread only while a repair is owed or UNREAD_BACKSTOP_INTERVAL (15 minutes) has passed since the last one. A pass that dropped a read after its retry (a window request, a thread read, a root fetch) ended with `partial` set but `owing` false, so every later tick skipped unread and the partial notice and unknown channels stayed until the backstop. A pass in which nothing landed throws and stays owed. The repair's tail now sets `owing = unchecked`. A pass that drops a read stays owed, as one that fails does, and the next tick repairs; a pass with every read landed pays the debt. A relay that keeps dropping one read costs one repair a minute, as one that keeps failing whole repairs does. session.ts (FOUNDATION) is unchanged: its gate reads `unread.owed()`. Docs: docs/inbox.md says the repair stays owed after a dropped read, so the next periodic check, a minute later, repairs again, where it said "no automatic retry". docs/relay-queries.md counts a repair that dropped a read as evidence debt, cleared only by one with every read landed. Its "an evidence overflow" goes from the same sentence: this series' evidence never resets on overflow, it evicts. Test (unread-startup, through the session's real recovery gate): "a pass that drops a window chunk leaves unread owed: ...". 129 channels, so the windows are two requests: the viewer's page with 127 windows, then two windows. A full reconnect pays the marker debt; a covered reconnect then drops the second window request after its retry (`partial: true`). A minute later, with the relay answering, the tick reads it again and `partial` clears; a minute after that, the tick reads no evidence. - With `owing = false` at the tail, as before: "expected 4 to be 5" at the tick a minute later. - With the tail always owed: "expected 21 to be 17" at the last tick. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Signed-off-by: Matt Toohey <contact@matttoohey.com>
…he record's final names Forward-port 3 of the layer-1 split: 76e4193 without its events. Participation moves from `buzz-thread-participation-v1` into the store the device record later grows in, so the next layer only adds fields. - `unread-storage.ts` owns IndexedDB `buzz-unread-v1`, version 1, with two stores: `meta`, one entry per relay and viewer holding the record version and the participation (roots, their marks, the walk mark), and `events` (keyPath `[scope, id]`, index `scope`), which nothing writes yet. Creating both now means the later build needs no upgrade branch. - A record is read whole or not at all: a meta of another version or with any malformed part reads as no record, so the next repair starts as a fresh device's. The old parse took the bare list and `[key, at]` pairs; that database was never released, so nothing migrates. A save refuses a record that would not read back and keeps the saved one. - `purgeUnreadStorage` is a plain delete, as read state and the outbox purge; partition purge names it "unread state". - `createUnread` takes `storage` (`UnreadStorage`) in place of `participation`, reads `record.participation` and saves `{ participation }`. A cache clear still re-reads it from disk. - `thread-participation.ts` keeps the types, the 1,000 limit and `NO_THREAD_PARTICIPATION`; its storage half and its test are gone. `session.ts` (FOUNDATION) wires `browserUnreadStorage(readScope)` in place of `browserThreadParticipation(readScope)`: the call and its import, approved by Matt Toohey. Tests: `unread-storage.test.ts` (Node, an in-memory IndexedDB whose transactions run one at a time with structured clones) covers a round-trip through a fresh connection with both stores created, each malformed meta, a refused save keeping the saved record, a blocked open not held against the next read, and a purge clearing only its partition. Mutations each failed a case: a lenient root parse, no version check, a cached failed open, no save guard, a purge of other partitions, and no `events` store. The repair and lookup tests read and seed threads through the store; device-state's purge test names it. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Signed-off-by: Matt Toohey <contact@matttoohey.com>
Forward-port 4 of the layer-1 split. The roster's windows and sample, a thread read and the viewer's authored page were built inline, in the repair and in the participation pass; they are now `rosterReads`, `threadRead` and `authoredRead` beside `split`, in the form the later layers use, so their diffs do not carry the extraction. Requests are unchanged: the same filters, in the same order. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Signed-off-by: Matt Toohey <contact@matttoohey.com>
…remembered threads' roots last Forward-port 5 of the layer-1 split, conditional on a test written first on the layer-1 tree: a remembered thread's root, older than a never-read channel's week of window and past the roster sample, fetched by ID when the pass reads the thread, should not count. It counted (observedCount 1, expected 0), so `keepRoot` comes into layer 1 with the structure cache's order and exact bound, from 9815e47, de2d02e and da8ed62. - `landed()` hands a root fetched by ID to `keepRoot` before it accepts the replies, so they group under it at once. The root goes to the lookup cache as structure, as a lookup's fetch does, and counts only once a window or the sample returns it. The viewer's own root is remembered as its thread's lookup and witnessed. - The cache holds 1,024 events besides the remembered threads' roots it holds, counted once per remembered set and incrementally after that. Those roots go last, as their evidence does; one passed over moves to the newest end. Keeping an event again makes it the newest. - A root let go releases its `:structure` lookup. `want()` asks again only for a reply in a remembered thread, or one that teaches the pass its thread; any other released lookup stays done, so replies needing more roots than the cache holds cannot fetch one another's forever. - docs/inbox.md says roots a remembered thread's read fetched leave the row's exact-message target, and that remembered threads' roots go last. Left for layer 2: restore's `admitRestored`, the `clears` guard and the cache-clear wipe from 9815e47, and de2d02e's incomplete save. From 11139eb (layer 3), `keepRoot`'s return and the absent roots. Tests (unread-repair.test.ts): - "a remembered thread's root the repair fetches by ID, in a channel the viewer never read, groups its read mention and never counts": fails before the port at the count, and with `keepRoot` skipped. - "a remembered thread's root outlasts 1,024 other structure fetches, and a root let go is fetched again for a thread it teaches", adapted from the restore test the head's trim dropped. It fails with oldest-first eviction, a bound of 1,024 plus every remembered thread, no re-ask, a re-ask of every released lookup, and no release. Both ran with `floor: () => 0` in the setup, which forward-port 6 adds; until then main's 30-day floor reads the 1970 fixtures, as it does for the 10 tests that commit fixes. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Signed-off-by: Matt Toohey <contact@matttoohey.com>
…t a repair's channel read, and fail only the timeline's page read Forward-port 6 of the layer-1 split: the test fixes main's later work needs, from 367b38c and 11f22a6. - Read floor (#1286). `createReadState` reads everything older than 30 days before the device clock as read, and the repair fixtures date in 1970, so 10 unread-repair tests saw nothing unread (and the two `keepRoot` tests passed without checking anything). Their setup takes `floor: () => 0`, as main's setups do. unread-fold's floor and #2366's fold-cache reorder came with forward-port 1, whose fold test needed them. - #2252's recovery tests counted a repair as one evidence read: a kind-9 request, or an `include_aux` filter with kind 40002 first. A layer-1 repair sends the authored page and windows, then the roster sample, so each counted two. `tickReads` counts a channel read (`#h`, no `#e` or `ids`, kind 40002) leading a request, which is the sample once a repair, and the four tests count with it. - entity-navigation's failed-message-read test failed any kind-9 read, so the Inbox's reads, which run first, used up its scripted failures. It fails only the timeline's `top_level` page read (11f22a6). The new "a pass that drops a window chunk leaves unread owed" test still fails with `owing = false` (4 reads, expected 5) and with unread always owed (16, expected 13) under the new count. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Signed-off-by: Matt Toohey <contact@matttoohey.com>
…windows replace e8b13d4 (#1860) made main's evidence read incremental per 128-channel batch: a batch that came back short was next read from 960 s before the newest row it returned. This branch's unread.ts replaces those batches with per-channel windows and keeps no mark, so every repair reads each window in full. The 14 tests e8b13d4 added to unread-startup.test.ts, and their helpers (evidenceReads, nowSeconds, DISCOVERED, serve, discovered, serveWindow, holdNextRepair), tested behaviour this branch does not have. All 400 lines go; they match 1d8c527's deletion line for line. docs/unread.md's description of the batches and marks goes too. Coverage map. Each removed contract, then the layer-1 test covering the equivalent behaviour, or the exception: - "a repair after a complete observation reads only near the newest event it returned": exception. There is no mark, so no repair reads less than its windows. What that costs is in the PR's cost table; the catch-up stacked on this branch replaces it. - "a batch that returned nothing is read unbounded again": exception, nothing to port. Every repair reads unbounded. - "a future-dated event cannot move the next repair past the present": exception, nothing to port. There is no mark for an event to move. The catch-up ports the clamp with its mark. - "a repair never reads from before the newest event it already returned": exception, nothing to port, as above. - "regaining access reads the batch unbounded, not from the old mark": unread-startup "observes a channel again when the viewer regains access to it" stays, and "late chunk cannot publish or dispatch another after %s" (revoke) has the abandoned read start over against the current membership. - "an explicit refresh re-observes unbounded": unread-startup "explicit refresh bypasses optional profiles for both markers and evidence" stays; its trace shows the refresh's full repair. - "clearing the cache re-observes unbounded": exception. There is no mark to forget, so the repair after a clear reads in full, as every repair does. unread-startup "late chunk cannot publish or dispatch another after %s" (clear) and "a lookup in flight during a cache clear cannot restore its result" keep the clear boundary. - "capacity overflow discards the mark with the evidence it described": exception. The branch never clears evidence on overflow. It evicts the busiest channel's oldest ordinary evidence, and a channel left with none is unknown, not zero. Tests: unread-repair "a full budget evicts the busiest channel's oldest ordinary evidence, never a quiet channel's only message or the viewer's thread"; unread-startup "a full budget evicts the oldest evidence and the observation still completes". - "a read cut off at its limit installs no bound, so a freed slot still shows an older unread message": every channel has its own window, so a busy channel cannot crowd out a quiet one's message. Tests: unread-repair "a first run with a large roster sizes each window so the pass fits the budget, and a quiet channel's mention survives"; unread-startup "a full channel window does not starve later channels, and a dropped request leaves its channels unknown until explicit refresh". - "a bounded read that comes back at its limit drops the bound for the next read": exception, nothing to port. No read is bounded by a mark. - "a bounded repair answered after a live overflow restores no mark and keeps the overflow error": exception, as for the overflow above. There is no overflow error, reset or mark. - "an explicit refresh during a bounded repair waits for it, then reads in full", and "a refresh asked while a repair's answer is being accepted still reads in full": exception. A refresh during a repair joins it (`repair()` returns the running pass), as on main before e8b13d4, and that pass already reads in full. No layer-1 test pins the join for refresh; "concurrent ensure callers share the active marker/evidence work" pins it for ensure. - "renaming a channel does not move the batches, so they keep their bounds": exception, nothing to port. Windows are per channel ID and carry no bound. docs/unread.md: the coverage paragraph now covers, briefly, the cap and eviction and the per-channel windows, and defers to docs/inbox.md#ownership-and-limits for what a repair reads. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Signed-off-by: Matt Toohey <contact@matttoohey.com>
…across repairs, from one budget total PR #2401 review, two comments on src/features/relay/unread.ts. kalvinnchau (P3): WINDOW_BUDGET = 3072 and PARTICIPATION_BUDGET = 1024 encoded the split of EVENT_BUDGET = 4096, declared 1,000 lines away, so changing the total alone broke the split. EVENT_BUDGET now sits with the window and participation constants: PARTICIPATION_BUDGET is a quarter of it and WINDOW_BUDGET the rest. The values are unchanged. kazemek21 (P2): the participation budget bounded only what one repair admits. Replies already retained cost a repair nothing, so each repair of busy remembered threads added up to 1,024 more. Pinned evidence went last, so on a full budget it evicted ordinary evidence and refused new ordinary evidence: a quiet channel's mention left, and a fresh mention was refused even after Refresh. The bound now applies where retained evidence lives, in `accept`'s eviction: - Once the event budget is full, the viewer's threads' evidence beyond PARTICIPATION_BUDGET events goes first. An ordinary event evicts that excess; a pinned event competes within the threads' share. Within the share the order is unchanged: ordinary evidence goes first, the busiest channel's oldest, and a channel left with none is unknown. - The threads' evidence is pooled by thread, as ordinary evidence is by channel: the busiest thread's oldest goes first. A single pinned pool, oldest first, empties the oldest threads once the share is full, and drops a remembered mention, an old one-event thread, as soon as newer replies arrive. The participation pass gives each thread its newest unread reply first; oldest-first retention undid that on the next repair. - `pools()` derives each event's pool key once and totals the threads' count from their pools. On a full budget, 300 single-event live accepts took 475-496 ms, against HEAD's 566-677 ms (load 55-65). The byte budget is unchanged: under byte pressure alone, the threads' evidence still goes last. Docs: docs/inbox.md and docs/unread.md say the remembered threads go last while they hold at most a quarter of a full budget, and that beyond that the busiest thread's oldest goes first. Test (unread-repair): "30 remembered threads of 200 replies, repaired again and again, keep a quarter of a full budget: a quiet channel's mentions are kept and admitted, and every thread keeps its row". One thread a channel, its replies read, so the windows return nothing and only the thread reads add the threads' evidence. Six repairs, a fresh mention with Refresh, then 4,096 live events in a third channel. - Before (HEAD's unread.ts, soft assertions, one run): the mention is gone and quiet unknown from repair 5; 21 of 30 thread rows remain; the fresh mention is refused after Refresh (quiet null, expected 2); the live channel is refused whole (null, expected 3072). - With the share but one pinned pool, oldest first: repair 5 loses the mention. - After: passes in about 0.8 s. The live channel holds 3,072, WINDOW_BUDGET, and the threads 1,024, the mentions among them; all 30 rows remain. Once balanced, about six repairs in, 7 to 9 of the 4,096 retained IDs change per repair as equally full threads trade a reply. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Signed-off-by: Matt Toohey <contact@matttoohey.com>
…, answer the roster sample in the 278-channel test, and refuse a second authored page within 128 filters PR 2401 review, three P3 threads from kalvinnchau on 01f0b61. Production code is unchanged. tests/browser/policy-relay.mjs:472. The modeled relay held every unread window to a limit of at least 20, the least a repair sizes a window to. The reader halves a lone window's limit when its page comes back over the byte budget (`split` in unread.ts: 24 → 12 → 6 → 3 → 1), so the model 500ed the valid split reads the live probe produced. The model now accepts a limit of 1 to 100 for a lone window and keeps the bound of 20 to 100 for a window beside others, which is the shape every initial window request has; the unread-startup tests still assert the initial sizing (limit 20 on 278 channels). tests/integration/ policy-relay.test.mjs gains a focused case against the model itself, signed as the broker signs: a lone window at limits 12, 1, 20 and 100 is answered, two windows at 20 and 100 are answered, and two windows at 12, a lone window at 0 and a lone window at 101 are refused, each recorded in report.unexpected. With the old bound restored it fails: "Expected: >= 20, Received: 12". src/features/relay/unread-startup.test.ts:217, "queries all 278 membership IDs". The mock answered every kind-9 request with an `#h` and asserted each filter named one channel, so the roster sample (kind 9, up to 128 `#h` IDs) threw inside the mock; `pool()` retried and dropped each sample request, and the test passed with the pass partial. The sample, with no `since`, is now answered empty as the next test answers it; the mock only records each window request and the busy channel's count when it arrived; and after `ensure()` the test asserts the Inbox is ready, observed and not partial, then the recorded requests: [128, 128, 23] filters, the authored page once in the first, every window one channel at limit 20 with a `since`, all 278 channels, and a count of 1 when the third request started. With the sample throwing in the mock as before, it fails: "expected true to be undefined" at `partial`. src/features/relay/reader.test.ts:349 and browser-host/ relay-broker.test.mjs. The duplicate-authored case, `[...batch.slice(1), batch[0], batch[0]]`, had 129 filters, so the length guard refused it before the authored rule ran. The predicate's shapes now live once in src/features/relay/unread-window-batch.test.ts (16 cases: the 128 batch, the four lone shapes, and each refusal: none, 129, a second authored page within 128, one kind, no include_aux, a window without since, two windows of one channel, until, limits 0 and 501, since -1, two authors, two roots since, an ids limit not its count, null). The reader and the broker, which import the same predicate, keep the valid batch and two refusals each: 129 filters, and the second authored page within 128, `[...batch.slice(0, 127), batch[0]]`, which proves the batch reaches the predicate and not only its length. With `authored ||` removed from the rule, all three fail (the unit case "expected true to be false"; the reader's and broker's duplicate case admitted), where the 129-filter case still passed. Checks: vitest on unread-window-batch, reader, unread-startup and relay-broker (82 passed); node --test on the integration file; biome check and tsc --noEmit clean. At load average 112, unread-startup's "a failed membership lookup stays pending and retries with backoff" timed out at 5 s in every run, including HEAD's copy of the file run alone (9 s); it is untouched here. No browser journey was run under that load; the model change is covered by the integration case. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Signed-off-by: Matt Toohey <contact@matttoohey.com>
13ffa9f to
e3a9a92
Compare
kalvinnchau
left a comment
There was a problem hiding this comment.
🤖 [P3] Accept reduced authored-page limits in the modeled relay
At tests/browser/policy-relay.mjs:465–469, the model still requires exactly 100 for the viewer's authored page and 500 for its continuation. The adjacent window fix now accepts split limits, but unread.ts:2003–2025 halves any lone filter and :2297–2302 preserves reduced continuation limits. An authored response exceeding 8 MiB can therefore produce valid limit-50 or limit-250 reads that the fixture rejects with 500.
This is a statically identified test-model mismatch, not a demonstrated production defect or a reproduced failure in current browser journeys. Accept 1..100 and 1..500 respectively when the authored filter is alone, and cover these cases beside the new lone-window integration test.
…and overlay reads Restacking onto #2401's tip brought in unread-window-batch.test.ts, which admits a lone fetch of 100 IDs. This layer caps a fetch by ID at 50 and adds the overlay reads (the edits and deletions of held events, fresh or resumed), so the table failed at the 100-ID case. The lone fetch is now 50 IDs, 51 is refused, and the table admits a fresh and a resumed overlay read and refuses one without `since` or with content kinds. Production code is unchanged. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Signed-off-by: Matt Toohey <contact@matttoohey.com>
Stack
This stack splits #1697, the frozen draft umbrella at
34fc40668, into three layers. Each PR is based on the one before and merges in order. #2408's tree equals #1697's, and #1697 closes when #2408 merges.inbox-1-channel-windows): reads each channel on its own and remembers your threads, so busier channels no longer push a thread out of the Inbox. ← this PRinbox-2-device-record): saves the Inbox's evidence on the device and restores it on launch.inbox-3-catch-up): catches up from a saved mark instead of re-reading everything on every repair.Before and after
Main
c3c46c503, this PR's base, is on the left and this PR47e9a8031on the right. Both run the web app in Chromium on a fresh device against one local fixture relay with the same made-up team. The box at each pane's bottom right shows the time since the pane loaded, its relay queries and the bytes downloaded.Diego's reply in your thread appears on the right only
Busy channels push Diego's unread reply in your quiet
#rollout-teamthread out of main's newest-500 read. This PR reads each channel and your threads, so the row and the channel's thread dot appear at launch and again after a reload.pr2401-s1.mp4
One
#incident-reviewrow for one thread, not threeMain never looks up the root of a read reply, and this thread's root is older than main's read, so three read @mentions in it show as three rows. This PR fetches the root and groups them under one row; opening the thread shows the same thread on both sides.
pr2401-s2.mp4
#design-opsshows as unread on the right onlyA post from nine hours ago, with no @mention, in a channel you last read three days ago is older than main's read reaches. This PR reads the channel itself and marks it unread; main shows the post only when you open the channel.
pr2401-s3.mp4
A reply in a three-week-old thread appears on the right only
Lena answers, without an @mention, in a 21-day-old
#platform-archthread you replied in. This PR pages back through your own messages, finds the thread and reads it; main never sees the reply.pr2401-s4.mp4
One failed read: a notice on the right, an error and a lost row on the left
Here the busy channels are lighter, so main's read reaches Diego's reply, and both builds show it when every read answers. Then the relay fails that read: main shows “Relay request failed (500)” and loses the row, while this PR keeps it from its other reads and shows “Some recent activity could not be checked.” with Refresh.
pr2401-s8.mp4
Problem
A thread you had replied in vanished from the Inbox (reported in
#buzz-team). Main builds the Inbox from one sample per 128 joined channels: their newest 500 messages, and since #1860 only what is newer than that batch's last read. On a busy roster the sample reaches back only a few hours. Once busier channels push a thread out of it, nothing brings it back. A read reply whose root has aged out then shows up as a loose row of its own.What this PR contains
This is PR 1 of three. It contains the per-channel windows and the viewer's remembered threads. It does not restore evidence from the device and does not catch up from a mark: every repair (launch, reconnect, the 15-minute backstop, and recovery while a repair is owed) reads in full.
buzz-unread-v1, in itsmetastore. Itseventsstore is created empty, for PR 2.inbox()folds each channel's own evidence, and the edits and deletions on it, rather than every retained event.#1860's per-batch incremental marks go: per-channel windows replace the batches they bounded. Its 14 tests are retired; see the coverage map below.
Details and limits:
docs/inbox.mdanddocs/unread.md.Commits
b64d85cf0Snapshot. Its production tree equals4d3639704's, the layer-1 tip of fix(unread): keep participated threads in the Inbox and persist its evidence across launches #1697 before its device record and catch-up. Tests are4d3639704's. This commit is red on its own and was committed with--no-verify.9f220aa62applies fix(unread): keep participated threads in the Inbox and persist its evidence across launches #1697's test trim (34fc40668) by name to those tests.a5b5ee48a: fold per channel (5a5225718). Itsunread-fold.test.tscomes from34fc40668, so it also carries that file's 30-day read floor and perf(unread): reuse folded rows until the evidence index changes #2366 cached-fold reorder.031332b5a: keep unread owed after a pass that drops a read (dd306db07). It adds the test "a pass that drops a window chunk leaves unread owed…", shown failing beforeowing = unchecked.5211b06fe: storage under the record's final names (buzz-unread-v1,metaandevents, IDB version 1).711d93cb7: request shapes named.9b346befd:keepRoot, with the cache order and exact bound (9815e47a0,de2d02efb,da8ed62cc).c0b98bb58: tests read past main's 30-day read floor, count a repair's channel read, and fail only the timeline's page read (367b38c45,11f22a6f4).47e9a8031: retire fix(relay): resume live routes from the last connection and spread restart reconnects #1860's tests.keepRoot. The test "a remembered thread's root the repair fetches by ID, in a channel the viewer never read, groups its read mention and never counts" fails on the layer-1 tree withoutkeepRoot(observedCount1, expected 0). SokeepRootcame into this PR, with the cache order and exact bound.Cost against main
These are the plan's figures. None was re-measured on this branch. Each was taken at the commits named, and every one of those builds predates #2252 (
45caf7fd2), when every minute tick repaired. None is this branch's tree. The branch-side probes ran on #1697 before its rebase (5cc123c61or1c428329f), which holds the windows, remembered threads and walk, but not the roster sample or this PR's forward-ports.7d275a808(510 KB, 500 events). This PR: derived.1c428329f's live per-tick reads (5 requests, ~2.1 MB, ~1.8k events) plus an estimated ~0.5 MB roster sample. 229 filters in a replay of1c428329f's requests.848288b67and1c428329fagainst the block/buzz relay at24a370606on a seeded Postgres 18.6. The repair read alone is ~77×.ensure()to firstinbox()848288b67. This PR: live probe at5cc123c61, without the sample. Deltas: headless Chromium, live relay,7d275a808vs1c428329f, medians of 5.5cc123c61. Caption:7d275a808vs1c428329f, as above.inbox()CPU67e404917(~530 events). This PR: Vitest micro-benchmark on 2,000 events across 80 channels,5a5225718vs its parentf4770e830. The two use different fixtures, so they do not compare directly.A minute tick still reads nothing unless a repair is owed, as on main since #2252. Each full repair costs about 5× main's bytes, by the derived 2.6 ÷ 0.5 MB. Main's figure is for a busy roster, where #1860's marks drop at the limit; at
67e404917, which has #1860, main's repair read 481 KB. On quiet rosters main reads less, so this PR's relative cost is higher there. There is no kill switch. PR 3 brings the backstop and reconnect down to one delta.#1860 coverage map
e8b13d4 (#1860) made main's evidence read incremental per 128-channel batch: a batch that came back short was next read from 960 s before the newest row it returned. This PR reads each channel's window on its own and keeps no mark, so its 14 tests in
unread-startup.test.ts, and their helpers, tested behaviour the branch does not have. All 400 lines go (47e9a8031); they match1d8c52742's deletion line for line. For each removed test, here is the test covering the equivalent behaviour, or the exception:repair()returns the running pass), as on main before e8b13d4, and that pass already reads in full. No layer-1 test pins the join for refresh; "concurrent ensure callers share the active marker/evidence work" pins it forensure.FOUNDATION
src/features/relay/session.tsis FOUNDATION. This PR adds one import and one line to it,storage: browserUnreadStorage(readScope). Matt Toohey approved this before the change.Checks
All of these were run on
47e9a8031, on a loaded machine (load average 9–16):git diff 4d3639704 HEAD -- src tests. On the branch's files, the snapshot equals4d3639704, so everything past it is the trim and the seven forward-ports above. The other 37 files that differ were changed only by main, between4d3639704's basecbc71c85aandc3c46c503.bin/pnpm typecheck: clean.biome check --error-on-warningson the 33 changed code files: clean.git diff --check c3c46c503 HEAD: clean.src/features/relay,src/bundled/inbox,src/features/notifications,src/features/messagesandsrc/features/communities: 273 files passed and 1 skipped; 6,136 tests passed and 4 skipped.--no-depsonsidebar-unread,message-navigation,scroll,navigation-thread-history,inbox,inbox-edit,thread-unreadandchannel-lifecycle, one engine after the other: Chromium 76 passed (2.7 min) and WebKit 76 passed (3.1 min), with no timeouts or retries.031332b5a's new test fails beforeowing = uncheckedand passes after it. ThekeepRoottest fails on the layer-1 tree withoutkeepRoot.Browser cases: none were added or removed. Four journeys' request assertions are adapted to per-channel windows and the authored walk:
sidebar-unread,scroll,message-navigationandnavigation-thread-history.Not verified: the native host (its 16 MiB split, and the dev broker's
validFilters), a live relay, the live probe andjust scan. Nothing was exercised in the running app, and there has been no human test, so this stays a draft.🤖 Generated with Claude Code