Skip to content

fix(messages): keep a playing row mounted in the channel timeline - #2695

Merged
matt2e merged 4 commits into
mainfrom
av-media-restarts-when-row-leaves-timeline-buffer
Nov 6, 2026
Merged

matt2e merged 4 commits into
mainfrom
av-media-restarts-when-row-leaves-timeline-buffer

Conversation

@matt2e

@matt2e matt2e commented Nov 5, 2026 •

Copy link
Copy Markdown
Contributor

Voice notes, audio files and inline videos stopped and restarted from 0:00 when their row left the channel timeline's virtualizer buffer (about 1,600 px), unless focus happened to be inside that row. In the Mac app a pointer click doesn't focus the Play button, so this likely affected every play started with the trackpad.

MessageRow now tracks whether any <audio> or <video> in its attachment list is playing and feeds that into the existing keepAlive → keepMounted pin, the one the report dialog and action menus already use. The row stays mounted while media plays and is released when it pauses, ends, errors, or leaves the row. Media events don't bubble in the DOM, but React passes them up its tree, so the list's handlers also see plugin players such as the voice-notes card. No plugin API change.

Before and after

Left: main. Right: this branch. Play a 2-minute voice note, click the composer so focus leaves the row, scroll up 3,600 px, then scroll back. On main the audio stops and the note is back at 0:00; on the branch it keeps playing and is further along on return. Pausing then releases the row as before.

voice-note-sbs.mp4

Details

  • A player counts as playing only while !paused && !error. emptied (from load(), read recovery and src swaps) and error also trigger a recheck.
  • React drops events from deleted nodes, so a MutationObserver on the attachment list rechecks when a player leaves the list (edited out, claimed by a message renderer, swapped by a plugin boundary). The callback ref's cleanup clears the flag when the list itself unmounts, e.g. an agents-audience row collapsing.
  • Known limits, unchanged from main: a paused player still resets to 0:00 after eviction; the fullscreen video viewer renders outside the row and isn't pinned; a plugin player rendered through a portal isn't counted.

Tests

ChannelTimeline.report.test.tsx gains 9 tests: plain audio, voice note, voice note reloaded via emptied, inline video, two players in one row, a voice note with a decode error, a player edited out, a player claimed by a renderer, and an agents row collapsing mid-play. Each fails with the code it covers removed. MessageRow, AudioAttachment, VoiceNoteCard, MediaAttachment.video and ChannelTimeline suites pass unchanged; tsc and biome are clean. Rebased onto current main (#2590).

🤖 Generated with Claude Code

matt2e and others added 4 commits November 6, 2026 10:02
Voice notes, audio files and inline videos stopped and restarted from
0:00 when their row left the channel timeline's virtualizer buffer,
unless focus happened to be inside that row.

MessageRow now tracks whether any <audio> or <video> in its attachment
list is playing and feeds that into the existing keepAlive pin, so the
row holds its keepMounted pin while media plays and releases it on
pause or ended. Media events don't bubble in the DOM, but React passes
them up its tree, so the list's handlers also see plugin players such
as the voice-notes card. The flag is recomputed from the list on each
event, so pausing one player while another plays keeps the pin.

ChannelTimeline.report.test.tsx covers a plain audio file, a voice
note and an inline video, plus a row with two players. All four fail
without the MessageRow change.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Signed-off-by: mtoohey <[email protected]>
…ently

The playback pin from the previous commit cleared only when React
delivered a pause or ended event from a player still in the attachment
list. Some paths never deliver one, so the row stayed in keptIndices
for the rest of the visit.

A player is now counted as playing only while it is not paused and has
no error. The list also recomputes on emptied, which load(), media read
recovery and src swaps fire instead of pause. It also recomputes on
error, which reaches the list before AudioAttachment or MediaAttachment
swaps in its unavailable state.

React drops the pause a removed player fires. A player is removed
without one when it is edited out, claimed by a message renderer,
swapped by its plugin's ContributionBoundary, or unmounted with the
whole list once no attachment groups remain. While a player plays, a
MutationObserver on the row rechecks the list whenever the row's tree
changes, and it disconnects when playback stops. It watches the row,
not the list, so the list unmounting counts as a change. A recheck after
MessageRow commits would miss boundary swaps, which re-render only the
boundary.

The comment now says that only players inside the list's DOM count, so
a plugin player portalled elsewhere never pins the row.

ChannelTimeline.report.test.tsx adds three tests: a playing a.mp3 that
fails, a playing player edited out, and one claimed by a message
renderer, which empties the list. All three fail on f2889a1, with the
row still pinned after eviction.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Signed-off-by: mtoohey <[email protected]>
…unmounts

The observer from the previous commit watched the row div, so it missed
that div or an ancestor being removed while MessageRowContent stayed
mounted. An agents-audience row renders the message inside
Collapsible.Panel, which Base UI 1.9 unmounts on close by default.
Collapsing a row while its audio played detached the player without a
mutation record inside the row, and React dropped the browser's pause,
so the row stayed pinned for the rest of the visit. The effect also
reran only when mediaPlaying changed, so after the row reopened it kept
watching the detached div.

A stable callback ref on the attachment list now owns the observer. It
watches the list's subtree, rechecks only while a player plays, and its
cleanup disconnects and clears the flag. React 19 runs ref cleanups
whenever the node is deleted, whichever ancestor is removed, so that
covers a claim of the last attachment group as well as the fold. The
callback also keeps the ref the media handlers read in sync.

The review also named MessageActionBar's wrapper swap. That can't happen
today: no production code has provided ConversationPresentation since
89bc099, so it is always true. The ref cleanup would cover it too.

Test changes in ChannelTimeline.report.test.tsx:
- A new test plays audio in an agents row, collapses it and evicts it.
  It fails on the previous commit.
- The error test now uses a voice note with a decode error. Recovery
  retries only network errors, and the card keeps its failed <audio>
  mounted, so only onError and the error check can release the row.
- The playback it.each gains a voice note stopped by emptied.
- That it.each now flushes microtasks after play. The list observer is
  attached for the list's whole life, so records from the play's own
  re-render otherwise arrived after the test set paused, and released
  the row whatever the stop event was.

Each of these fails when its code is removed: onError with the error
check fails the error test, onEmptied fails the emptied case, and
dropping setMediaPlaying(false) from the cleanup fails the agents and
claim tests.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Signed-off-by: mtoohey <[email protected]>
trackMedia read the attachment list through a ref that observeAttachments
set on attach and cleared in its cleanup. A React media event's
currentTarget is already that list div, including events that reach it
from a portalled player. trackMedia now takes the event and rechecks
event.currentTarget, so the attachmentList ref and the two lines that
kept it in sync are gone. playingIn always gets a list now, so it takes
a non-null Element.

No behaviour change. The ref was set before the list could receive
events and cleared only once it was detached, so it always matched
currentTarget when a handler ran. No test changes.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Signed-off-by: mtoohey <[email protected]>
@matt2e
matt2e marked this pull request as ready for review November 5, 2026 23:37
@matt2e
matt2e requested a review from a team as a code owner November 5, 2026 23:37

@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 this PR and the Nexus review-tracker notes and approved with no comments.

Posted by Codex (gpt-6.1-sol) on behalf of kylez

@matt2e
matt2e merged commit 199c94f into main Nov 6, 2026
43 checks passed
@matt2e
matt2e deleted the av-media-restarts-when-row-leaves-timeline-buffer branch November 6, 2026 03:55
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
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