Skip to content

fix(inbox): open DM thread rows as the thread - #2587

Merged
matt2e merged 7 commits into
mainfrom
inbox-doesnt-go-to-dm-thread
Nov 6, 2026
Merged

matt2e merged 7 commits into
mainfrom
inbox-doesnt-go-to-dm-thread

Conversation

@matt2e

@matt2e matt2e commented Nov 5, 2026 •

Copy link
Copy Markdown
Contributor

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 main at 2552bf65f. Conflicts were in InboxPage.tsx, InboxPage.test.tsx, docs/inbox.md and tests/browser/inbox-archive.spec.mjs. The one that affected behaviour was tabTarget (src/bundled/inbox/InboxPage.tsx). #2638 gave reaction rows no read target, so it made target optional and opened every DM row whole by channel type (channel?.channelType === "dm"). This branch had opened a DM row whole when item.target.kind === "channel" && !item.threadOnly. The resolution applies !threadOnly to both DM tests: (item.target?.kind === "channel" || channel?.channelType === "dm") && !item.threadOnly. Reaction rows carry no threadOnly, so DM reaction rows still open the whole DM, as #2638 intends. #2543 deleted tests/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

Key moment. Left (main): the side tab "Alice Fixture" shows the whole DM, with the reply collapsed under "2 replies". Right (this branch): the side tab "Thread · Alice Fixture" shows the original message and both replies.

Case main This branch
Thread-only: Alice sends two unread replies in a thread under your message Whole DM, thread collapsed as "2 replies" "Thread · Alice Fixture": the root and both replies
Mixed: one unread reply, then a newer unread top-level message Whole DM Whole DM (unchanged on purpose)

The recording predates the rebase and the read cutoff below. Neither changes the tab that either case opens; main now 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, plus rootId when that message is a reply. But tabTarget in src/bundled/inbox/InboxPage.tsx picked 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.ts sets a new threadOnly flag 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.
  • tabTarget opens a DM row as that thread only when threadOnly is set. Otherwise the row opens the whole DM, as before. DM reaction rows have no threadOnly, so they still open whole.
  • Why not open the thread whenever the row resumes at a reply? Take a DM with an unread reply followed by newer unread top-level messages. Opening the thread would hide the row's own preview message. So a mixed DM still opens whole, where each thread shows its reply count.
  • Re-clicking the selected row:
    • A whole-DM or session tab stays whole, even if the row would now open as a thread (for example, after its read).
    • A thread tab follows the row. If the row no longer opens as a thread (for example, a new top-level message arrived), re-clicking opens the whole DM instead of staying on a thread that hides it.
    • A Solo chat mention that regrouped into a thread still keeps its timeline tab.
  • Channel rows and Solo chat rows open as before.

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.

  • A reading lease can now name the thread its reader shows: session.unread.reading(channelId, rootId). useReading passes its rootId. ThreadPanel passes 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.
  • In a DM, a thread read covers retained messages, but stops one second before the earliest message outside that thread that the frontier still leaves unread: a top-level message, or a reply in another thread. A new helper, dmCut, computes this cut.
    • The thread's own root and replies never stop it. A reply belongs to the thread its marked root names (row.rootId ?? row.threadRootId), even when that root is not retained.
    • The viewer's own messages never stop it.
    • A message kept unread by Mark unread or a device-local mark does not stop it either. Advancing the frontier leaves that message unread anyway.
    • With nothing unread outside the thread, a thread read still covers the whole DM, as before.
  • The row's click reads the same way when it opens a thread. prepareChannelRead(channelId, rootId) takes the thread the opened tab shows, and the Inbox page passes tabTarget(...).threadId for 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 show threadOnly and open the thread after a top-level message has reached the owner.
  • One frontier cannot skip a message. So once a message outside the thread stays unread, the thread's own later replies 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 it opens the whole DM.
  • Surfaces: the Activity DM thread tab (including one restored on returning to Activity), the Channels DM thread pane and 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 bounded.
  • Unchanged: whole-DM reads, ✓ and Mark as read (an explicit whole-DM catch-up through the click time), a whole DM opened from its row, and all channel and Solo reads.

Docs: docs/inbox.md says 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.md describes the thread lease, its cut and the bounded click. docs/buzz-help.md says 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:
    • A DM whose only unread message is a reply opens "Thread · Alice" with the reply shown.
    • An older unread reply with newer top-level messages opens the whole DM, and stays whole on re-click after the read.
    • An older unread top-level message with a newer reply opens the whole DM. After the read, re-click keeps it whole.
    • A DM opened as a thread receives a new top-level message, and re-click opens the whole DM.

