Skip to content

feat(activity): show reactions to your messages in Activity - #2638

Merged
tulsi-builder merged 1 commit into
mainfrom
basil/activity-reactions
Nov 5, 2026
Merged

tulsi-builder merged 1 commit into
mainfrom
basil/activity-reactions

Conversation

@tulsi-builder

@tulsi-builder tulsi-builder commented Nov 5, 2026 •

Copy link
Copy Markdown
Contributor

Why

In Slack, a reaction like ✅ or ❤️ on your message is a quiet but useful signal: someone saw it, agreed, or finished the thing. Buzz drops these today, so people have to go back to each message to check. This PR adds reactions to your messages to Activity (renamed in #2543) and briefly shows the emoji on the Activity bell when a live reaction comes in.

Stacked on #2543 (basil/inbox-filter-clear). Retarget to main once that merges.

What

  • Reactions tab. A fifth tab after DMs. Selecting it turns Unread off, since reactions are never unread. They add no badge count and no bold rows; they just show up in the list.
  • Rows. Each row has three lines:
    1. "Alice reacted ❤️" on the left and the time on the right. The time lines up with message rows.
    2. A one-line preview of your message.
    3. "in #channel", "in " or "in DM with Bob".
  • Grouping, per message:
    • An emoji used by two or more people gets one shared row: "Alice and Bob reacted ❤️", "Alice, Bob and Carol…", "Alice, Bob and 3 others…".
    • Each person's other emoji fold into a single row for that person: "Alice reacted 👍 🎉".
    • Rows sort by their newest reaction. The From filter applies before grouping. Rows regroup live as reactions are added or removed.
  • Opening a row shows the message in its own conversation or thread, through the same path as a normal message row. There's no separate reaction view.
  • Sidebar hover card lists reactions with the same rows.
  • Bell. When a live reaction arrives while Buzz is visible, the bell becomes that emoji for 3 seconds, then fades back.
    • Reactions that arrive during those 3 seconds are ignored, so a burst never makes the bell flicker.
    • History loading never triggers it, and neither do multi-character reactions.
    • With reduced motion, it uses only a crossfade.
    • It's hidden from screen readers.

How reactions are found (please review, wesb)

Kind-7 reactions only carry the reacted message's id (e), so "reactions to my messages" can't be queried directly. This PR adds a NIP-25 author tag (p) to new reactions, then reads #p: [me]:

  • src/features/relay/messages.ts: react() adds ["p", <message author>].
  • src/buzz-mcp/tools.ts: the MCP react tool does the same, so agents' reactions show up too.
  • src/features/relay/session.ts (FOUNDATION): 10 lines of wiring.
    • It passes a finite reader, relayAuthor and signingAuthority into createInboxFeed.
    • It forwards live and replay events to inboxFeed.reactions.receive behind the same closed / epoch / generation guards as channelActivity.
    • Read state and unread.ts are untouched.
  • src/features/relay/inbox-reactions.ts (new) owns the projection:
    • It checks that each reacted message is yours and visible to you.
    • Deletions settle fail-closed: a reaction whose target or reaction was deleted, or isn't admissible, never shows.
    • It tracks live vs replay provenance for the bell.

Forward-only: reactions sent before this change, or from older clients, have no p tag and won't appear. We aren't backfilling them; that was a product decision.

Screenshots

Test fixture data; names are made up.

Reactions tab: a shared ❤️ row, one person's other emoji folded together, and a single reaction.

All tab: reactions mixed in with messages; reactions never show an unread dot.

Sidebar hover card: the same rows.

Bell: briefly shows a live reaction's emoji.

Dark mode: Reactions tab and hover card.

Not in this PR

  • Backfilling untagged reactions.
  • Unread state, badge counts or notifications for reactions.
  • A ~10px truncation gap in the hover card and slightly loose emoji spacing. Both are deferred polish.

Testing

Rebased onto #2543's current head 655417f50 (which picked up main's Activity scroll restoration) and squashed to one commit, 2de660049. On that head: pnpm check passes, and vitest run passes (972 files) apart from codex-sh.test.ts, which failed once under load and passes alone. The inbox, inbox-mark-read, inbox-tabs and inbox-preview journeys pass in Chromium and WebKit (50 tests). The runs below were on the previous head:

  • Static: pnpm check passes (biome, tsc, design typecheck, design checks), and so does git diff --check.
  • Unit: vitest run passes: 960 files, 17,885 tests. node --test tests/integration/*.test.mjs passes.
    • New: inbox-reactions.test.ts (ownership and channel checks, deletion settlement, failed-settlement retry, unarchive, archived-room isolation, replay vs live), reaction-rows.test.ts (regrouping after removal and filtering, custom emoji identity), ActivityNavigation.test.tsx (3s hold, bursts, replay, hidden window, custom-image preload, single-grapheme swap) and InboxPage.test.tsx cases.
  • Native: cargo test --lib relay:: passes, 216 tests. It includes a new check that packaged signing accepts the p tag.
  • Browser, Chromium and WebKit: inbox, inbox-mark-read, inbox-tabs and inbox-preview pass in both engines. New journeys in inbox-tabs.spec.mjs cover tab geometry, the settled indicator and row layout; inbox-preview.spec.mjs covers the hover card.
    • The feat(inbox): rename to Activity and simplify the filter bar #2543 toolbar journeys now expect Reactions as the fifth tab. At a 540px list the row still fits, but with only about 9px to spare, so that check now asserts the row fits rather than leaving more than 40px.
    • Hosted CI (all browser journeys, both engines) passed on the previous head. A local full pnpm test:browser run reached 1,804 of 2,176 before its time limit. Its failures were either the two pre-fix toolbar journeys, since fixed, the same failure on feat(inbox): rename to Activity and simplify the filter bar #2543's head without this change (layout.spec.mjs:1405, typing.spec.mjs:290, WebKit page-placement.spec.mjs:259), or a load-only failure that passes when rerun alone (route-teardown.spec.mjs:33, WebKit timeline-setup.spec.mjs:251).
  • Manual: in the dev app, with agents reacting live, including several people with the same emoji. Checked shared and folded rows, the hover card, opening the message in its context, and the bell during bursts.

Known gap: the unchanged native test window_state::tests::onboarding_snapshot_is_kept_until_next_launch_restore crashed in an earlier full native run. This PR doesn't touch it.

🤖 Generated with Claude Code

@tulsi-builder
tulsi-builder marked this pull request as ready for review November 5, 2026 18:01
@tulsi-builder
tulsi-builder requested a review from a team as a code owner November 5, 2026 18:01

@joahg joahg left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

🤖 Read inbox-reactions.ts and the p-tag wiring in messages.ts, tools.ts, and session.ts. Ownership, channel-membership, and deletion fail-closed checks look sound, and the read budgets are bounded. Worth confirming that adding a p tag to kind 7 doesn't cause the relay or agent wake logic to treat reactions as mentions that wake agents.

@tulsi-builder

Copy link
Copy Markdown
Contributor Author

🤖 Thanks @joahg, I checked the agent wake question at d8c80fb02. A p-tagged kind 7 doesn't start a mention run:

  • src/features/agents/runner.ts heard() starts a mention run only through addressedTo(), and only for CHAT_KINDS.
  • src/features/agents/attention.ts addressedTo() returns true only for kind 4 or CHAT_KINDS (9, 40003, 40007, 46010). Kind 7 always returns false, whatever its p tags.
  • The one-to-one DM shortcut above it is limited to event.kind === 9.
  • An existing test already pins this: attention.test.ts, "treats mentions and DMs to the agent, not replies or reactions, as addressed", asserts addressedTo({ kind: 7, tags: [["p", agent]] }) is false.

So reactions still reach an agent only through an Attention watch that asks for kind 7. That's unchanged, because watches already heard untagged reactions in their channels. The one thing the tag changes there: a classifier watch's mentions.agent fact (classifier.ts mentionFacts) is now true for a reaction to the agent's message.

I couldn't verify the relay's own push or wake handling from this repo. If it fans out on #p without a kind check, it would see these too.

@tulsi-builder
tulsi-builder force-pushed the basil/inbox-filter-clear branch from 6ae6125 to 655417f Compare November 5, 2026 18:16
@tulsi-builder
tulsi-builder force-pushed the basil/activity-reactions branch from d8c80fb to 2de6600 Compare November 5, 2026 19:02
Base automatically changed from basil/inbox-filter-clear to main November 5, 2026 19:50
Add a Reactions tab to Activity, list reactions in the sidebar hover card,
and briefly show a live reaction's emoji on the Activity bell.

New reactions carry a NIP-25 `p` tag for the reacted message's author (app
and MCP react), so Activity reads `#p: [viewer]` kind 7. Shared emoji get a
shared row per message; each person's other emoji fold into one row.
Reactions have no unread state or badge, and opening one shows the message
in its own conversation or thread.

Co-authored-by: Eugene <11b65c7022d0662a7e3c969ca511980925a21d138ec0d25214a4997e7b598240@buzz.block.builderlab.xyz>
Co-authored-by: BasilBot <399581b6165ab01343e3a1324774b1024d76942aea563468f93c975e5ee5193c@buzz.block.builderlab.xyz>
Co-Authored-By: Claude Opus 5.5 <[email protected]>
Signed-off-by: tulsi <[email protected]>
@tulsi-builder
tulsi-builder force-pushed the basil/activity-reactions branch from 2de6600 to 47a3b96 Compare November 5, 2026 19:54
@tulsi-builder
tulsi-builder merged commit fb90c66 into main Nov 5, 2026
48 checks passed
@tulsi-builder
tulsi-builder deleted the basil/activity-reactions branch November 5, 2026 20:17
wpfleger96 added a commit that referenced this pull request Nov 5, 2026
…leanup

* origin/main:
  AIDA-2425: Preserve links beside pasted images (#2520)
  feat(activity): show reactions to your messages in Activity (#2638)

# Conflicts:
#	docs/inbox.md
baxen added a commit that referenced this pull request Nov 5, 2026
Bring in reactions in Activity (#2638) and the plugin, terminal and
profile changes before it. Activity and Members browser tests now find
the reaction emoji on the dock tile, name the Manage members button by
its conversation, and leave room for the dock beside the Activity list.

Co-authored-by: mbot <3754f8729004d95654c46dbab3129e4ab9ef05cc2534e2a3fbfc155983bd637b@buzz.block.builderlab.xyz>
Signed-off-by: Bradley Axen <[email protected]>
wpfleger96 added a commit that referenced this pull request Nov 5, 2026
…-service

* origin/main:
  fix(sidebar): highlight collapsed sections with unread channels (#2539)
  perf(threads): isolate thread rows inside the scroller (#2462)
  perf(messages): skip the glass on closed message action bars (#2461)
  perf(threads): measure long-thread scrolling in browsers and the macOS app (#2460)
  docs(threads): set scrolling requirements and targets for long threads (#2459)
  fix(composer): seed required recipients in saved briefs (#2667)
  fix: preserve agent activity ownership and remove Solo composer gap (#2613)
  feat(agents): open agent card links in Create agent (#2662)
  Align Bestie messaging with the shared conversation workspace (#2614)
  feat(messages): add a compact message density (#2547)
  fix(dialog): limit footer click-through to the gradient gutter (#2545)
  fix(channels): put the huddle launcher after the other header tools (#2660)
  feat(drafts-sent): bundle Drafts & sent for everyone (#2644)
  feat(identity): one Builderlab sign-in, the native NIP-FI session (#2576)
  fix(agents): let people dismiss connection setup cards (#2631)
  AIDA-2425: Preserve links beside pasted images (#2520)
  feat(activity): show reactions to your messages in Activity (#2638)

# Conflicts:
#	docs/identity.md
#	src-tauri/src/federated_identity/agent/mod.rs
#	src-tauri/src/federated_identity/mod.rs
#	src-tauri/src/federated_identity/tests.rs
#	src/bundled/codex/runtime.ts
baxen added a commit that referenced this pull request Nov 6, 2026
App navigation moves to a left dock, and the header keeps only history,
Search and window controls.

### What changes

- **Dock.** Bestie, Activity, Messages, Reminders, Agents, Workflows and
any plugin page placed in the topbar or toolbar are icon tabs in a left
column, Bestie first. Labels show at once, as a documented dock label
hover bucket, and on keyboard focus; inactive Activity keeps its hover
preview and shows the latest reaction emoji. With a plugin shell
backdrop, the dock keeps the same solid floor as header controls. The
community picker and profile sit at the bottom of the dock, and their
menus open to the right.
- **Header.** Back/forward, a centered Search capsule with its shortcut
(⌘K by default, follows rebinding), and window controls. Search fills
the space at smaller widths and drops to its own row when narrow. The
old header page tabs and "More pages" overflow are removed.
- **Sidebar only on Messages.** Pages without their own sidebar
(Activity, Agents, Bestie, Usage, Settings…) use their main view.
Sidebar-placed pages (Usage, Drafts & sent) list in the Messages
sidebar. New and ⌘N follow the creation sidebar.
- **Conversation headers.** Member avatars and the conversation name
form one button that opens the existing access dialog, named after what
that dialog offers: "Manage members of …", or "Share access to …", "Chat
access for …" or "Channel access for …" for Solo chats, visitors and
guests. Solo chats show their agents. A conversation with one tab no
longer shows a tab strip.
- **Design system.** `Tabs` gets a `dock` variant and `orientation`,
`Tooltip` gets `hint="dock"`, and `PanelHeader`'s divider changes for
every panel header.
- **Also:** macOS traffic lights move to 16,24; message history fades at
its top and bottom edges (profiled: no frame cost in WebKit or Chromium,
~18% more scroll CPU in headless Chromium); Activity row avatars change
size.

### Since the first draft

- Rebased once onto main, then merged main up to `f6b0c2c62` (Activity
rename #2543, reactions in Activity #2638, Drafts & sent #2644,
Builderlab sign-in #2576).
- Restored the Search shortcut hint. `PageNavigation` takes
`placement="dock"` and labels its tablist "Apps".
`ChannelHeaderIdentity` owns the conversation avatars, label and hint.
Header measurement uses refs. Removed dead attributes, keys and a
duplicate check.
- Browser tests from main updated for the dock: the Activity reaction
emoji is on the dock tile, Manage members is named by its conversation,
and Activity toolbar widths leave room for the dock.
- Merged main again, up to `fd6296a5c`. Then review fixes: dock labels
show on keyboard focus, and the dock label hover bucket is documented.
The header access names match their dialog. The sidebar grip's backdrop
halo is gone, since the grip now sits on the opaque joined seam. The
Search shortcut chip no longer makes the topbar 48px tall. Test fixes
for the shell backdrop, wrapped link drag and Drafts & sent placement
checks.

### Validation (on `92fb0abe6`)

- `pnpm check` and pre-push hooks pass. Full vitest: 18,294 pass, 1
fails: `ChannelMembersDialog.test.tsx` "drains a gated action … after
cancellation", which fails the same way on a clean main checkout on this
Mac and passes in isolation.
- 248 browser tests across the layout, Activity, page placement,
backdrop, channel settings, sidebar, Solo, lifecycle, permissions and
profile specs, plus the wrapped-link and zoom tests, pass in Chromium
and WebKit. Integration tests (261) passed before the last two merges
from main. The full browser suite runs in CI.
- One local failure, `layout.spec.mjs` "Alt+ArrowUp from a message link
preserves native scroll ownership", fails the same way on a clean main
checkout on this Mac.
- Native rendering, traffic lights, window dragging and real sign-in
were checked by hand in the desktop app.

---------

Signed-off-by: Arjun Mahanti <[email protected]>
Signed-off-by: Bradley Axen <[email protected]>
Co-authored-by: Codex <[email protected]>
Co-authored-by: bmo <a7b504d9a2bfe905589e9ac237d7d8d023b8d10547060ec9618e6487b55d756c@buzz.block.builderlab.xyz>
Co-authored-by: Codex <[email protected]>
Co-authored-by: Bradley Axen <[email protected]>
Co-authored-by: mbot <3754f8729004d95654c46dbab3129e4ab9ef05cc2534e2a3fbfc155983bd637b@buzz.block.builderlab.xyz>
matt2e added a commit that referenced this pull request Nov 6, 2026
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]>
matt2e added a commit that referenced this pull request Nov 6, 2026
… 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
#2638 intends. DM read behaviour is unchanged.

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


https://github.com/user-attachments/assets/3cea7338-611b-4427-bac6-25fc4a6ef8ae

![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.](https://github.com/user-attachments/assets/73b4b90d-ca16-475e-a7a6-f57629a2cf63)

| 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](#2587 (comment)))
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](https://claude.com/claude-code)

---------

Signed-off-by: Matt Toohey <[email protected]>
Co-authored-by: Claude Opus 5.5 <[email protected]>
kalvinnchau added a commit that referenced this pull request Nov 6, 2026
)

## Problem

Three costs add up while a busy channel is open and new messages arrive.

- **Reading observation restarts on every message.** `useReading` lists
`latestMessageId` as an effect dependency, so each new latest message
tears down the effect and runs it again. That disposes the reading
lease, the observers and the listeners, then measures rows synchronously
in the passive-effect phase (`getClientRects` /
`getBoundingClientRect`). The result is a forced layout per message, and
a dwell that new messages arriving faster than 300 ms never let
complete.
- **Activity reactions feed pruning.** #2638 added this feed. On every
channel-list notification, which live messages trigger, it prunes up to
100 items with `allowed(item.channelId)`. Each call rescans the list and
runs `members.includes(viewer)`, even though the callback has just built
the same allowed set.
- **Own-reaction ring.** #2709 draws the ring with an inset
`box-shadow`, which adds paint-bound long-animation-frame blocking while
the pointer rests on a message.

## Change

This PR combines three changes:

- `useReading` reads the latest message id through `useEffectEvent` at
measurement time, and it is no longer an effect dependency. A new latest
message already mutates the list, and the existing mutation observer
remeasures it in the next frame.
- The reactions prune filter uses `next.has(item.channelId)`, the set
built a few lines above from the same list snapshot and predicate.
- The own-reaction ring is drawn with `outline: 1px solid` and
`outline-offset: -1px` instead of the inset shadow. It uses the same
color variables, and in forced-colors mode only `outline-color:
Highlight` is set.

## No behavior changes

- **Reading.**
- The latest id is read when the bottom row is measured, so catch-up
still targets the newest rendered row. `catchUp` itself still skips when
a newer row exists and revalidates its target.
- If the latest id changes with no DOM mutation, the bottom check finds
no matching shown row, the same outcome as the old restart.
- `rootId`, `channelId`, `session` and `enabled` still restart the
effect.
- A new test checks that a new latest id neither restarts observation
nor takes a lease, and that catch-up reaches the new row after it is
measured. The test fails without this change.
- **Reactions.** `next` holds exactly the ids `allowed` would accept,
built from the same snapshot with no `await` before the filter. `load`
and `eventVisibility` still call `allowed`.
- **Ring.** It has the same width, color and position, inside the pill's
border box, and follows the capsule radius. An outline paints above the
pill's content and an inset shadow below it. The pill's button is
transparent at rest and the hover glyph is transformed, so the ring
looks the same. Outlines follow `border-radius` from Safari 16.4 /
WebKitGTK 2.40.

## Evidence

Main `8c0cb0c1e` and this PR were interleaved for 6 rounds on 4 Linux
and 5 macOS workstations. Each workstation's median is taken first, and
the table shows the mean of those medians.

- **Browser rows.** Production build, Chromium at 4× CPU throttling.
Traffic appends a message every 200 ms to the open channel and to
another one.
- **pointer on message:** the pointer rests on a message, which reveals
its action bar, for 4.5 s.
  - **no pointer:** the same run with the pointer away.
  - **at rest:** the channel stays open for 3 s with no interaction.
- **Unit row.** Vitest micro-benchmark with 400 channels, one of them
with 5,001 members and the viewer at the end of its list, and 100
reaction items in that channel. It delivers 50 channel-list
notifications. It ran on 2 Linux and 2 macOS workstations.

| metric | Linux: main | Linux: this PR | Linux Δ % | macOS: main |
macOS: this PR | macOS Δ % |
|---|---|---|---|---|---|---|
| pointer on message: blocking | 156.2 ms | 35.3 ms | −77.4% | 32.3 ms |
0.0 ms | −100% |
| pointer on message: task | 2,017.7 ms | 1,997.4 ms | −1.0% | 1,458.1
ms | 1,461.7 ms | +0.2% (noise) |
| pointer on message: script | 1,028.8 ms | 1,010.5 ms | −1.8% | 644.5
ms | 640.2 ms | −0.7% (noise) |
| no pointer: blocking | 133.9 ms | 13.7 ms | −89.8% | 25.6 ms | 0.0 ms
| −100% |
| no pointer: task | 1,843.9 ms | 1,864.5 ms | +1.1% (noise) | 1,365.2
ms | 1,353.7 ms | −0.8% |
| at rest: task | 318.0 ms | 324.4 ms | +2.0% (noise, one box +11%) |
235.1 ms | 236.0 ms | +0.4% (noise) |
| reactions list callback ×50 (unit) | 464.4 ms | 6.9 ms | −98.5% |
309.0 ms | 4.0 ms | −98.7% |

Lower is better. Blocking is long-animation-frame blocking time. Every
workstation moved the same way on both blocking rows (per box: Linux
−77.0% to −77.9% and −88.1% to −91.9%; macOS −100% on all five). Neither
variant blocks in the at-rest run.

Checks:
- The `messages` vitest suites pass (96 files, 2,835 tests).
- `inbox-reactions` passes (10 tests).
- `tsc`, `biome` and `check-contrast` pass.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

---------

Co-authored-by: Claude Opus 5.5 <[email protected]>
kalvinnchau added a commit that referenced this pull request Nov 6, 2026
## Problem

#2638 added reaction rows to the Activity inbox. `InboxRow.tsx` imports
`reactionName` statically to build each row's accessible label. That one
import put `features/messages/reaction-name` and its emoji-mart name
table (`@emoji-mart/data/sets/15/native.json`, a 413 KB chunk) into the
first-paint closure. Every launch now downloads, parses and evaluates
the table and builds its `Map`, whether or not a reaction row is ever
shown. `MessageReactions` and `StatusEmoji` already load the same module
with `import()`.

## Change

- A new `useReactionName` hook
(`features/messages/use-reaction-name.ts`) loads `reaction-name` with
`import()` once, caches it at module level and rerenders when the table
arrives. This is the load-once code `useEmojiName` in `StatusEmoji`
already had, moved so it can be shared.
- `ReactionRowButton` and `useEmojiName` both use the hook, so one table
load serves both.

## Behavior

- The visible row doesn't change.
- Until the table loads, a Unicode reaction's `aria-label` uses the
emoji character, for example "reacted with ❤️". It switches to the name,
"reacted with heart", once the table loads. The first reaction row to
mount fetches the chunk.
- Custom-emoji reactions don't use the table and are unaffected.

## Evidence

Cold start of a production build in Chromium at 4× CPU throttling, from
navigation to the first frame with the app shell rendered. Main
`8c0cb0c1e` and this PR ran as 12 interleaved samples each on one Linux
and one macOS workstation. The table shows medians.

| metric | Linux: main | Linux: this PR | Linux Δ % | macOS: main |
macOS: this PR | macOS Δ % |
|---|---|---|---|---|---|---|
| script | 392.9 ms | 374.4 ms | −4.7% | 273.6 ms | 240.0 ms | −12.3% |
| module evaluation | 160.5 ms | 131.0 ms | −18.4% | 102.6 ms | 79.7 ms
| −22.3% |
| time to shell | 1,302.1 ms | 1,214.2 ms | −6.8% | 862.5 ms | 808.5 ms
| −6.3% |
| eager JS (Vite manifest) | 6,296,425 B | 5,884,309 B | −6.5% | same |
same | −6.5% |

Lower is better.
- The eager JS row is the static-import closure from `index.html`. The
`native` chunk (412,954 B) leaves it, and `main` grows by 273 B for the
dynamic import.
- On macOS, every sample of this PR reached the shell faster than every
sample of main.

Checks:
- The `src/bundled/inbox` and `src/features/user-status` vitest suites
pass (239 tests).
- `tsc` and `biome` pass.
- A production build has no remaining static importer of
`reaction-name`.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Signed-off-by: Kalvin Chau <[email protected]>
Co-authored-by: Claude Opus 5.5 <[email protected]>
bostonaholic added a commit to block/buzz that referenced this pull request Nov 9, 2026
## Why

NIP-25 says a reaction should carry a `p` tag naming the author of the
reacted-to event. buzz-app's Activity → Reactions tab
(block/buzz-app#2638, merged) finds reactions to your messages with a
`#p` read. Reactions sent from mobile have no `p` tag, so they never
appear there. #9121 makes the same change for `buzz reactions
add`.

## What

- Every reaction sent from mobile now carries `["p", <message author>]`.
This covers the reaction pill, the quick-reaction row, the emoji picker,
and reactions on system rows.
- Removing a reaction is unchanged.
- Agent status reactions (buzz-acp) and the desktop app are unchanged by
decision. Agent status reactions stay out of Activity.

## Testing

- New unit test: `ChannelActions.addReaction` publishes exactly `["e",
target]` and `["p", author]`. It fails without the change (actual tags:
`[['e', 'target-event']]`).
- The message-actions widget test now checks that a quick reaction
passes the message's author.
- `just mobile-check`: format and `flutter analyze` are clean (no
issues).
- `just mobile-test`: 4,507 passed, plus the push-unconfigured and
secp256k1 suites. All pass.
- No screenshots: the change is one tag on the published event, and
nothing on screen changes.
- Not yet: a run on a device, and the human test.

## How a human can test

1. On mobile, as user B, react 👍 to a message from user A.
2. As user A, open Activity → Reactions in a current buzz-app build.
3. It works if a "B reacted 👍" row appears. Before this change, no row
appears.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Signed-off-by: Matthew Boston <[email protected]>
Co-authored-by: mboston Implementer <932d3b6ebea6cc83e091551ae46d1c7e7f8f7d0fa8e9cef5e6facc8544af413b@buzz.block.builderlab.xyz>
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.

2 participants