Repository navigation
fix(inbox): open DM thread rows as the thread - #2587
Conversation
kazemek21
left a comment
There was a problem hiding this comment.
Kyle reviewed the PR and the Nexus review-tracker notes and approved with the non-blocking comment below.
Posted by Codex (gpt-6.1-sol) on behalf of kylez
wesbillman
left a comment
There was a problem hiding this comment.
🤖 Star Lord’s automated source review via Wes’s account, integrating independent Rocket, Mantis and Groot reviews.
Reviewed head 382368798c886f8192f578a750c14283bc324ee8 against base 15febc12eb31b96d9bf8a65e754f57eb4d91f79a.
Recommendation: resolve the existing P2 read-state concern and obtain the stated Inbox-owner product decision before merging. The threadOnly projection and re-click routing implement the described opening-time policy, but opening directly into a DM thread makes the disclosed unseen-arrival behavior the default for these rows.
The existing P2 comment is supported by source: after the thread opens, a new top-level DM can remain outside it while a subsequent focus/scroll dwell advances the whole channel frontier. readDm takes the newest retained channel timestamp (unread.ts:2174–2205), including messages the thread never displayed. This also applies when returning to a restored Inbox thread after a top-level message arrived while away. Re-clicking changes the tab but cannot recover the unread indication. The underlying DM read policy predates this change; this PR increases exposure. Keep whole-DM opening until a thread-safe read cutoff is available, or obtain explicit Inbox-owner acceptance of that precise tradeoff along with the intentional #1279 reversal. The inspected approval does not establish that product-owner decision.
The four added Inbox component cases and unread projection case cover initial thread-only/mixed opening and re-click transitions through the existing session/tab owners. No browser journeys were added or removed; the browser change is a corrected comment. A regression for the chosen unread-arrival behavior should verify the frontier as well as the tab shape.
The hosted snapshot showed 43 succeeded checks and one skipped. Source-only review: the residual is inferred from source, not runtime-reproduced. We did not run tests, builds or app workflows, and did not reproduce the author’s local validation or timing claims. The attached screenshot contains synthetic fixture data but depicts older revisions, not this reviewed head. The video, native desktop, assistive technology, themes and viewport behavior were not assessed. This COMMENT is not a formal GitHub approval or merge authorization.
3823687 to
6f6d25f
Compare
… showed Addresses the PR #2587 review (kazemek21, discussion_r4482874961, and wesbillman's review). With a DM row now opening as its thread, a top-level DM message arriving later never appears in that thread tab. The thread's next earned dwell, after refocusing or scrolling, still read the whole DM: `readDm` advanced the DM's one channel frontier through the newest retained message, including messages the thread never displayed. The same happened when returning to Activity restored the thread tab after a top-level message had arrived meanwhile. Re-clicking the row repaired navigation (it then opens the whole DM), but the message had already lost its unread state. Fix: a reading lease can name the thread its reader shows (`session.unread.reading(channelId, rootId)`). In a DM, a thread lease's read stops at one second before the earliest unread message outside that thread: a top-level message, or a reply in another thread. The thread's own root and replies, and the viewer's own messages, never stop it. "Unread" is the same predicate the Inbox projection uses for the row's `threadOnly`. With nothing unread outside the thread, a thread read still covers the whole DM, as before. Whole-DM reads (the DM timeline, which names no thread) are unchanged, and so is the row's own explicit read on click. The frontier is one timestamp, so it cannot skip a message. The thread's own replies from that outside message on stay unread too, until a whole-DM read covers them. That is deliberately conservative: the row then holds unread messages outside the thread, so it is no longer `threadOnly`, and re-clicking it opens the whole DM (the existing rule that a thread tab follows its row). Surfaces changed: every DM thread view reads through `ThreadPanel`'s `useReading`, which now passes its thread to the lease. That covers the Activity (Inbox) DM thread tab, including a tab restored on returning to Activity, and the Channels DM thread pane, plus embedded DM threads. In Channels the DM timeline beside the thread pane still reads the whole DM when it has focus; only reads earned in the thread pane alone are now bounded, as a channel thread's catch-up never reads its channel's timeline. `ThreadPanel` passes the thread it opened while the root is still unresolved, so a reply visible under an unavailable root cannot read the whole DM either. Channel and Solo reads ignore the thread and are unchanged. Tests: - unread.test.ts (catchUp and observe): one DM through a thread lease. Nothing unread outside: reads all of it. A reply in another thread: stops just before it, leaving it and the thread's later reply unread. A whole lease then reads everything. The viewer's own top-level message does not stop it, but Alice's top-level message does. The row then resumes at that message, unread, without `threadOnly`, and a further thread dwell still cannot pass it. - InboxPage.test.tsx (open tab, and tab restored after returning to Activity): open a thread-only DM row as "Thread · Alice". Alice then replies in the thread and posts at the top level, either while the tab shows or while Activity is unmounted. A focus dwell in the thread history must read the new reply but stop the frontier one second before the top-level message. The row stays unread, previews and resumes at that message, and re-clicking it opens the whole DM. jsdom has no layout or window focus: the test lays out thread history alone (so the opened thread's reveal completes and it settles, as a real layout lets it) with the window unfocused, so nothing reads until the dwell focuses the window and the history and runs a controlled clock until the read lands. A fixed 300 ms window raced the settle, which depended on a leftover reveal frame (1 in ~8 full-file runs). - ThreadPanel.test.tsx: with the original unavailable, every reading lease names the thread the panel opened. - use-reading.test.ts: leases now carry `(channelId, rootId)`; a thread list's leases name its thread. - Fail-before: restoring only unread.ts fails both owner cases (frontier 31, expected 29) and both InboxPage cases (frontier at the top-level message's second, one past the expected cutoff). Restoring only use-reading.ts (leases name no thread) fails both InboxPage cases the same way. Restoring only ThreadPanel.tsx fails the ThreadPanel case (lease `["channel", undefined]`). Docs: docs/inbox.md no longer says reading a DM, even in one of its threads, reads all of it. It now says the row's own read does, and describes the thread cutoff, the conservative unread replies, and the row opening whole meanwhile. docs/unread.md describes the bounded thread lease, and docs/buzz-help.md says when a DM row opens a thread and that DM messages outside it stay unread until the whole DM opens. Validation (machine load 4–17 throughout; each Vitest file run alone): unread, unread-startup/-follows/-invalidation/-lookups, inbox-feed, inbox-reactions, inbox archive/dm-label/index/reaction-rows/route, notifications messages-reading and messages, ThreadPanel (+identity, profile, nesting), EmbeddedThread, ConversationTab, useConversationTabs, ChannelsPage.tabs, use-reading and use-reading.dm all pass; InboxPage passed 8 of 8 full-file runs after the settle fix (two of them in parallel, load up to 17). `tsc --noEmit` and `pnpm lint` pass. Chromium journeys inbox-reading, inbox, inbox-tabs, thread-unread, sidebar-unread and typing-dm: 41 passed. No load timeouts. Remaining gap, unchanged here: the row's explicit read on click still covers the DM through the click time, so a top-level message created by then but delivered after the click is read without being shown. Co-Authored-By: Claude Opus 5.5 <[email protected]> Signed-off-by: Matt Toohey <[email protected]>
wesbillman
left a comment
There was a problem hiding this comment.
🤖 Star Lord’s automated source follow-up via Wes’s account, integrating independent Rocket, Mantis and Groot review lanes.
Reviewed head 6f6d25f6d35743d9050f6413526f4d7b2f76d47f against base 8bd102f215027965fa45d673e9918f628cb9b495 (feature merge base 2552bf65f9f2165e5fa5746a3f5c8b14bb65c1d8). This follows review 5794808133, preserving the earlier opening/re-click coverage.
No new required code changes in the focused repair. One optional improvement for the disclosed unresolved-root case is noted inline. The retained unseen-arrival case from the earlier P2 is addressed in source: dmCut stops before the earliest still-unread message outside the shown thread; thread identity reaches both the reader’s leases and the row’s captured click intent. This covers later dwells, restored Activity threads and a click on a stale threadOnly row. The shared unread owner, tab/navigation owners and existing retry fences are retained.
The added owner and component tests assert the read frontier as well as the visible tab: outside-thread arrivals, missing retained roots, manual unread during dwell, stale clicks and restoration. No browser journeys are added or removed in the current feature diff. We inspected these tests; we did not run them or reproduce the author’s fail-before and validation reports.
The documented limits remain: a late-delivered message dated at or before a saved cutoff can still be covered unseen; later thread replies remain unread behind an outside message; an unresolved exact-reply root can conservatively leave shown replies unread. The separate Inbox-owner decision accepting the #1279 reversal remains required by the PR description and is not established by the inspected feedback. This repair does not substitute for that decision.
Source-only review: no tests, builds, app workflows or CI verification were performed in this round. The unchanged attached screenshot uses fixture data and older revisions; it does not verify this repair. The video, native desktop, WebKit, assistive technology, themes and viewport behavior were not assessed. This COMMENT is not a GitHub approval or merge authorization.
…ing lease Addresses wesbillman's review thread on PR #2587 (#2587 (comment)), the unresolved-root gap named in 6f6d25f. When Activity opens a DM thread at a reply and the thread view cannot resolve the original ("Original message unavailable"), `ThreadPanel` named the opened reply (`messageId`) as the reading lease's thread. The DM thread cut then counted the thread's other replies, which share the reply's real root, as outside it: they stayed unread while shown. That failed safe, but left shown replies unread. The lease's thread is now `snapshot.root?.id ?? snapshot.target?.threadRootId ?? messageId`. `ChannelMessage.threadRootId` is the canonical root from the reply's signed tags, so an exact reply names its real thread even without its root. The `messageId` fallback remains for a view with no root evidence: opened at a root, or an exact target not yet ready (whose reply the view does not show yet). The unread subscription keys are unchanged. Tests: a new ThreadPanel.test.tsx case opens at a reply whose original is unavailable and asserts every reading lease names the reply's signed root. It fails before the fix (leases name the reply id) and passes after. The existing case still covers the fallback when opened at a root ID. Docs: docs/unread.md says a thread view opened at a reply whose root it cannot resolve names that reply's marked root as its thread. docs/inbox.md and docs/buzz-help.md did not describe the gap and need no change. Validation (load average ~4-6; each Vitest file run alone): ThreadPanel 91/91, ThreadPanel identity 3/3, nesting 34/34, profile 1/1, InboxPage 150/150, use-reading 32/32, use-reading.dm 1/1, unread 162/162. `tsc --noEmit` and `pnpm lint` pass. Not run: browser journeys, and no manual check in the desktop app. Co-Authored-By: Claude Opus 5.5 <[email protected]> Signed-off-by: Matt Toohey <[email protected]>
wesbillman
left a comment
There was a problem hiding this comment.
🤖 Star Lord’s automated source follow-up via Wes’s account, integrating independent Rocket, Mantis and Groot lanes.
Reviewed 0ae41a6ad28e72405d1e5bc633bff855ee4123f4 against base 8bd102f215027965fa45d673e9918f628cb9b495, focused on the three-file delta from 6f6d25f6.
One P2 introduced by this delta is detailed inline: when the original is unavailable, the thread view shows only the exact target, but the new fallback allows its dwell to read unseen sibling replies from the DM’s retained evidence.
I need to correct my previous optional recommendation. It assumed those siblings were displayed; threads.ts explicitly filters them out in this state. Mantis caught that missed boundary in this follow-up, and I verified it against the source. Restore the prior conservative fallback and replace the new lease-argument expectation with a regression asserting that an unseen sibling remains unread. This corrects our advice; the author implemented the suggestion as given.
Prior opening/read-frontier coverage remains retained. The separate Inbox-owner decision accepting the #1279 reversal is still needed before merge; this review does not provide product sign-off. Existing late-delivery/timestamp limits are unchanged.
Source-only: tests inspected, not run; no app, runtime or CI validation claimed. The inspected screenshot uses older fixture revisions and does not validate this delta; the linked video was not inspected.
Clicking an Inbox row for a reply inside a DM thread (e.g. "kalvin · DM ·
kalvin — might not be exactly realted", a reply in a 9-reply thread in the
kalvin DM) opened the DM's main conversation with the thread collapsed as
"9 replies", instead of opening the thread at the reply. Channel thread
rows already open their thread.
Root cause: the unread owner projects each DM as one Inbox row whose read
target is always the DM channel ({ kind: "channel" }), because a DM is
read as a whole channel. The row still records where opening resumes:
`messageId` and, when that message is a reply, `rootId`. But the Inbox
page's `tabTarget` decided "open whole" from the read target alone
(`item.target.kind === "channel"`), so every DM row opened the whole DM,
even when it resumed at a thread reply. The read target had been reused
as the open-shape decision. The Inbox hover preview already passed
`threadRootId` for these rows, so only the page's tab path dropped the
thread.
Fix: a DM row opens whole only when its resume message is top-level
(`item.rootId` unset). When it resumes at a thread reply, it opens that
thread at the reply, the same tab shape a channel thread row uses. Read
behaviour is unchanged: the DM's read target is still its channel. Rows
whose resume message is a top-level DM message still open the whole DM,
and Solo chat rows are unchanged.
Tests: a new InboxPage test covers a DM whose only incoming message is a
reply under the viewer's DM message. It fails before the fix (side tab
"Alice", the whole DM) and passes after ("Thread · Alice", with the reply
in "Thread messages"). The existing "opens a DM row's whole conversation
in a tab" test still covers top-level DM rows. DM reaction rows carry no
read target and still open the whole DM, as #2638 intends.
docs/inbox.md documents the behaviour.
Manual before/after in the running app:
1. In a DM with someone, send a top-level message, and have them reply to
it in its thread (no newer top-level message from them afterwards).
2. Open Inbox. The DM row ("<name> · DM · <name>") previews their reply.
3. Click the row.
Before: the side tab is titled with the person's name and shows the
whole DM timeline; the thread is collapsed under your message as
"N replies".
After: the side tab is "Thread · <name>" and shows the thread, headed
by your original message, with their reply revealed.
4. For contrast, a DM row whose resume message is top-level (e.g. a new
top-level message from them) still opens the whole DM.
Co-Authored-By: Claude Opus 5.5 <[email protected]>
Signed-off-by: Matt Toohey <[email protected]>
… unread
Follow-up to eb0e1600d, addressing its review.
eb0e1600d opened a DM row as a thread whenever its resume message was a
reply. But reading any of a DM reads all of it. Take a mixed DM: Alice
replies in a DM thread, then posts two top-level messages. Its row
resumed at the reply, so it opened "Thread · Alice". The row's read then
marked both top-level messages read, including the row's own preview,
without showing them. Re-clicking after the read could not reach them
either. The representative had moved to the newest top-level message, so
`tabTarget` now opened whole, but the captured tab was the thread, and
the shape-mismatch reselect kept showing that thread.
Fix:
- The unread owner's Inbox projection records `threadOnly` where it picks
the representative. It is set when every unread message (or, with none
unread, the representative) is a reply in the representative's thread.
`rootId` keeps its meaning for archive, `targetOf` and the hover
preview.
- A DM row opens as a thread only when `threadOnly` is set. A DM mixing
unread top-level and thread messages opens whole, where threads show
their reply counts, so the row's whole-DM read never covers a message
its tab hides. With nothing unread, the row follows its representative
as before. So the reported case, where the latest incoming DM message
is a thread reply, still opens the thread.
- Re-clicking the selected row still keeps a captured whole DM or session
tab when the row would now open as a thread. For example, a DM with an
older top-level message and a newer reply opens whole, and after its
read it no longer flips to the thread. A captured thread tab now follows
the row instead of being kept. A DM thread whose row has since moved to
a top-level message opens the whole DM rather than staying on a thread
that hides that message. The documented Solo chat case (a mention that
regrouped into a thread keeps its timeline tab) is unchanged.
Channel and Solo chat rows open as before: channel rows always open at a
thread, and a Solo chat's shape does not depend on `threadOnly`. DM
reaction rows carry no `threadOnly`, so they still open the whole DM, as
Tests:
- unread.test.ts: a new projection test covers one DM through four
states: thread-only unread, an older reply with a newer top-level
message, the same after reading (follows the newest, top-level
message), and an older top-level message with a newer reply, before
and after reading.
- InboxPage.test.tsx keeps the thread-only DM test and adds three:
- Older unread reply with newer top-level messages: the DM opens whole,
and stays whole on re-click after the read.
- Older unread top-level message with a newer reply: the DM opens whole.
After the read the row would open the thread, but re-click keeps the
whole DM.
- A DM opened as a thread, then a new top-level message arrives:
re-click opens the whole DM.
The tests share a small `seedDm` and tab-shape helper. jsdom does not
lay out the virtualised channel timeline, so whole-DM tabs are checked
by tab title and the "Channel message history" region, as the existing
whole-DM test does.
- Fail-before: restoring only the old `tabTarget` (`!item.rootId`) fails
the older-reply/newer-top-level test, which opens "Thread · Alice".
Restoring only the old shape-mismatch reselect fails the thread-then-
top-level re-click test, which stays on "Thread · Alice".
docs/inbox.md no longer claims parity with channel thread rows. It says
that reading a DM, even in a thread, reads all of it, when a DM row opens
as a thread, and how re-clicking treats whole and thread tabs.
Validation (local, on a heavily loaded machine; load average 100-270):
- Vitest: InboxPage.test.tsx 155/155, DraftsView 39/39, inbox-feed
45/45, unread-startup 50/50, and the other inbox/unread files
240/240, each passing when run alone. In parallel runs, a different
set of about 11 tests timed out each time. HEAD's production code
showed the same timeouts in A/B runs (3/3 archived-DM InboxPage tests,
6/50 unread-startup tests).
- Playwright inbox, inbox-archive, inbox-reading and inbox-tabs:
Chromium 19/19. WebKit 18/19. inbox-archive:658 lost its fill()'d
draft, a known WebKit flake under load. It passed twice when rerun
alone at lower load, and it opens a channel row, not a DM.
- tsc --noEmit and pnpm lint pass.
- Deferred: no manual run in the desktop app yet.
Co-Authored-By: Claude Opus 5.5 <[email protected]>
Signed-off-by: Matt Toohey <[email protected]>
Polish from the review of 3cbd60029. No behaviour changes. - The unread owner's Inbox projection now sets `threadOnly` only for DM rows. Its only consumer is the Inbox page's `tabTarget`, which reads it only for rows whose read target is the whole channel, and only DMs have that target. Channel and Solo thread rows used to carry the flag with no effect, and now omit it. The `InboxItem` comment now says it describes a DM row. - docs/inbox.md: re-clicking now says "a DM thread whose row no longer opens as a thread opens the whole DM". The old wording, "a DM row that now resumes at a top-level message", implied the resume message was the trigger. The trigger is `threadOnly` clearing, which also happens when a row still resumes at its unread reply but gains a top-level message or a reply in another thread. Validation (local, load average 66-161): - Vitest, Inbox and unread files (src/bundled/inbox/, unread*.test.ts, inbox-feed, indicator-unread): 538/542 in one run. All 4 failures were 5 s timeouts in unread-startup and InboxPage. When rerun alone, unread-startup passed 50/50. InboxPage had 3 different timeouts, and those tests, plus the archived-DM tests that timed out earlier, passed when rerun by name (6/6). Earlier runs on this branch showed the same load timeouts on HEAD. - tsc --noEmit and pnpm lint pass. Co-Authored-By: Claude Opus 5.5 <[email protected]> Signed-off-by: Matt Toohey <[email protected]>
… showed Addresses the PR #2587 review (kazemek21, discussion_r4482874961, and wesbillman's review). With a DM row now opening as its thread, a top-level DM message arriving later never appears in that thread tab. The thread's next earned dwell, after refocusing or scrolling, still read the whole DM: `readDm` advanced the DM's one channel frontier through the newest retained message, including messages the thread never displayed. The same happened when returning to Activity restored the thread tab after a top-level message had arrived meanwhile. Re-clicking the row repaired navigation (it then opens the whole DM), but the message had already lost its unread state. Fix: a reading lease can name the thread its reader shows (`session.unread.reading(channelId, rootId)`). In a DM, a thread lease's read stops at one second before the earliest unread message outside that thread: a top-level message, or a reply in another thread. The thread's own root and replies, and the viewer's own messages, never stop it. "Unread" is the same predicate the Inbox projection uses for the row's `threadOnly`. With nothing unread outside the thread, a thread read still covers the whole DM, as before. Whole-DM reads (the DM timeline, which names no thread) are unchanged, and so is the row's own explicit read on click. The frontier is one timestamp, so it cannot skip a message. The thread's own replies from that outside message on stay unread too, until a whole-DM read covers them. That is deliberately conservative: the row then holds unread messages outside the thread, so it is no longer `threadOnly`, and re-clicking it opens the whole DM (the existing rule that a thread tab follows its row). Surfaces changed: every DM thread view reads through `ThreadPanel`'s `useReading`, which now passes its thread to the lease. That covers the Activity (Inbox) DM thread tab, including a tab restored on returning to Activity, and the Channels DM thread pane, plus embedded DM threads. In Channels the DM timeline beside the thread pane still reads the whole DM when it has focus; only reads earned in the thread pane alone are now bounded, as a channel thread's catch-up never reads its channel's timeline. `ThreadPanel` passes the thread it opened while the root is still unresolved, so a reply visible under an unavailable root cannot read the whole DM either. Channel and Solo reads ignore the thread and are unchanged. Tests: - unread.test.ts (catchUp and observe): one DM through a thread lease. Nothing unread outside: reads all of it. A reply in another thread: stops just before it, leaving it and the thread's later reply unread. A whole lease then reads everything. The viewer's own top-level message does not stop it, but Alice's top-level message does. The row then resumes at that message, unread, without `threadOnly`, and a further thread dwell still cannot pass it. - InboxPage.test.tsx (open tab, and tab restored after returning to Activity): open a thread-only DM row as "Thread · Alice". Alice then replies in the thread and posts at the top level, either while the tab shows or while Activity is unmounted. A focus dwell in the thread history must read the new reply but stop the frontier one second before the top-level message. The row stays unread, previews and resumes at that message, and re-clicking it opens the whole DM. jsdom has no layout or window focus: the test lays out thread history alone (so the opened thread's reveal completes and it settles, as a real layout lets it) with the window unfocused, so nothing reads until the dwell focuses the window and the history and runs a controlled clock until the read lands. A fixed 300 ms window raced the settle, which depended on a leftover reveal frame (1 in ~8 full-file runs). - ThreadPanel.test.tsx: with the original unavailable, every reading lease names the thread the panel opened. - use-reading.test.ts: leases now carry `(channelId, rootId)`; a thread list's leases name its thread. - Fail-before: restoring only unread.ts fails both owner cases (frontier 31, expected 29) and both InboxPage cases (frontier at the top-level message's second, one past the expected cutoff). Restoring only use-reading.ts (leases name no thread) fails both InboxPage cases the same way. Restoring only ThreadPanel.tsx fails the ThreadPanel case (lease `["channel", undefined]`). Docs: docs/inbox.md no longer says reading a DM, even in one of its threads, reads all of it. It now says the row's own read does, and describes the thread cutoff, the conservative unread replies, and the row opening whole meanwhile. docs/unread.md describes the bounded thread lease, and docs/buzz-help.md says when a DM row opens a thread and that DM messages outside it stay unread until the whole DM opens. Validation (machine load 4–17 throughout; each Vitest file run alone): unread, unread-startup/-follows/-invalidation/-lookups, inbox-feed, inbox-reactions, inbox archive/dm-label/index/reaction-rows/route, notifications messages-reading and messages, ThreadPanel (+identity, profile, nesting), EmbeddedThread, ConversationTab, useConversationTabs, ChannelsPage.tabs, use-reading and use-reading.dm all pass; InboxPage passed 8 of 8 full-file runs after the settle fix (two of them in parallel, load up to 17). `tsc --noEmit` and `pnpm lint` pass. Chromium journeys inbox-reading, inbox, inbox-tabs, thread-unread, sidebar-unread and typing-dm: 41 passed. No load timeouts. Remaining gap, unchanged here: the row's explicit read on click still covers the DM through the click time, so a top-level message created by then but delivered after the click is read without being shown. Co-Authored-By: Claude Opus 5.5 <[email protected]> Signed-off-by: Matt Toohey <[email protected]>
Addresses the review of e3aeaa4. 1. A reply whose thread root is not retained belonged to no thread. The thread lease's cut matched replies by `row.rootId`, which `root()` leaves unset when the root event is not retained, as with an "Original message unavailable" root, or one older than the evidence window that the thread view fetched itself. Every reply visible in that thread then counted as outside it. The cut landed one second before the first of them, so the thread pane could never read the replies it showed. That failed safe (nothing read unseen), but it regressed from reading the whole DM. A reply now belongs to the thread its marked root names (`row.rootId ?? row.threadRootId`). 2. Per-message unread intent pushed the cut below the frontier. The cut used `isUnread`, which is also true for the message menu's Mark unread (`forcedMessages`) and device-local `msg:` marks. Those do not depend on the frontier, so advancing it never clears them. In a Channels DM, marking an older top-level message unread and then opening a different thread in the pane left that thread's new replies unread while it showed them: the dwell read nothing. Now only messages the frontier still leaves unread stop the cut (`relevant` and `afterFrontier`, which still honours synced overrides). The marked message stays unread through its own intent. 3. The row click read the whole DM even when it opened a thread. The Inbox page picks the tab shape from the rendered row, which can lag the owner's retained evidence (live updates are coalesced for up to 200 ms). A top-level message received just before the click left the rendered row `threadOnly`, so the tab opened as the thread. The click's explicit read (`prepareChannelRead`) still covered the whole DM through the click time, which read that message unseen. `prepareChannelRead(channelId, rootId)` now takes the thread an opened DM tab shows. Its cut is then the thread lease's, through retained evidence rather than the click time. The Inbox page passes the thread only for the open click (`tabTarget(...).threadId`). ✓ and "Mark as read" remain an explicit whole-DM catch-up through the click time, as they are for channels. A whole DM opened from its row is unchanged. This also narrows the click gap e3aeaa4 named: a thread tab's click no longer covers messages created after the newest retained one and received after the click. The lease and the click share one helper, `dmCut`, which replaces the lease's inline cut. 4. Tests: the InboxPage dwell helper waited with `vi.waitFor` (2 s of wall time, advancing the fake clock 50 ms per real check), so how far fake time got depended on runner speed. It now advances fake time in 50 ms rounds, failing after 2 s of fake time. 5. Docs: docs/inbox.md no longer says a message that arrived while the thread showed stays unread; that holds only for messages received before the read. It says the click and later dwells cover the DM only through the newest message received, and stop before the first unread message outside the thread. It names the remaining gap below, and says a whole DM opened from its row still reads through the click time. docs/unread.md describes the marked-root membership, Mark unread not stopping the cut, and the bounded click. Remaining gap (one timestamp frontier cannot close it): a message outside the thread that this client receives only after a thread read (dwell or click), dated at or before that read's cut, is covered without being shown. That happens with sender clock skew (the relay accepts ±900 s), a top-level message sent in the same second as a thread reply, or an outbox retry that keeps its original timestamp. It replaces the click gap named in e3aeaa4 for thread tabs. Also still open: when a thread is opened at an exact reply (as Inbox does) and the thread view cannot resolve its root either, `ThreadPanel` names that reply as the lease's thread rather than its root. The thread's other replies then still count as outside it and stay unread while shown (fails safe). Tests: - unread.test.ts: - A thread lease whose root is never retained reads both its replies (frontier 23), and a later top-level message still stops it (29). - For Mark unread and for a device-local message mark: an older top-level message marked unread does not stop a thread lease. It reads the thread's new reply (frontier 30), and the marked message stays unread. - `prepareChannelRead("dm", root)` cuts at the newest retained message (21), not the click time. With a top-level message retained before the click, it stays at 21 and that message stays unread. The whole-DM read then covers it through the click time. - InboxPage.test.tsx: a thread-only DM row. Alice's top-level message reaches the owner and the row is clicked in one `act` batch, before React re-renders the row, so it still previews the reply and opens "Thread · Alice". The frontier stops at 24, one second before the message. The row stays unread at that message, and re-clicking opens the whole DM. - Fail-before, each reverting only its own fix: - `row.rootId` membership: the unretained-root case reads 21, not 23. - `isUnread` as the bound: both mark cases stay at 21, not 30. - Explicit read ignoring `rootId`: the owner case reads through the click time, not 21. - The Inbox page passing no thread, or the owner ignoring it: the InboxPage case reads through the click time, not 24. Validation (machine load 13-22; each Vitest file run alone): all 26 affected files pass. They are unread (162), unread-startup, -follows, -invalidation and -lookups, inbox-feed, inbox-reactions, the inbox archive, dm-label, index, reaction-rows and route files, notifications messages-reading and messages, ThreadPanel (plus identity, nesting and profile), EmbeddedThread, ConversationTab, useConversationTabs (plus panels), ChannelsPage.tabs, use-reading and use-reading.dm. InboxPage passed 150/150 in 5 full-file runs. `tsc --noEmit` and `pnpm lint` pass. Chromium journeys inbox-reading, inbox, inbox-tabs, thread-unread, sidebar-unread and typing-dm: 41 passed. Not run: WebKit journeys, and no manual check in the desktop app. Co-Authored-By: Claude Opus 5.5 <[email protected]> Signed-off-by: Matt Toohey <[email protected]>
…ing lease Addresses wesbillman's review thread on PR #2587 (#2587 (comment)), the unresolved-root gap named in 6f6d25f. When Activity opens a DM thread at a reply and the thread view cannot resolve the original ("Original message unavailable"), `ThreadPanel` named the opened reply (`messageId`) as the reading lease's thread. The DM thread cut then counted the thread's other replies, which share the reply's real root, as outside it: they stayed unread while shown. That failed safe, but left shown replies unread. The lease's thread is now `snapshot.root?.id ?? snapshot.target?.threadRootId ?? messageId`. `ChannelMessage.threadRootId` is the canonical root from the reply's signed tags, so an exact reply names its real thread even without its root. The `messageId` fallback remains for a view with no root evidence: opened at a root, or an exact target not yet ready (whose reply the view does not show yet). The unread subscription keys are unchanged. Tests: a new ThreadPanel.test.tsx case opens at a reply whose original is unavailable and asserts every reading lease names the reply's signed root. It fails before the fix (leases name the reply id) and passes after. The existing case still covers the fallback when opened at a root ID. Docs: docs/unread.md says a thread view opened at a reply whose root it cannot resolve names that reply's marked root as its thread. docs/inbox.md and docs/buzz-help.md did not describe the gap and need no change. Validation (load average ~4-6; each Vitest file run alone): ThreadPanel 91/91, ThreadPanel identity 3/3, nesting 34/34, profile 1/1, InboxPage 150/150, use-reading 32/32, use-reading.dm 1/1, unread 162/162. `tsc --noEmit` and `pnpm lint` pass. Not run: browser journeys, and no manual check in the desktop app. Co-Authored-By: Claude Opus 5.5 <[email protected]> Signed-off-by: Matt Toohey <[email protected]>
…al is unavailable Addresses wesbillman's review thread on PR #2587 (#2587 (comment)), which retracts the optional suggestion implemented in 0ae41a6. When a thread view cannot load the original of the exact reply it opened, threads.ts shows only that reply: it folds the thread's other replies but keeps only the target (`!rootUnavailable || row.id === messageId`). 0ae41a6 named the reply's signed root (`snapshot.target?.threadRootId`) as the reading lease's thread. The DM thread cut then counted a sibling reply as inside the thread, although the view hides it. So when an unread sibling reached the unread owner after opening, a dwell on the shown reply advanced the DM frontier through the sibling and cleared its unread state unseen. The view observes rows individually there (no `latestMessageId`, so no catch-up), but `observe` also reads the DM through `dmCut`. Fix: restore the lease thread to `snapshot.root?.id ?? messageId`. In that view the opened reply alone is the thread, so its hidden siblings count as outside it and stop the cut: they stay unread, as before 0ae41a6. The comment now says why the signed root is wrong here. Tests: - ThreadPanel.test.tsx: the 0ae41a6 case (leases name the signed root) is replaced by a regression with the real unread owner. A DM thread view is opened at reply T (time 20) with the original unavailable, as threads.ts publishes it. An unread sibling S (30) arrives after opening. A focus dwell on T, run on a controlled clock, reads T but stops the frontier at 29, and S stays unread. With the `threadRootId` fallback restored it fails: expected 30 to be 29. - thread-target.test.ts: the unavailable-original case now also delivers a sibling reply live and asserts the view still shows only its target. Removing the threads.ts filter fails it (two rows). Docs: docs/unread.md drops the 0ae41a6 sentence. It now says a thread view opened at a reply whose original it cannot load shows only that reply, so the thread's other replies, hidden there, count as outside it and stay unread. Validation (load average 6-15; each Vitest file run alone): ThreadPanel 91/91, ThreadPanel identity 3/3, nesting 36/36, profile 1/1, InboxPage 150/150, use-reading 32/32, use-reading.dm 1/1, unread 162/162, thread-target 34/34. The new test passed 5 more runs alone. `tsc --noEmit` and `pnpm lint` pass. Not run: browser journeys, and no manual check in the desktop app. Co-Authored-By: Claude Opus 5.5 <[email protected]> Signed-off-by: Matt Toohey <[email protected]>
0ae41a6 to
2eef1a5
Compare
wesbillman
left a comment
There was a problem hiding this comment.
🤖 Star Lord automated source review via Wes’s account, with independent Rocket, Mantis and Groot lanes.
Reviewed head 2eef1a542ac2f722b09aeedf7d6f5f211d9efc25 against base/merge-base 3bcf1c4bb1111d924a84d9b710c1da2db80ccf0b. This follow-up checks the hidden-sibling repair and relevant rebase integration, retaining prior opening/read-frontier coverage.
The hidden-sibling P2 is addressed in source; no new required code changes found in this pass. ThreadPanel.tsx:1772 restores snapshot.root?.id ?? messageId. When the original is unavailable, the thread owner displays only the exact target, and its hidden unread siblings now count as outside the dwell’s scope and stop the DM frontier. Root recovery and existing lease cleanup retain the resolved-thread behavior. My earlier signed-root fallback suggestion remains retracted.
The replacement regression in ThreadPanel.test.tsx:687 uses the real unread owner and journal: after opening target T at 20, a hidden sibling S at 30 arrives; a focus dwell must persist frontier 29, read T and leave S unread. The addition in thread-target.test.ts separately checks that the real thread owner hides a live sibling. This covers the presentation/read-state boundary missed by the former argument-only test. An earlier or same-second outside sibling can conservatively leave T unread too; the timestamp-frontier and late-delivery limitations remain.
The separate Inbox-owner acceptance of reversing #1279 remains outstanding before merge. This source assessment does not supply that product decision.
Optional description cleanup: the remaining-risk bullet still says sibling replies stay unread “while shown”; in the unavailable-original case they are hidden, as the updated unread documentation now explains.
Source-only: inspected code and test bodies; did not run tests, builds, browser journeys or the app, and did not verify the author’s reported test/mutation runs or CI. The previously inspected screenshot is an older synthetic fixture comparison, not current-repair evidence; the historical video remains unviewed.
* eva/huddle-audio-fixes: (29 commits) fix(huddle): keep calls flowing under stalled sends and multiple devices fix(channels): wrap unread conversation stepping around the sidebar (#2682) fix(navigation): let bare buzz:// open Buzz without a broken-link notice (#2718) fix(inbox): open DM thread rows as the thread (#2587) test(channels): complete the context navigation fixture (#2783) fix(channels): open a thread's channel at its root message from the #channel link (#2377) fix(people): find people by a pasted npub or hex key (#2396) test(bestie): open the narrow long-history thread with clickRow (#2723) Account-first onboarding and sign-in recovery (#2579) fix(agents): scroll the avatar dock with a mouse wheel (#2567) fix(avatars): keep Done in view when the avatar popup is short (#2706) feat(messages): highlight reactions you've made (#2709) feat(usage): move Usage from the sidebar into Settings (#2742) fix(messages): keep a playing row mounted in the channel timeline (#2695) Fix plugin status declaration closure for author consumers (#2643) fix(navigation): keep empty new chats out of Back history (#1617) [buzz-app] Recover remote agent message outcomes (#2542) feat(messages): name background send destinations and attach the files that fit (#2406) feat(drafts): clear a draft from its row (#2725) Authorize external application identities in Buzz (#2685) ... Co-authored-by: Eva <011987e296fd5006292d2f930b574be47c7801048d1983c46c425d3c95f0cffd@buzz.block.builderlab.xyz> Co-authored-by: Tyler Longwell <[email protected]> Signed-off-by: Tyler Longwell <[email protected]> # Conflicts: # docs/huddles.md # src-tauri/src/relay/huddle/audio.rs
…leanup * origin/main: (84 commits) Restore macOS Cmd-W window closing when no tab handles it (#2443) Make agent transcripts quieter and link finished replies to activity (#2424) fix(channels): wrap unread conversation stepping around the sidebar (#2682) fix(navigation): let bare buzz:// open Buzz without a broken-link notice (#2718) fix(inbox): open DM thread rows as the thread (#2587) test(channels): complete the context navigation fixture (#2783) fix(channels): open a thread's channel at its root message from the #channel link (#2377) fix(people): find people by a pasted npub or hex key (#2396) test(bestie): open the narrow long-history thread with clickRow (#2723) Account-first onboarding and sign-in recovery (#2579) fix(agents): scroll the avatar dock with a mouse wheel (#2567) fix(avatars): keep Done in view when the avatar popup is short (#2706) feat(messages): highlight reactions you've made (#2709) feat(usage): move Usage from the sidebar into Settings (#2742) fix(messages): keep a playing row mounted in the channel timeline (#2695) Fix plugin status declaration closure for author consumers (#2643) fix(navigation): keep empty new chats out of Back history (#1617) [buzz-app] Recover remote agent message outcomes (#2542) feat(messages): name background send destinations and attach the files that fit (#2406) feat(drafts): clear a draft from its row (#2725) ... # Conflicts: # src/bundled/builderlab/adapter-type/Activity.test.tsx
## Problem `[webkit] tests/browser/notifications.spec.mjs:274 › a fully visible incoming row stays quiet without publishing read intent` fails intermittently at line 366: the live mention's `attention(...).unread` is `false` (Expected `true`) just before `page.clock.resume()`. Failing runs: - [Run 42218773714, job 127896226733](https://github.com/block/buzz-app/actions/runs/42218773714/job/127896226733) (Browser journeys webkit 9/14, PR 2779, which doesn't touch notifications or the timeline) - [Run 42216085209, job 127889153841](https://github.com/block/buzz-app/actions/runs/42216085209/job/127889153841) (`main` at 3bcf1c4) - [Run 42152898799, job 127682329346](https://github.com/block/buzz-app/actions/runs/42152898799/job/127682329346) (`main`) - [Run 42149589737, job 127672336676](https://github.com/block/buzz-app/actions/runs/42149589737/job/127672336676) (`main`) The race is in the product, not the test: a channel's bottom catch-up could mark read a mention that arrived after its dwell ended. ## Root cause `catchUp` in `src/features/relay/unread.ts` captures `rows = byChannel.get(channelId)`, awaits the `activity:<channel>` save, then filters `rows` for the mentions and broadcast replies to give `msg:` marks. A live event from another person goes through `extendIndex`, which pushes onto that same array in place. A mention delivered while the save is pending, dated no later than the bottom row, was therefore marked read by a dwell that ended before it existed. The comment above the code ("check retained evidence at invocation") and `docs/unread.md` (later attention with an older timestamp stays unread) both say it should wait for its own dwell. How the journey hits it, from the failing trace: 1. Focusing the history at 54484 ms starts the 300 ms dwell on the `Following row`, which is dated now+20 s. 2. `pauseClock(page, 30_000)` runs `fastForward` (54521–54582 ms). That fires the pending dwell about 40 ms in, and its catch-up starts a strict-durability IndexedDB save. 3. The test publishes the `Visible mention` at 54590 ms. It is dated now, so it is older than the bottom row. 4. If the IndexedDB commit outlasts the mention's delivery, the catch-up then marks the mention read, and line 366 fails. That depends on real I/O timing, which is why it fails rarely and only on loaded runners. ## Fix Copy the rows when `catchUp` starts (`[...(byChannel.get(channelId) ?? [])]`). Every decision then uses the evidence retained when the dwell ended. A row that arrives during the save waits for its own dwell, and the reader's mutation observer schedules that dwell when the row renders. `docs/unread.md` now says the `msg:` marks cover rows retained at dwell completion. No test timing changed: no timeouts, retries or clock steps. ## Tests - New unit test in `src/features/relay/unread.test.ts`: "bottom catch-up leaves older attention that arrives during its save unread". It holds the catch-up's storage save with the existing `holdSaveStarted` gate, delivers an older mention, then releases it. Without the fix it fails with an extra `msg:<late>: 17` frontier. With the fix it passes. - No browser cases added or removed. The existing journey is the browser coverage. ## Verification WebKit, local macOS, Playwright `--project=webkit --no-deps`. "Load" means 12 `yes` CPU stressors plus 6 processes rewriting a 1 MB file with `F_FULLFSYNC`. | Run | Unfixed `main` (3bcf1c4) | This branch | |---|---|---| | Journey ×40 under 16 CPU stressors | 40/40 pass (CPU alone didn't reproduce) | — | | Journey ×40 under CPU + fsync load | **1/40 fail**, at `:366`, Expected `true`, Received `false` (same as CI) | 40/40 pass | | Journey ×30, no load | — | 30/30 pass | | Local probe (not committed): the spec with the read-state IndexedDB commit held across the live arrival | **3/3 fail** at the same assertion (6 read-state saves, the 6th being the leaked `msg:` mark) | 10/10 pass (5 saves) | | `notifications.spec.mjs` + `unread.spec.mjs`, Chromium and WebKit | — | 36/36 pass | - `vitest run src/features/relay/`: 3157 passed, 4 skipped. - `unread.test.ts` on a merge of this branch with current `main` (067db60, #2587, which also edits `unread.ts`): 163/163 pass. - `biome check --error-on-warnings` on the touched files, `pnpm typecheck` and `git diff --check`: clean. Not done: hosted CI on this head, and a human check in the app. Agent self-review covered the full diff. Other `byChannel` readers were checked for the same pattern: `channelReadIntent` already computes its cut and keys up front. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Signed-off-by: Matt Toohey <[email protected]> Co-authored-by: Claude Opus 5.5 <[email protected]>
## Problem Follow a plugin's "Settings: GitHub" link from its details in Manage plugins, then press Back (⌘[). Manage plugins comes back with no row expanded and no details pane, so you have to find the plugin again. The selection was local component state: the Settings visit never recorded which plugin's details it showed, so Back/Forward had nothing to restore (#2419 left this open). ## Change Each Manage plugins visit records the plugin whose details it shows (or `null`) through the existing per-visit store, `navigation.savePosition("settings.pluginDetails")`, the same way `ChannelTimeline` and `ThreadPanel` keep their positions. Back/Forward to that visit (`navigation.traversal`) reopens those details with focus on **Close plugin details**. Unchanged: closing details is kept, so Forward and Back to that visit show the list; choosing Manage plugins from another section starts at the list; addressed details links (`pluginId` in the target) behave as before. Three commits, in landing order: 1. `8a14c4ae1` — record and restore the shown plugin per visit. 2. `2818a4e0d` — adjacent visits: a stored `null` clears a selection still showing; each new visit records what it starts with; Back from an addressed visit (for example the plugin-update toast) restores the earlier visit's details, while Close through `dismissPluginDetails` still lands on the list; a removed plugin records `null`; "Open in Buzz" from a plugin import keeps its plugin. 3. `35349385c` — import paths: "Open in Buzz" from a link opened outside Manage plugins records on the visit that replaced the import; a stale `onClosed` after Install + Back can't leave the closing flag set; the effect no longer writes under other sections' entries. Production change: about 65 lines in `src/app/Settings.tsx`. Docs: one sentence each in `docs/navigation-history-behavior.md` and `docs/buzz-help.md`. ## Before and after Same steps on both builds, Chromium, local fixture data. Left: `main` @ `3bcf1c4bb`. Right: this branch @ `35349385c`. At step 3 (about 0:20), ⌘[ on `main` lands on the list with nothing selected; on the branch GitHub's details reopen with focus on Close. Steps 5 and 6 show the unchanged behaviour: Close is kept across Forward/Back, and a sidebar visit starts at the list. https://github.com/user-attachments/assets/23493f50-69e7-44a6-89e1-070f337904f9  ## Tests - `src/app/Settings.test.tsx`: 11 new cases. **Fail-then-pass:** with only the test changes on `main`, 6 fail (the Back restore at `toBeVisible`, both addressed-return cases, and the three import-path cases); the other 5 guard existing behaviour (inherited details on an adjacent visit, Close of addressed details landing on the list, Open in Buzz). All 65 pass on the branch. - Fixture: one navigation request object per visit (as App's `route.request`), with `entryId`, `signal`, `position`/`savePosition`, and `pluginId`/`pluginImport` in the target. A `useSyncExternalStore` host covers the dismissal that lands synchronously (replace) versus later (`history.go()`). - Mutation check: reverting each behavioural change fails at least one named test. The exception is the redundant flag reset when leaving Plugins, which is unobservable and kept as belt and braces. - Suites: `Settings.test.tsx` + `App.profile.test.tsx` (89), App-level navigation and settings suites, `SettingsSidebar`, `DecisionsSettings`, `PluginImport`, `src/features/navigation` (19 files, 480). Biome, `tsc --noEmit`, `git diff --check` clean. - No browser cases added or removed. The recording above is a harness flow (fixture relay, Playwright), not a repo journey. ## Review Two independent agent review rounds. Round 1 found the adjacent-visit and addressed-visit cases; round 2 reproduced the two import-path cases and the stray writes. All were fixed with fail-before tests. ## Manual test 1. Settings → Manage plugins → View GitHub details → click **GitHub** in the "Settings: GitHub" line. Press ⌘[. GitHub's details should be open, the row expanded, focus on Close plugin details. ⌘] then ⌘[ should do it again. 2. Close the details. ⌘] then ⌘[: the list, no details. 3. Open any details, click Appearance, then Manage plugins in the sidebar: the list. 4. With Settings on Manage plugins showing details, trigger a plugin-update notice and open its details from the toast, then ⌘[: the earlier details should be back. ## Deferred - WebKit and the desktop app (WKWebView) weren't re-run on the final commits; the change is state logic, not layout. - Branch base is `3bcf1c4bb`; `main` has moved 3 commits (#2587, #2718, #2682), none touching these files. - #2621 (draft) uses the changed effect-dependency line as context and will need a small hand rebase. 🤖 Generated with [Claude Code](https://claude.com/claude-code) --------- Signed-off-by: mtoohey <[email protected]> Co-authored-by: Claude Opus 5.5 <[email protected]>
Forward could leave you stuck in two ways. In its To field, the first Escape closed the people list, and every later Escape did nothing, so Forward stayed open until you used Cancel or Close. And after More message actions (⋯) → **Forward message**, closing Forward with Cancel, Close or Escape left focus on the page body instead of returning it to ⋯. With this change, the first Escape in To closes the people list and the next one closes Forward. That retires its visit, as Cancel does. Closing a Forward opened from ⋯ returns focus to ⋯. ## Before and after Left: main @ `3bcf1c4bb`. Right: this branch. Same keystrokes on both. Step 2 (⋯ → Forward → Close): on the left the focus ring is gone because focus fell to the page body; on the right it's back on ⋯. Step 3: the toolbar Forward button returns focus on both sides (unchanged). Step 6 (second Escape): on the left nothing happens and the dialog stays open; on the right it closes and focus is back on ⋯. https://github.com/user-attachments/assets/cfa6d2ad-a00e-4e52-9adf-2de1ee271b0e  | Flow | `main` | This branch | |---|---|---| | Forward, To field: Escape with the people list open | Closes the list | Closes the list (unchanged) | | Forward, To field: Escape with the list closed | Nothing happens; Forward stays open | Closes Forward; Back then Forward doesn't bring it back | | ⋯ → Forward message → Cancel, Close or Escape | Focus on the page body | Focus on ⋯ | | Message context menu → Forward message → Cancel | Focus on the page body | Focus on the menu's target: the row's first button after a right-click, or the focused button after Shift+F10 | | Toolbar Forward button → Cancel | Focus on the Forward button | Unchanged | | New message, To field: Escape | Closes an open list | Unchanged | ## The change The production diff is +22/−8 across two files. `ForwardMessage.tsx` is unchanged. **Escape (`RecipientPicker.tsx`).** The To field's wrapper and its input both handled Escape, and both swallowed it even with the list closed. Each now acts only while the list is shown: `open && !disabled`, the same condition as `Popover.Root`'s `open` and the input's `aria-expanded`. Otherwise Escape reaches the host dialog, which closes through `close()` → `navigation.dismiss`, as Cancel does. Escape counts as an explicit close under the [navigation history contract](https://github.com/block/buzz-app/blob/main/docs/navigation-history.md#passive-navigation-preserves-explicit-close-dismisses), so the visit is retired. Both gates are needed, because either handler alone still swallows the second Escape. An Escape during IME composition still can't close Forward: Base UI's `useDismiss` ignores Escape while `isComposing`. **Focus (`MessageActionBar.tsx`).** Forward returns focus to whatever had focus when it opened. From the ⋯ menu, that was the Forward menu item, which unmounts with the menu, so focus fell to the page body. The Forward menu item now focuses the menu's target (with `preventScroll`) just before it calls `onForward`: - ⋯, however its menu was opened; - on the context-menu path, the context menu's own target (`contextFocus`), if it's still connected. This mirrors the toolbar Forward button, which focuses itself, and Report in the same menu, which returns focus to ⋯ (`finalFocus={menuTrigger}` in `MessageRow.tsx`). `MessageActionBar` keeps its own ref to ⋯ when no `menuTriggerRef` is passed. `menuTriggerRef` is narrowed to a `RefObject`, which its only caller, `MessageRow`, already passes. With no connected return target, `ForwardMessage.tsx`'s `finalFocus` still returns `false`, so focus goes to the page body rather than to an unrelated control. ## Why not `finalFocus → true` The first version made Forward's `finalFocus` return `true`, and review showed that this can put focus on an unrelated control. `true` hands the choice to Base UI's fallback list, which every Base UI popup in the app shares. Each popup adds the element that had focus when it opened, and Base UI focuses the newest entry still in the page, even if it has nothing to do with this message. So ⋯ got focus back only if ⋯ itself had focus when its menu opened. Two real paths miss that: - **A pointer press on ⋯ in WebKit**, which is the macOS app's webview. WebKit doesn't focus buttons on click, which is why the toolbar's Reply and Forward buttons focus themselves. - **The message context menu.** Right-clicking text focuses no button, and the hand-off dropped the context menu's own return target. In both paths, focus after Cancel or Send jumped to an earlier control, so the next Enter or Space would activate it. In the test, that's a button called "Elsewhere" from an unrelated menu. Focusing the menu's target explicitly avoids the fallback list. ## Shift+Escape in New message The picker's modifier guard doesn't exclude Shift. With the list closed, Shift+Escape in New message's To field now reaches the app-wide Mark all as read shortcut, as it does in other fields. Plain Escape there has no ancestor handler, so New message behaves as before. Inside Forward, Mark all as read isn't available in a modal visit, so Shift+Escape only closes the dialog. ## Tests `src/features/messages/ForwardMessage.test.tsx` adds six tests. All of them fail on `main` and pass with this change: - Escape closes the people list, then the dialog, and Back then Forward doesn't bring the dialog back. On `main`, the dialog stays open after the second Escape. Reverting either picker gate on its own also fails this test. - ⋯ opened from the keyboard → Forward message → Cancel, and → Escape, Escape (`it.each`): focus returns to ⋯. On `main`, it's on `<body>`. - A menu on a button called "Elsewhere" is opened and closed. Then ⋯ is pressed without being focused (`fireEvent` with `detail: 1`, standing in for the WebKit press), then Forward message → Cancel. Focus must land on ⋯. It's on `<body>` on `main` and on "Elsewhere" with the `finalFocus → true` version. - Context menu → Forward message → Cancel, opened by right-click and by Shift+F10 (`it.each`). Focus returns to the row's first button after a right-click, and to the button that had focus after Shift+F10. | Check | Result | |---|---| | `ForwardMessage`, `NewMessage`, `NewMessage.recovery` and `MessageActionBar` tests | 150/150 | | Those four files plus the `MessageActionBar`/`MessageRow` importers (`MessageRow`, `ReportMessageDialog`, `ActivityGroupPreview`, `ThreadPanel`, `ThreadPanel.nesting`) | 470/470 at `85f2e372c` on `3bcf1c4bb`; 472/472 at this head, `00875cfd0`, where #2587 adds two `ThreadPanel` tests | | The five new focus tests, 5 runs in a row | 5/5 on each run | | `tsc --noEmit` (`tsconfig.json` and `tsconfig.design.json`), `biome check`, `git diff --check` | Pass on both heads | No browser journeys were added or removed. The component tests cover the behaviour, and the browser checks below used scratch specs that aren't committed. ## Browser checks Scratch Playwright specs ran against the channel fixture workspace in Chromium and WebKit (Linux WebKit during review, macOS Playwright WebKit locally). The recording above uses Chromium. - **Pointer: ⋯ → Forward message → Cancel.** Focus ends on ⋯ without `:focus-visible`, and the action bar hides once the pointer leaves, so #397's action-bar pinning doesn't come back. On `main`, focus lands on `<body>`. - **Keyboard close: Escape, Escape, after the menu was opened by pointer or keyboard.** Forward closes and focus returns to ⋯. Base UI marks a keyboard close as focus-visible, so after a pointer open the bar stays shown. That matches #397's own case for a keyboard dismissal of a pointer-opened menu, and the toolbar Forward button behaves the same way. On `main`, the second Escape leaves Forward open. - **A no-focus press on ⋯ (dispatched `pointerdown`/`mousedown`) and the right-click coordination row.** Focus returns to the menu's target, while on `main` it lands on `<body>`. - **Scroll.** The hand-off and Base UI's focus return both use `preventScroll`, and the scroller didn't move. No visual design changes: the diff changes only key handling in the feature-owned `RecipientPicker` and the focus hand-off in `MessageActionBar`'s existing menu. Because nothing renders differently, themes, narrow widths and enlarged text weren't re-checked. `docs/buzz-help.md` doesn't describe Escape or focus return for Forward, so it needs no change. ## Deferred checks - **Native macOS app (WKWebView): a pointer press on ⋯ → Forward message → Cancel.** That press doesn't focus ⋯, and no automated engine here reproduces it: Linux WebKit focuses buttons on click, and so did Playwright's macOS WebKit. The jsdom "Elsewhere" test stands in for it. - **Bestie conversation fixture.** Forward → Cancel already drops focus to `<body>` on `main` from every Forward entry point, including the toolbar button, and it still does on this branch. Its provider records the return target in one owner, and a different owner renders the dialog. This isn't a regression, and this PR doesn't claim to fix every Forward entry point. How the real app mounts Bestie wasn't checked. - **Not run:** Firefox and screen readers. - **Human test:** still to do, so this PR stays a draft. ## Agent review Two agent reviews ran: - **First review (the `finalFocus → true` version).** It confirmed the Escape fix, including the one-person limit, no matches, the disabled state, IME and New message. It flagged the `true` fallback, which led to the explicit hand-off above. - **Second review (the reworked version).** It found no blockers: - no #397 pinning and no scroll jump; - no conflict with `handingOffFocus` or row retirement; - the `RefObject` narrowing is safe; - the jsdom press is a fair stand-in for the WebKit press. All three of its suggestions were applied: clearer comment wording, an `it.each` over right-click and Shift+F10, and one commit with an accurate message. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Signed-off-by: mtoohey <[email protected]> Co-authored-by: Claude Opus 5.5 <[email protected]>
…2803) ## Problem Close all tabs (right-click a tab, ⌘⇧W, or the macOS **Close All Tabs** menu item) closes the whole right pane. A launcher's side panel such as Todos goes with it, even though it isn't a tab. - Its launcher still reads pressed (`aria-expanded="true"`), so the first press "closes" the hidden panel and nothing visible happens. Only the second press brings Todos back. - **Toggle tab pane** and **Back** to the channel visit end the same way: Todos is hidden and the launcher reads pressed. Close all tabs from that earlier channel visit also hides Todos. ## Fix Close all tabs now leaves a launcher's side panel as it was: shown in an open pane, hidden in a closed one. The launcher reads pressed only while the pane shows the panel, so one press always brings it back. Production change: 23 lines added and 5 removed, in 4 files. 1. **`closeTabs`** (`useChannelTabState.ts`): `paneOpen: side || (s.paneOpen && !!s.sidePanel)`. A pane showing a launcher's panel stays open, and a closed pane stays closed. 2. **`onCloseTabs`** (`ChannelsPage.tsx`): when nothing is kept and a launcher's side panel is selected, shown or hidden, it dismisses toward the `"side"` pane, not `"main"`. - `dismissContent` then records the existing side-panel survivor from #1767, so the return to the channel visit keeps Todos. That's the same path that keeps Todos when its last tab closes with X. - The kept-return marker records the pane's pre-close visibility: a kept tab shows, and a side panel keeps `paneOpen` as it was. The matching return applies it after `enterVisit`, so a channel visit that saved an open pane (a retained thread or Todos was in it) doesn't reveal the Todos the user had hidden before ⌘⇧W. 3. **Launcher `pressed` and `toggle` via `shown`** (`useChannelPanels.tsx`): a side panel counts as `shown` only while the pane is open, and `pressed` and `toggle` use `shown`. - On a hidden panel, one press goes through `onOpen` (`setPaneOpen(true)`) and shows the pane with the panel. - `selected` is unchanged, so the panel stays retained while the pane is hidden. - `ChannelPanelTabs` gains `paneOpen`. `useConversationTabs.tsx` passes `tabState.paneOpen`, the flag its tab-placed `pressed` already reads. Close other tabs on a main-pane tab now keeps the side panel beside it too. **Preserved:** - [Navigation history](https://github.com/block/buzz-app/blob/main/docs/navigation-history.md): closed thread visits are still retired together. Back and Forward still skip them, and no visit's saved pane state changes. Back to a channel visit still restores that visit's saved pane. Where it was hidden, Todos stays hidden, but the launcher now reads unpressed and one press shows it. - No visual change. Pressed now matches what's on screen. **Docs:** - `shell-design.md` describes Close all tabs and hiding the pane for side panels. - `plugin-architecture.md` defines `pressed` for `channelPlacement: "side"` as "shown in the open pane", not merely selected. ## Tests **Vitest** (`useConversationTabs.test.tsx`; `launcherHarness` gains a `placement` parameter): - "Close all tabs leaves a launcher's side panel as the open/closed pane showed it": - The open case fails on main. - The closed case fails if `closeTabs` drops the `s.paneOpen &&` guard. - "a side panel's launcher reads unpressed while the closed pane hides it, and one press shows it": catches the launcher change. - "Close other tabs on a main-pane tab keeps a launcher's side panel open beside it." **Journey** (`tests/browser/todos.spec.mjs`): "Todos isn't a tab: Close all tabs keeps it, and its launcher tracks the pane". This is one new browser case; none were removed. - **Why it needs a browser:** Close all tabs retires the thread's history visit, and the page's return to the channel visit has to keep the side panel. jsdom page tests don't route visits through navigation. - **What it covers:** 1. Toggle tab pane hides Todos with the launcher unpressed, and one press shows it. 2. Close all tabs from a thread visit keeps Todos. 3. Back to a channel visit that saved a closed pane hides Todos, one press shows it, and Close all tabs from there keeps it. Since #2838 a channel visit showing Todos saves an open pane, so the journey hides the pane there first. 4. ⌘⇧W from a thread visit with the pane closed doesn't reveal Todos, even when the channel visit it returns to saved an open pane. - **What fails without each part:** - On main, it fails at the first launcher check after Toggle tab pane. - With the `closeTabs` change reverted, it fails at Close all tabs from the channel visit. - With only the launcher change, Todos is gone after Close all tabs. - If the return drops the kept-return marker's `paneOpen`, it fails at the ⌘⇧W step from a closed pane. **Fail before, pass after:** - With only the first commit's tests applied to main, Vitest fails 2 of 42. The journey fails in Chromium and WebKit, where `aria-expanded` is "true" after Toggle tab pane. - With the fix: - 139 Vitest tests pass across `useConversationTabs`, `useConversationTabs.panels`, `useChannelPanels`, `useChannelTabState` and `ChannelsPage.tabs`. - `todos.spec.mjs`, `tab-close-others.spec.mjs` and `right-pane-dismissal.spec.mjs` pass in Chromium and WebKit, 32 tests in all. So does the layout.spec case "Todos stacks beside threads". - `tsc --noEmit` and `biome check --error-on-warnings` are clean. - These local runs were on the same three patches before the rebase onto 067db60. That rebase brought in only #2587 (inbox, thread and unread files), which touches none of this PR's files. The later rebase onto be67480 brought in #2838 (retained side panels); the journey's Back setups were reconciled with it in 2a30ea9 and have not been run locally since. ## Not checked - The native ⌘⇧W menu. The browser runs dispatch `buzz:close-all-tabs`, the window event the native menu sends. The page handled it (`preventDefault`), and the result matched the right-click menu. - The desktop app. ## Related - **#2415** reworks the same dismissal-return code. This change depends on `dismissContent` keeping `(pane === "side" && tabState.sidePanel)`. Whichever of the two lands second must keep that condition. - **#1793** (draft, conflicting) rewrites `closeTabs` and changes `onCloseTabs`. If it's revived, it needs the same two rules: - A pane showing a launcher's side panel stays open on Close all tabs. - `onCloseTabs` dismisses toward the side pane while a launcher's side panel is selected, and the return reapplies the pane's pre-close visibility. ## Before and after Both runs used the same scripted steps on the same timeline. Each run checked the expected state at every step, and both passed. | Step (starts at) | Before: main @ 3bcf1c4 | After: this branch @ 2c81a4905 | |---|---|---| | 1. Press **Toggle channel todos**, then open the thread from its reply chip (3.6 s) | Thread tab above Todos. Launcher pressed. | Same. | | 2. Right-click the thread tab, choose **Close all tabs**, hold about 3.5 s (10.0 s) | The whole pane closes, and Todos goes with it. The launcher still reads pressed, so the badge turns red. | The tab closes. Todos stays and fills the pane. Launcher pressed. | | 3. Open the thread again, then ⌘⇧W (16.2 s) | Reopening brings back the thread with Todos, because Todos was never deselected. ⌘⇧W then hides both, and the launcher still reads pressed. | ⌘⇧W closes the tab and Todos stays. Launcher pressed. | | 4. Open the thread again, then **Toggle tab pane** (23.2 s) | Pane and Todos hidden. Launcher **still pressed**. | Pane and Todos hidden. Launcher **not pressed**. | | 5. Press the Todos launcher once (30.0 s) | Nothing visible happens. The launcher flips to not pressed and Todos stays hidden. | The pane comes back with the thread tab above Todos. Launcher pressed. | 2c81a4905 has the same fix and tests as this PR, from before the rebase. The only later commit changes a comment and docs. The video was recorded in Chromium against the fixture relay, with ⌘⇧W sent as the `buzz:close-all-tabs` event. Back, and Close all tabs from the earlier channel visit, are covered by the journey but not shown in the video. https://github.com/user-attachments/assets/c40bd79d-7668-4511-b065-915e533ea6e6 🤖 Generated with [Claude Code](https://claude.com/claude-code) --------- Signed-off-by: mtoohey <[email protected]> Signed-off-by: Matt Toohey <[email protected]> Co-authored-by: Claude Opus 5.5 <[email protected]>
Clicking an Activity (Inbox) row for a reply in a DM thread opened the whole DM, with the thread collapsed under "N replies". The reply stayed hidden until you opened the thread yourself, while channel thread rows already open their thread. With this change, a DM row opens its thread too, but only when that thread holds all of the row's unread messages. A DM thread's reads now stop before any unread DM message the thread does not show, so a top-level message that arrives while the thread is open stays unread.
Rebased onto
mainat2552bf65f. Conflicts were inInboxPage.tsx,InboxPage.test.tsx,docs/inbox.mdandtests/browser/inbox-archive.spec.mjs. The one that affected behaviour wastabTarget(src/bundled/inbox/InboxPage.tsx). #2638 gave reaction rows no read target, so it madetargetoptional and opened every DM row whole by channel type (channel?.channelType === "dm"). This branch had opened a DM row whole whenitem.target.kind === "channel" && !item.threadOnly. The resolution applies!threadOnlyto both DM tests:(item.target?.kind === "channel" || channel?.channelType === "dm") && !item.threadOnly. Reaction rows carry nothreadOnly, so DM reaction rows still open the whole DM, as #2638 intends. #2543 deletedtests/browser/inbox-archive.spec.mjs, so this branch's one-line comment fix in that file is gone and the branch no longer changes any browser spec.Before / after
Left:
main. Right: this branch. Both run the same seeded data in the browser test harness (headless Chromium, fixture names only).inbox-dm-thread-before-after.mp4
mainThe recording predates the rebase and the read cutoff below. Neither changes the tab that either case opens;
mainnow calls the page Activity (#2543).Root cause
The unread owner gives each DM one Inbox row. Its read target is the whole DM channel, because a DM is read as a whole. The row also records where opening resumes:
messageId, plusrootIdwhen that message is a reply. ButtabTargetinsrc/bundled/inbox/InboxPage.tsxpicked the tab shape from the read target alone (item.target.kind === "channel"). So every DM row opened the whole DM, even when it resumed at a thread reply. The hover preview already passed the thread root; only the tab path dropped it.Fix
Opening a DM row
src/features/relay/unread.tssets a newthreadOnlyflag on a DM row at the point where it picks the row's representative message. The flag is set when every unread message is a reply in the representative's thread. With nothing unread, it is set when the representative is a reply. Only DM rows carry the flag.tabTargetopens a DM row as that thread only whenthreadOnlyis set. Otherwise the row opens the whole DM, as before. DM reaction rows have nothreadOnly, so they still open whole.Reading a DM thread (review follow-up)
Before this, any read in a DM advanced the DM's one channel frontier through its newest retained message. That includes a read earned inside one of its threads, so a top-level message the thread never showed was marked read.
session.unread.reading(channelId, rootId).useReadingpasses itsrootId.ThreadPanelpasses the thread it opened even while the root is still unresolved (snapshot.root?.id ?? messageId), so a reply shown under an unavailable root cannot read the whole DM.dmCut, computes this cut.row.rootId ?? row.threadRootId), even when that root is not retained.prepareChannelRead(channelId, rootId)takes the thread the opened tab shows, and the Inbox page passestabTarget(...).threadIdfor the open click only. The cut then comes from retained messages, not the click time. This also covers a row that renders late: live updates are coalesced for up to 200 ms, so a row can still showthreadOnlyand open the thread after a top-level message has reached the owner.threadOnly, and it opens the whole DM.Docs:
docs/inbox.mdsays when a DM row opens as a thread, how re-clicking treats whole and thread tabs, and how a DM thread's reads are bounded.docs/unread.mddescribes the thread lease, its cut and the bounded click.docs/buzz-help.mdsays when a DM row opens its thread, and that DM messages outside it stay unread until the whole DM opens.Tests
Opening:
src/features/relay/unread.test.ts: a new projection test takes one DM through several states. It covers thread-only unread, then an older reply with a newer top-level message, then an older top-level message with a newer reply. The last two are checked before and after reading.src/bundled/inbox/InboxPage.test.tsx, four new tests:Reading:
src/features/relay/unread.test.ts, through a DM thread lease (catchUpandobserve):threadOnly, and a further thread dwell still cannot pass it.prepareChannelRead("dm", root)cuts at the newest retained message, not the click time, and leaves a retained top-level message unread. A whole-DM read then covers it through the click time.src/bundled/inbox/InboxPage.test.tsx, three new tests. Each checks the read frontier as well as the tab shape:actbatch, before the row re-renders. It still opens "Thread · Alice", and the click's read stops one second before the message.vi.waitFor, where how far fake time got depended on runner speed.src/features/messages/ThreadPanel.test.tsx: with the original unavailable, every reading lease names the thread the panel opened.src/features/messages/use-reading.test.ts: leases carry(channelId, rootId), and a thread list's leases name its thread.Fail-before:
main, where the side tab is "Alice" and shows the whole DM.tabTargetfails the mixed test, which then opens "Thread · Alice".unread.tsfails both owner cases (frontier 31, expected 29) and both InboxPage dwell cases.use-reading.ts(leases name no thread) fails both InboxPage dwell cases. Reverting onlyThreadPanel.tsxfails its lease test.row.rootIdalone fails the unretained-root case (21, not 23).isUnreadfails both mark cases (21, not 30).No browser journeys were added or removed (see the rebase note above).
Validation (local, on the rebased branch):
tsc --noEmitandpnpm lintpass.ThreadPanel,EmbeddedThread, conversation-tab,ChannelsPage.tabsanduse-readingfiles pass, each run alone.InboxPage.test.tsxpassed 150/150 in 5 full-file runs.inbox-reading,inbox,inbox-tabs,thread-unread,sidebar-unreadandtyping-dmall pass (41 tests).Remaining risks
The unseen-arrival risk raised in review (P2) is addressed. A DM thread's dwells and its row click no longer read a top-level message, or a reply in another thread, that this device received before the read. What remains:
ThreadPanelnames that reply as the lease's thread instead of the root. The thread's other replies then count as outside it and stay unread while shown. This fails safe: nothing is read unseen.main. ✓, Mark as read and a whole DM opened from its row read through the click time, as a channel's explicit read does. So a message created by then but delivered after the click is read without being shown.Product sign-off needed
#1279 made Inbox DM rows open the whole DM on purpose. This PR reverses that for DM rows whose unread messages are all in one thread, so it needs an explicit OK from the Inbox owner before merge.
🤖 Generated with Claude Code