Reading:

  • src/features/relay/unread.test.ts, through a DM thread lease (catchUp and observe):
    • With nothing unread outside the thread, it reads the whole DM.
    • A reply in another thread stops it just before that reply. That reply and the thread's later reply stay unread, and a whole lease then reads everything.
    • The viewer's own top-level message does not stop it, but Alice's does. The row then resumes at that message, unread and without threadOnly, and a further thread dwell still cannot pass it.
    • A thread whose root is never retained reads both its replies, and a later top-level message still stops it.
    • An older top-level message marked unread, by Mark unread or by a device-local mark, does not stop the thread's read. The marked message stays unread.
    • 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:
    • With a thread-only DM open as "Thread · Alice", Alice replies in the thread and posts at the top level. A focus dwell in the thread reads the reply but stops one second before the top-level message. The row stays unread at that message, and re-clicking opens the whole DM.
    • The same, with the messages arriving while Activity is unmounted and the thread tab restored on return.
    • Alice's top-level message reaches the owner and the row is clicked in one act batch, before the row re-renders. It still opens "Thread · Alice", and the click's read stops one second before the message.
    • The dwell helper advances fake time in 50 ms rounds and fails after 2 s of fake time. It replaces 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:

  • The thread-only opening test fails on main, where the side tab is "Alice" and shows the whole DM.
  • Restoring only the old tabTarget fails the mixed test, which then opens "Thread · Alice".
  • Restoring only the old re-click rule fails the thread-then-top-level test, which then stays on the thread.
  • Reverting only the thread cut in unread.ts fails both owner cases (frontier 31, expected 29) and both InboxPage dwell cases.
  • Reverting only use-reading.ts (leases name no thread) fails both InboxPage dwell cases. Reverting only ThreadPanel.tsx fails its lease test.
  • Matching thread membership by row.rootId alone fails the unretained-root case (21, not 23).
  • Bounding the cut by isUnread fails both mark cases (21, not 30).
  • An owner click read that ignores the thread reads through the click time. That fails the owner case (expected 21) and the InboxPage stale-row case (expected 24). An Inbox page that passes no thread fails the stale-row case the same way.

No browser journeys were added or removed (see the rebase note above).

Validation (local, on the rebased branch):

  • tsc --noEmit and pnpm lint pass.
  • Vitest: all 26 affected unread, Inbox, notifications, ThreadPanel, EmbeddedThread, conversation-tab, ChannelsPage.tabs and use-reading files pass, each run alone. InboxPage.test.tsx passed 150/150 in 5 full-file runs.
  • Playwright Chromium: inbox-reading, inbox, inbox-tabs, thread-unread, sidebar-unread and typing-dm all pass (41 tests).
  • Not run on this head: WebKit journeys, and the desktop app. Agent checks ran in jsdom and the browser harness only.
  • This push skipped the local pre-push hooks, because this machine times out tests under load. Hosted CI runs the full suite.

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:

  • Late delivery. A message outside the thread can reach this device only after a thread read (dwell or click). If it is dated at or before that read's cut, it is read without being shown. This 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. A single timestamp frontier cannot close this.
  • Thread replies after an outside message stay unread while shown. This is deliberate (see Fix). The row then opens the whole DM, which reads them.
  • Unresolved root at an exact reply. Activity opens a thread at an exact reply. If the thread view cannot resolve the root either, ThreadPanel names 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.
  • Unchanged from 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

@matt2e
matt2e requested a review from a team as a code owner November 5, 2026 06:02

@kazemek21 kazemek21 left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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

Comment thread src/bundled/inbox/InboxPage.tsx Resolved

@wesbillman wesbillman left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🤖 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.

@matt2e
matt2e force-pushed the inbox-doesnt-go-to-dm-thread branch from 3823687 to 6f6d25f Compare November 6, 2026 04:34
matt2e added a commit that referenced this pull request Nov 6, 2026
… 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 wesbillman left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🤖 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.

Comment thread src/features/messages/ThreadPanel.tsx Resolved
matt2e added a commit that referenced this pull request Nov 6, 2026
…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 wesbillman left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🤖 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.

Comment thread src/features/messages/ThreadPanel.tsx Resolved
matt2e and others added 7 commits November 6, 2026 23:32
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]>
@matt2e
matt2e force-pushed the inbox-doesnt-go-to-dm-thread branch from 0ae41a6 to 2eef1a5 Compare November 6, 2026 12:51

@wesbillman wesbillman left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🤖 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.

@matt2e
matt2e merged commit 067db60 into main Nov 6, 2026
60 checks passed
@matt2e
matt2e deleted the inbox-doesnt-go-to-dm-thread branch November 6, 2026 13:41
tlongwell-block added a commit that referenced this pull request Nov 6, 2026
* 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
wpfleger96 added a commit that referenced this pull request Nov 6, 2026
…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
kalvinnchau pushed a commit that referenced this pull request Nov 6, 2026
## 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]>
matt2e added a commit that referenced this pull request Nov 7, 2026
## 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

![Step 3, just after Back. Left: main, no details. Right: this branch,
GitHub's details are
back.](https://github.com/user-attachments/assets/0aad8b57-cd62-41db-943a-3b92416cbf72)

## 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]>
matt2e added a commit that referenced this pull request Nov 7, 2026
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

![Second Escape, side by side: on main the Forward dialog stays open; on
this branch it has closed and the focus ring is on
⋯](https://github.com/user-attachments/assets/9d1f93c7-d3b6-430a-a80d-675cb3082335)

| 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]>
baxen pushed a commit that referenced this pull request Nov 9, 2026
…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]>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants