Skip to content

Add mobile Huddles voice MVP - #6056

Merged
klopez4212 merged 1 commit into
mainfrom
kennylopez-mobile-huddle-transport
Aug 22, 2026
Merged

klopez4212 merged 1 commit into
mainfrom
kennylopez-mobile-huddle-transport

Conversation

@klopez4212

Copy link
Copy Markdown
Contributor

Summary

  • add foreground mobile Huddles on Android and iOS with native Opus capture/playback, mute, speaker routing, participants, lifecycle, and minimized drawer UI
  • keep mobile Huddle cards and roster state live, including ended rooms, relay-resolved profiles, and agents
  • broadcast desktop agent TTS through the existing Huddle audio protocol

Scope

Foreground human-to-human voice MVP only. Agent setup/transcripts, background calling, recording, and advanced device controls remain out of scope.

Validation

  • just mobile-check
  • just mobile-test — 1,500 passed
  • just desktop-check and just desktop-test — 4,957 passed
  • desktop typecheck, strict Clippy, and Tauri tests — 2,445 passed, 15 ignored
  • mobile worktree identity contract checks
  • physical Pixel/iPhone behavior reviewed during development

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

Adversarial mobile audio review at e395e3320fe13609ecaba36aac009b15a415cdb6 found two blocking correctness risks shared by iOS and Android:

  1. Unbounded Flutter ingress queue defeats the native jitter bounds (high). Every remote packet is appended to _playbackTail (mobile/lib/shared/huddle/huddle_session.dart:392-404), and each link waits for a platform-channel call to complete before the next starts. There is no queue cap, coalescing, or late-frame drop before that chain. The relay permits 25 peers (crates/buzz-relay/src/audio/room.rs:46-49), so one mobile listener can receive up to 24 × 50 = 1,200 packets/s. If platform-channel service is slower than ingress—even briefly—the Dart future chain retains packets without bound, growing memory and latency while native's advertised 10-packet jitter bounds never get a chance to apply. This contradicts the foreground-session bounded-latency guarantee in mobile/HUDDLES.md:97-103. Smallest safe remedy: place a bounded, drop-oldest ingress queue in Dart (preferably per peer), run a single drain loop, and clear it on peer-left/dispose; add a burst/backpressure test proving the queue stays bounded and newest audio wins.

  2. Mobile fatally supports fewer talkers than the relay admits (high). Both native engines hard-fail the entire call after 15 remote playback objects (HuddleAudioEngine.kt:355-381,493; HuddleAudioEngine.swift:396-426). The relay admits 25 total peers, and Desktop can add up to 20 agents, so a valid room can exceed this limit. When a 16th distinct remote peer sends audio, reportFailure tears down media rather than dropping/evicting that track. This is especially plausible for the advertised Desktop-agent TTS interoperability. Smallest safe remedy: align the supported peer cap end-to-end (admission/UI/protocol), or make native playback resource management tolerate the relay maximum; exceeding a local playback budget must degrade one track explicitly, not terminate an otherwise valid Huddle. Add a 16+ remote-talker test on both native paths or enforce a lower server-visible admission contract.

Related hardening: peer-left is only cleared from Flutter speaking UI (huddle_session.dart:435-439); neither native engine receives a remove-peer signal, so decoder/player state and queued audio survive peer departure and index reuse (HuddleAudioEngine.kt:393-478; HuddleAudioEngine.swift:779-835). Clearing native peer state on left should accompany the bounded ingress fix.

Verdict: BLOCK until ingress is bounded before the platform channel and the participant-cap mismatch cannot tear down valid rooms. I did not duplicate CI suites.

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

Carl, an automated reviewer, commenting via Wes’s GitHub account.

Reviewed exact head e395e3320fe13609ecaba36aac009b15a415cdb6. Requesting changes for these mobile correctness defects:

  1. Mobile does not implement the relay's authoritative roster resync control, and it retains native playout state across peer-index reuse. The relay emits { "type": "roster", ... } after a mesh revision-gap recovery (crates/buzz-relay/src/audio/join.rs:1538-1557), but mobile's control switch only accepts challenge, joined, left, and error; it reports roster as unknown (mobile/lib/shared/huddle/huddle_transport.dart:328-350). Even for an ordinary left, Flutter only clears active-speaker UI (huddle_session.dart:435-439). It never tells native media to remove that peer. Android and iOS therefore retain the decoder, duplicate-filter sequence, queued packets, and player keyed solely by reusable peerIndex until the entire call stops (HuddleAudioEngine.kt:355-417; HuddleAudioEngine.swift:396-423,779-835). Desktop explicitly drops this state on leave, roster removal, or index-to-new-pubkey remapping (desktop/src-tauri/src/huddle/playout.rs:438-486). A replacement participant can inherit stale queued audio and Opus decoder history, and a matching first sequence can be discarded. Handle roster snapshots as authoritative, detect index identity changes, and add a bridge operation that destroys/clears native per-peer state on leave/remap. Cover leave + index reuse and roster resync on both native paths.

  2. Every remote packet is appended to an unbounded serialized Dart future chain before native's bounded queues. _playbackTail retains all incoming frames until each platform call completes (huddle_session.dart:392-404). With the relay-valid maximum of 24 remote senders at 50 packets/s, ingress can reach 1,200 calls/s. Any period where platform-channel service trails ingress grows memory and latency without a cap or late-frame policy, so native's 10-packet queues cannot enforce the documented bounded-ingress guarantee (mobile/HUDDLES.md:97-103). Put a bounded drop-oldest queue before the channel, preferably per peer, use one drain loop, clear it on leave/remap/dispose, and add a burst/backpressure regression test.

  3. A relay-valid room can terminally fail mobile audio at the 16th remote talker. The relay admits 25 total peers (crates/buzz-relay/src/audio/room.rs:46-49), while both native engines allow only 15 remote playback objects and report playback_failed on the next distinct speaker (HuddleAudioEngine.kt:355-381,493; HuddleAudioEngine.swift:396-426). Dart treats a native media failure as terminal (huddle_session.dart:230-240). Align admission and client capacity end-to-end, or make local resource exhaustion degrade/evict a track rather than tear down a valid call. Test the boundary on iOS and Android.

  4. Background leave can lose durable channel cleanup. paused/detached independently triggers relay shutdown (mobile/lib/shared/relay/app_lifecycle_provider.dart:35-47) and an unawaited Huddle leave (mobile_huddle_controller.dart:78-83), with no ordering contract. If relay shutdown wins, the member lookup falls back to 2 (mobile_huddle_controller.dart:168-178), then leaveChannel runs while disconnected and its failure is swallowed with no resume retry (:205-232). Audio stops, but backing-channel membership can remain, producing ghost roster state and preventing last-human end/archive logic. Coordinate lifecycle publication before relay teardown or persist/retry the cleanup, and test both listener orderings.

CI is also currently red: Desktop Smoke E2E (2) failed in run 32006323730, job 95316546605. The Mobile job passed, but it does not exercise these relay/native lifecycle contracts. I did not duplicate CI-equivalent suites locally; git diff --check origin/main...HEAD passed on the reviewed head.

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

Carl, an automated reviewer, commenting via Wes’s GitHub account.

Re-reviewed exact head b9ebeb349212c337e29460e1d16228e12c36153c. The only changes since the prior reviewed head are one-line desktop roster call-site updates in HuddleBar.tsx and HuddleRoomHeader.tsx; they do not address the four requested mobile correctness fixes:

  1. authoritative roster resync plus native per-peer cleanup on leave/index remap;
  2. a bounded, late-frame-dropping Dart ingress queue before serialized platform-channel playback;
  3. alignment between the relay's 25-peer room capacity and native's fatal 15-remote-playback cap; and
  4. durable/ordered background channel cleanup rather than racing relay shutdown and swallowing failure.

The detailed reproduction paths and smallest safe remedies remain in my changes-requested review on commit e395e3320fe13609ecaba36aac009b15a415cdb6. Please resolve those blockers before landing this MVP.

@klopez4212

Copy link
Copy Markdown
Contributor Author

Carl, an automated agent commenting via Kenny Lopez’s GitHub account.

Fixed the four mobile correctness blockers and the two follow-up races at head ddaaef418b86415ef861a0c99c28f015331edec0:

  • Mobile now consumes authoritative revisioned roster snapshots/deltas, rejects stale/duplicate controls, resyncs on gaps, detects peer-index reuse, and clears Flutter/native per-peer state on leave or replacement.
  • Dart transport ingress and per-peer session playback are bounded with drop-oldest behavior and fair draining.
  • Android and iOS now use deterministic active-talker capacity/eviction instead of failing the entire Huddle when a valid relay room exceeds the local playback budget.
  • Background/failure/user teardown is local-first, idempotent, admission-fenced, and ordered before relay pause.
  • Queued transport audio is purged on authoritative removal/replacement and revalidated while draining; a left + same-index rejoin regression proves old frames do not reach the replacement.
  • Creator end() now revalidates its admission epoch after announceHuddleEnded and before later lifecycle effects; a blocked-publish/rejoin regression proves the stale completion cannot archive the new admission.

Validation on the exact pushed head:

  • pre-push rust-tests, mobile-test, and desktop-tauri-checks: passed
  • full just mobile-test: 1,521 passed
  • flutter analyze: no issues
  • targeted transport and post-publish lifecycle regressions: passed
  • git diff --check: clean

The earlier one-off full-suite failure (profile sheet shows the poster before autoplay and restores it on tap) was a test-harness connectivity_plus MissingPluginException: it passed unchanged on the baseline branch. This branch now overrides app lifecycle in the shared channel-detail harness, and both that exact test and the complete mobile suite pass.

There are no inline review threads to resolve. Please re-review this head.

@klopez4212
klopez4212 marked this pull request as ready for review August 17, 2026 18:07
@klopez4212
klopez4212 requested a review from a team as a code owner August 17, 2026 18:07

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: ddaaef418b

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread mobile/lib/features/channels/mobile_huddle_controller.dart
@chatgpt-codex-connector

Copy link
Copy Markdown

💡 Codex Review

func enqueue(_ packet: HuddleRemoteOpusPacket) {
guard lastSequence != packet.sequence else { return }
lastSequence = packet.sequence
if jitterQueue.count == 10 {
jitterQueue.removeFirst()
}
jitterQueue.append(packet)

P1 Badge Reorder mobile audio packets before decoding

When participants are attached to different relay pods, Huddle media crosses the lossy QUIC-datagram mesh (crates/buzz-relay-mesh/src/wire.rs), where reordering is explicitly tolerated. This queue merely rejects a packet equal to the last sequence and otherwise appends arrival order, so packets arriving as 11 then 10 are decoded in that order, producing audible corruption; Android's PeerPlayback.enqueue has the same behavior. Use sequence/timestamp ordering with loss handling rather than treating a three-packet FIFO as a jitter buffer.


_HuddleButton(
channel: resolvedChannel,
events: messagesState.value ?? const [],

P1 Badge Track active Huddles outside the timeline window

In a channel with more than 50 newer top-level rows after a Huddle starts, the initial channel-window query in channel_messages_provider.dart no longer contains the kind-48100 start event. Passing only that window here makes _activeHuddleStart report no active room, so the toolbar offers “Start Huddle” and can create a second overlapping room while the first remains active. Use a dedicated lifecycle query/subscription, as the desktop surface does, instead of deriving room state from the currently loaded timeline page.


(left, right) =>
left.created_at - right.created_at ||
left.kind - right.kind ||
left.id.localeCompare(right.id),

P2 Badge Preserve causal order for same-second reconnect events

When a participant disconnects and rejoins within the same Nostr timestamp second, both lifecycle events have equal created_at; sorting by kind always places kind 48101 (join) before 48102 (leave), even when the relay emitted leave then join. Reconstruction therefore deletes the currently connected participant from the desktop roster until another lifecycle event arrives. Preserve subscription delivery order for equal timestamps or carry an explicit relay sequence instead of using kind/ID as a causal tie-breaker.

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

@klopez4212

Copy link
Copy Markdown
Contributor Author

@codex review

@brow brow 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.

🤖 Review of #6056 at head 1c9180bda8efb61b68fc1f567f77e833852f8d88. Four independent reviewers read this in parallel: the Dart control plane, the native audio bridge, the relay and desktop side, and test power. Each worked blind, with no findings shared between them. Every claim below is measured at that head, in clean worktrees.

You asked about the foundation of the wiring. The architecture is sound and it is worth building on. The layering is right. HuddleTransport owns the control plane and treats the relay roster as authoritative. HuddleSession owns the media bridge. MobileHuddleController owns the Nostr lifecycle. The revision contract in the Dart transport genuinely matches the relay contract in room.rs: monotonic per mutation, snapshot-authoritative, and a gap triggers a resync. We checked that against Room::add_peer, remove_peer_and_check_ended and roster_snapshot, not against the Dart comments. The generation and epoch guards are sufficient. We traced all four counters and found no interleaving where a stale callback mutates live state.

The findings below are concentrated in one seam. That seam is reconnect and native release, not the design.

We first measured this at 2368ed605ab28c992af8e1619ef4137b63029279. You pushed 1c9180bda8efb61b68fc1f567f77e833852f8d88 while we were writing, so we re-measured everything below at the new head. Your new commit fixes the packet-reordering finding, with a real sequence-ordered jitter queue on both platforms, and it adds a Huddle lifecycle query independent of timeline paging plus a community-transition hook that leaves the call. Both blockers below still reproduce at the new head. The two constructs behind the first one are byte-identical between the two heads.

We also checked the four blockers from the earlier automated review, because we did not want to repeat work you have already done. At this head all four are addressed. The transport now handles the authoritative roster control. The session now has a bounded per-peer playback queue that drops the oldest frame. The native active-talker limit now evicts one track instead of failing the call. The session now calls removeRemotePeer on left and on replaced. Nothing below repeats those points. Our first blocker is the residual case that those fixes do not cover.

Blocker 1: a media-socket reconnect can give one person's live audio state to another person

This is the one finding we ask you to fix before merge.

HuddleTransport.connect() emits a fresh transport state for the connecting phase. That constructor defaults its peer map to empty. The reconnect path therefore discards the previous roster. When the relay re-admits the client, the re-admission handler diffs the new roster against the now-empty previous map, so it finds nothing removed and nothing replaced. No left event and no replaced event is emitted.

HuddleSession releases native per-peer state only from peerEvents, on left or replaced. It is the only production caller of HuddleMedia.removeRemotePeer. That release works correctly for a steady-state departure or replacement. Reconnect is the path it does not cover, because reconnect emits no peer event at all. Both native engines key playout state by peer index: the Android engine keeps a peer-index-keyed playback map and an active-talker map, and the iOS engine keeps peerPlaybacks and activeTalkers the same way. Neither platform exposes a clear-all call. _audioIngress.clear() runs only in HuddleTransport.dispose(), never on reconnect.

The relay is free to reassign a peer index on re-admission. AdmissionGuard::release pushes freed indices onto a free list and alloc pops them, so index reuse across a reconnect window is expected relay behavior. When it happens, the decoder, the jitter buffer and the active-talker slot for the previous occupant of that index stay live and are reused for the new occupant.

Measured, two independent reviewers, separate rigs and separate probes:

  • Reviewer 1 control, same socket, an authoritative roster reassigns index 1 from alice to eve: the transport emits replaced for index 1. The teardown works.
  • Reviewer 1 probe, socket drops, then re-admission reassigns index 1 from alice to eve: the transport emits no peer events at all. No teardown.
  • Reviewer 2 probe, same shape at index 4: roster after reconnect shows the new pubkey, and the list of peers removed from native is empty.
  • Reviewer 2 also measured two further consequences. A peer that departs during the outage leaves a stale active speaker, so participantPubkeys and activeSpeakerPubkeys disagree for a few hundred milliseconds, and the UI reads the latter. Playback queues survive the reconnect and are replayed after the recycle, so frames authored by the old occupant reach playRemoteFrame while the roster says the slot belongs to the new occupant.

One relay detail decides where the fix belongs. We traced the admission paths in handler.rs and join.rs. Every fresh admission, including one after a socket drop, ends by sending a joined message whose peers field is the complete snapshot at the current revision. A client-facing roster is conditional repair traffic only: the owner emits it when its roster broadcast receiver lags or in response to an explicit resync, and the same-pod path never emits it at all. So a re-admitted client normally receives joined and nothing else. The flush must run on the re-admission joined path. The existing roster handling cannot cover this case.

The fix shape is already in this repo. The desktop playout loop handles this. On joined it compares the incoming pubkey against its index_to_pubkey entry for that index, and when they differ it drops that index from peers, frame_counts, active_indices and speaker_levels, under a comment that says "peer_index reuse with a new pubkey: flush the old peer's NetEq + Player so the next frame starts clean". Its roster path does the same with an identity-preserving retain. Mobile has no equivalent on its re-admission path. Mirroring the desktop behavior in the session, or having the transport emit replaced and left for a re-admission diff instead of going silent, would close it.

One warning if you fix this. A fix that only clears the Dart playback queues will pass a naive test and still leave the native decoder stale. Please make the fix drive removeRemotePeer as well, and test it against the index-reassignment shape specifically.

Why the current tests cannot see it: the session-level fake transport always republishes the same peers on connect, with the same local peer index. The identity behind an index never changes across a reconnect in any test, so the existing case that keeps media alive through a bounded transport reconnect passes while this hazard stays open.

Blocker 2: iOS native audio failures do not fail closed

HuddleAudioEngine.reportFailure on iOS sets a flag and calls onFailure. It does not clear running, remove the input tap, stop the engine, cancel the playout timer, or release the peer players. Capture and every peer player stay live until the Dart side completes a cross-bridge teardown. If that callback is not handled, the microphone can stay live.

The iOS engine does clear every peer player, but only in stopOnQueue, and reportFailure never reaches it. Android is the useful contrast. Its reportFailure sets running to false, and both realtime loops then release in a finally. We recommend the iOS path fail closed locally, in the same way, rather than depending on a round trip through Dart.

Further release and lifetime findings, native side

These are all in code this PR adds, so they are in scope for this branch. We rate them below Blocker 2 but above nits.

  1. Android partial-construction leaks. HuddleAudioEngine.start creates the AudioRecord before the encoder and outside its cleanup block, so an encoder failure leaks the record. createAudioRecord throws on a non-initialized record without releasing it. PeerPlayback builds a decoder and an AudioTrack before starting either, so a failure in track creation, decoder start, or track play leaves a partially built codec and track outside the playbacks map, where the playout loop cannot release them. These are exactly the resource-exhaustion conditions where release matters most.
  2. Android keeps reading the microphone through an audio-focus interruption. The focus listener only sets a flag. The capture loop keeps calling a blocking read and discards whole frames. It does not stop or pause the record. A long focus loss leaves capture live until an explicit stop.
  3. iOS can leave the audio session active after a failed prepare. prepare activates the session before it overrides the output port. If the override throws, the catch clears audioSessionPrepared but does not deactivate the session. A later stop sees that flag false and returns early without deactivating. No capture starts, but the active session leaks.

Test power

The suite is in good shape for a POC: the full mobile package suite is 1,521 tests and passed at 2368ed605ab2, and the focused Huddle suite is 32 tests and passes at the current head, just mobile-check is clean, and the Android debug and iOS simulator builds pass. Mutation testing on the wire codec found the recognizer tests strong. Byte order, the relay header prefix offset, header length, and the flags field all produce correct failures when mutated.

Four coverage gaps survived mutation. None is exploitable through an honest relay, because the relay caps binary frames and drops undersized v2 frames, so we report them as coverage rather than defects: the oversize length guard accepts one byte over the maximum, the minimum-length guard accepts an empty Opus payload, the client length guard has no exact-maximum payload test, and the invalid-level lower bound has no test for a valid -127.

Non-blocking observations

  • A left message naming the local peer index is accepted. The transport has no guard for that case, so the client stays connected and keeps encoding into a slot the relay says it does not own. We could not find a relay path that emits this, so we are not calling it a live defect. It is untested defense against a relay bug.
  • Bridge error codes are asymmetric. A missing engine in removeRemotePeer returns playback_failed on Android and invalid_state on iOS. A malformed setSpeakerEnabled while stopped differs the same way, because validation order differs. Android alone returns a code for a second in-flight permission request. Dart discards the native code and maps everything to a generic platform failure, so this is invisible today, but it will matter to any later direct caller.
  • Queue bounds match the description, with one caveat. The three-packet startup and ten-packet per-peer jitter bounds are correct on both platforms, and both cap native peers at 15. The whole pipeline can still hold more than ten packets for one peer, because the Dart per-peer queue and the 50-packet native ingress queue sit in front of the jitter queue. Worth a comment so a later reader does not read ten as end to end.
  • An unbounded bookkeeping set. _finishedLifecycleAdmissions only grows for the life of the notifier. It is one small object per huddle joined, but it gates a decision.
  • Pre-existing, not yours, noted so it is not lost. Room::add_peer has no pubkey uniqueness check, and register_remote_peer admits before it records the peer in a pubkey-keyed map. A second registration for one pubkey therefore admits a duplicate roster member and orphans the first. We confirmed this shape already exists at the merge base and that this PR only threads revisions through that code, so it is a follow-up issue rather than something this branch owes.

Summary

The wiring foundation is good. Two blockers to fix before merge: reconcile native peer state on reconnect, and make iOS native failures fail closed. The native release paths listed above deserve a pass in the same sitting. Everything else is a follow-up or a comment.

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 1c9180bda8

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread mobile/lib/features/channels/channel_detail_page/huddle_sheet.dart
Comment thread mobile/lib/features/channels/channel_detail_page/huddle_sheet.dart
@klopez4212

Copy link
Copy Markdown
Contributor Author

Princess Donut, an automated agent commenting via Kenny Lopez’s GitHub account.

Addressed the latest review findings in 8b950daef3 without changing the intended Huddle UI or core behavior:

  • reconnect re-admission now diffs the prior authoritative roster, emits left / replaced, purges stale transport frames, and therefore drives native removeRemotePeer cleanup before new media for a reused index;
  • iOS native audio failures stop capture, timer, engine, and peer players locally before notifying Flutter;
  • Android/iOS partial native allocations are released on construction/configuration failure; Android also stops AudioRecord during focus interruption and safely resumes it;
  • failed iOS audio-session preparation deactivates the session;
  • admitted participant UI uses the authoritative transport roster, preventing ghost avatars from stale backing-channel membership;
  • a different Huddle card is disabled while another session is active.

Validation at exact pushed head 8b950daef326a3222085ab97244779efee778f25:

  • full flutter test: 1,528 passed;
  • just mobile-check: clean;
  • Android debug APK build: passed;
  • iOS simulator debug build: passed;
  • pre-push mobile test hook: passed;
  • git diff --check: clean.

Replied to and resolved both current Codex threads. Please re-review this head.

@klopez4212

Copy link
Copy Markdown
Contributor Author

@codex review

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 8b950daef3

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread mobile/android/app/src/main/kotlin/xyz/block/buzz/mobile/HuddleAudioEngine.kt Outdated
Comment thread desktop/src/features/huddle/hooks/useHuddleParticipantRoster.ts Outdated
Comment thread crates/buzz-relay/src/audio/handler.rs Outdated
@brow

brow commented Aug 17, 2026

Copy link
Copy Markdown
Contributor

🤖 Re-reviewed #6056 at 8b950daef326a3222085ab97244779efee778f25. Both blockers from my earlier review are fixed, and I verified each one against a control. This comment adds one non-blocking follow-up finding. It is not a merge gate, and nothing in my earlier review needs a correction.

Both blockers are fixed

Reconnect peer-index reconciliation. _handleJoined now keeps the pre-connect roster and, on a re-admission, compares it with the authoritative roster, emits left and replaced, and purges audio ingress. I ran my original probe unchanged against the previous head and this head:

head native cleanup after reconnect stale frame played ghost speaker
1c9180bda none yes yes
8b950daef slot released no no

The previous head is the control, so the probe can show both results. Your new test crosses the reconnect boundary, which is the part that matters.

Fail-closed native audio. iOS reportFailure now stops the engine on the native queue before it notifies Flutter. The comment about not depending on a platform-channel round trip is the right reason. Android also releases partial allocations on construction failure, and stops the microphone on focus loss instead of only discarding frames.

Also correct, and easy to miss: the new sequence window handles a continuous sender across its own 16-bit wrap without loss. I measured 69,998 of 70,000 frames heard, on both platforms. The reorder fix does what it says.

Follow-up: a rejoin on the same slot can silence a participant

One route is still open. It does not need new work in this pull request.

A remote participant leaves and rejoins while the local client is reconnecting its media socket. The relay free list is last-in-first-out, so the rejoin can take the same peer_index. The pubkey does not change, so the admission comparison correctly reports no change, and no purge reaches the native engine. But the sender sequence is sender-authored and restarts at 0 for the new session. The per-peer jitter queue still holds the previous session's high-water mark, so it discards the new session's early packets as stale.

Measured at this head. Dart, after a leave and rejoin on slot 4 during the outage:

native slots released after reconnect = []          (none owed, none happen)
frames played for slot 4              = [33000, 0, 1]

Native, using the shipped HuddlePacketJitterQueue taken from this head, with each session tagged so only new-session audio is counted:

control, released slot:      first new frame = 2       heard 2998 of 3000
prior session talked 20 s:   silence 20.0 s
prior session talked 655 s:  silence 655.38 s
prior session talked 800 s:  silence 0.04 s

Same results on iOS and Android.

The cost law. The new participant stays silent for as long as the previous session talked on that slot, up to a ceiling near 655 seconds. Past that the cost falls to zero, because the window wraps back into the accepted range. It is a ramp and then a cliff, not a straight line, so a long call is not automatically the worst case.

One caution about grading a fix. The shipped test jitter queue reorders packets and rejects stale duplicates passes on both platforms while this route is open, because it never crosses a boundary between two senders. Please do not use it as the check for a fix. A test that can tell the difference needs two arms:

  • a recycled slot, where the new session must hear about 2998 of 3000 packets, not 2;
  • a released slot, which must still hear about 2998 of 3000.

Why this is follow-up material and not a gate. Closing it is a design choice, not a small patch. Either the relay stamps each admission so a rejoin is distinguishable from an unbroken session, or the client resets per-slot playout on any roster revision that touches that slot. The desktop playout path keys its flush on a pubkey change as well, so the same route reaches desktop, and the sizing question is wider than this pull request. Since mobile Huddles are new here, there is no working behavior to protect, and landing this and fixing the rejoin route separately looks right to me.

This is not a regression. main contains no mobile Huddle files, so all of this code is new on this branch.

For the record on scope: this is separate from the three findings the bot reviewer posted at this same head. Those stand on their own.

@jedwards27 jedwards27 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.

Changes requested on 8b950daef326a3222085ab97244779efee778f25 (base f956e6fe06a76e50cbd8fba1a162482e752e7f1a). The prior admission/reconnect/teardown fixes look substantially better, but three runtime correctness defects remain:

  1. Android audio-focus interruption can become terminal session failure. HuddleMediaPlugin.kt:38-48 maps focus loss to setInterrupted(true), which stops AudioRecord while running remains true (HuddleAudioEngine.kt:213-220). The capture loop treats a negative return from the blocked/stopped read() as fatal and calls reportFailure because running is still true (:322-331,387-394,520-524). Flutter then tears down the entire Huddle (huddle_session.dart:235-243). A transient call/focus duck should pause and resume, not eject the participant. Please synchronize interruption with the capture loop and add a native loss → read wakeup/error → gain regression proving no failure callback and resumed capture.

  2. More than 15 remote packet streams can churn every native jitter buffer before startup. The relay admits 25 peers (crates/buzz-relay/src/audio/room.rs:47-49), while both mobile engines cap playback at 15. Android calls activeTalkers.activate for every packet and immediately releases the evicted playback (HuddleAudioEngine.kt:477-501); iOS mirrors this (HuddleAudioEngine.swift:500-537). Capture emits ordinary Opus packets every 20 ms whenever unmuted, including silence (HuddleAudioEngine.kt:346-377,423-456; Swift :766-796), yet each newly created playback requires three packets (Kotlin :531-534; Swift :914-920). A deterministic 16-stream round-robin probe produced 81 evictions over six cycles and no stream ever retained three packets. Select on actual speech/inactivity or otherwise preserve jitter state; add a >15-stream regression around selector + playback startup, not only selector cardinality.

  3. Desktop historical lifecycle reconstruction has no causal order for equal-second events. The subscription replays history in relay order created_at DESC, id ASC, but reconstructHuddleParticipantRoster sorts only by created_at and preserves the incoming arbitrary ID order for ties (useHuddleParticipantRoster.ts:61-65). With causal leave(z) then immediate join(a) in one second, history arrives join(a), leave(z) and the reducer incorrectly removes the live participant. Start/join can invert similarly. The existing test supplies causal array order, so it does not exercise actual relay history order. This needs an authoritative sequence/state contract (not another arbitrary tie-breaker) plus a regression using the real historical ordering.

Validation at the exact head:

  • cd mobile && flutter test: PASS — 1,528 tests.
  • cd desktop && pnpm test: PASS — 4,958 tests.
  • cargo test -p buzz-relay: 868 passed / 13 failed / 43 ignored; the failures were not accepted as product regressions because the configured local DB lacked the communities relation, with one telemetry global-state failure and one mesh-demo timeout. Exact-head GitHub relay/unit/desktop/mobile/security checks are green.
  • Source-model probes reproduced the 16-stream jitter starvation and same-second roster inversion.

Even after these fixes, this native audio MVP still needs the physical iOS/Android acceptance receipt documented in mobile/HUDDLES.md:111-114 (permission, routes/headsets, interruption/resume, background microphone teardown, and two-way Desktop audio). Static and CI evidence cannot prove OS microphone release or route recovery.

@klopez4212

Copy link
Copy Markdown
Contributor Author

@codex review

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 88ed4212c7

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread mobile/lib/features/channels/mobile_huddle_controller.dart Outdated
Comment thread desktop/src/features/huddle/hooks/useHuddleParticipantRoster.ts
Comment thread desktop/src/features/huddle/hooks/useHuddleParticipantRoster.ts Outdated
Comment thread crates/buzz-relay/src/audio/join.rs Outdated
@brow

brow commented Aug 17, 2026

Copy link
Copy Markdown
Contributor

🤖 One correction to my previous comment on #6056, re-derived at 88ed4212c76bb93780d5e7f16f94500570480ae7. The cost law I gave for the rejoin follow-up was wrong. Nothing else changes: the two blocker fixes still verify, and the rejoin route is still non-blocking follow-up material.

The cost is periodic, not a ramp and a cliff

I wrote that the silence "falls to zero" once the previous session talked longer than about 655 seconds, so a long call is not the worst case. That is wrong. The cost repeats. It does not decay.

The sequence window compares against the last drained sequence, and that value is kept modulo 65536. 65536 frames at 20 ms is 1310.72 seconds, so the whole effect restarts every 21.8 minutes of transmitted airtime on that slot. Within each cycle the silence ramps from zero up to 655.36 seconds, then is near zero for the rest of the cycle.

Measured at the current head, with the shipped jitter queue and each session tagged so only new-session audio counts. The jitter queue is byte-identical to the head I first measured, and the mobile Dart transport is untouched since then, so the route and its cost carry forward:

previous session airtime new participant silence
20 s 19.8 s
5 min 300.0 s
10.9 min 655.2 s
11.5 min 0.04 s
21.8 min 0.04 s
25 min 189.3 s
30 min 489.3 s

The 25 minute row is the one that shows my error. Under the law I published it should have been free. A released slot hears 2998 of 3000 packets in every case, which is the control.

Same numbers on iOS and Android.

So a long call is not self-healing. Sweeping one full cycle at even intervals (512 samples), the mean silence is 164.5 seconds and the worst case is 655.36 seconds. About 46 percent of rejoins cost more than a minute of silence and about 27 percent cost more than five minutes.

The test arm needs to be stronger

My earlier suggestion of one recycled-slot arm is too weak. A single arm can sit in the near-zero half of the cycle and pass while the defect is fully present. Please use two departed-airtime values that fall in opposite halves:

  • recycled slot, previous session talked 5 minutes: new session must hear about 2998 of 3000, currently hears 0;
  • recycled slot, previous session talked 25 minutes: new session must hear about 2998 of 3000, currently hears 0;
  • released slot: must still hear about 2998 of 3000.

Everything else in my previous comment stands as written.

@brow

brow commented Aug 17, 2026

Copy link
Copy Markdown
Contributor

🤖 Re-reviewed #6056 at 88ed4212c76bb93780d5e7f16f94500570480ae7. My cost-law correction is in the comment just above this one, so it is not repeated here.

Two credits I owed this commit do not survive measurement at this head. I state them accurately below.

Fixed at this head, each verified against a control

Talker churn above 15 senders. HuddleActiveTalkerSelector.activate now orders candidates by levelDbov and admits a newcomer only when it is strictly louder than the quietest selected peer. The playback path skips a packet whose peer did not earn a slot, through the contains guard. With 16 equal-level senders, my run at the earlier head 8b950daef produced about 8,000 evictions and 0 of 8,000 frames heard. At this head the same rig produces 0 evictions and 7,500 of 7,500 frames heard on the 15 live slots, and the same at 17, 20 and 25 senders. A control confirms the fix does not overshoot: a genuinely louder newcomer still displaces the quietest peer. iOS mirrors the change.

Desktop same-second phase ordering. compareLifecycleEvents breaks equal-second ties on session phase before anything else. I ran the previously failing arm at this head: a start and a join in the same second, delivered newest first, now resolves to the correct roster. A second arm with no revision field on either event also resolves correctly, because phase alone decides that pair.

Correction 1: the relay expect() is only half removed

I credited this as fixed. That is wrong, and the newest automated finding is right.

The occurrence in the audio handler disconnect path is genuinely fixed. It is now a match on the optional removed peer with a logged fallback, which is the right shape.

Two more remain in production code, in the mesh stream loop in the audio join module: one in the UnregisterPeer control arm, one in the stream teardown loop that drops every peer the stream registered. Both unwrap the optional removed peer from the roster delta. Neither is inside a test module. Both are new in this branch: the merge base f956e6fe0 contains zero occurrences of that expect message.

The live risk is low, because Room::remove_peer always populates the removed peer when it returns a delta. The rule in AGENTS.md is still unmet, and it is the same shape that was already fixed one file over, so the fix is mechanical.

Correction 2: the roster events are trusted without checking who signed them

This is a new finding of mine at this head. It is not a regression, because the reconstruction is new in this PR.

The reconstruction hook documents its input as relay-signed lifecycle events, and reads the participant from the p tag. It never checks that the event author is the relay. The relay does not require that either: the four Huddle lifecycle kinds map to ChannelsWrite in the ingest scope table, and none of them is in is_relay_only_kind. So any member of the parent channel can publish a well-formed, validly signed lifecycle event naming somebody else.

Measured against the shipped module at this head:

  • A participant-left event signed by an ordinary member, naming another participant, removes that participant from the roster. The control without the forged event keeps them.
  • A start event signed by an ordinary member clears the roster and leaves only the forger in it.

The bound: this is the visible roster in the huddle bar and the room header. Both consumers pass the result to a participants prop. I did not find a media-plane consequence, so audio is unaffected and the attacker must already be a channel member. It is a spoofing surface, not a call-integrity surface.

Fix shape: verify the author is the relay for the authoritative join and leave kinds, and for a session boundary require the legitimate creator. The relay already knows its own key, so the alternative is to make the two authoritative kinds relay-only at ingest and keep the client-signed boundary kinds separate.

Still open: an audio-focus loss can end the call instead of pausing it

This corroborates the first item in the human review, and an automated reviewer reached the same chain independently at this head. I derived it separately, and part of it is my fault.

The chain on Android: the focus listener maps any change other than AUDIOFOCUS_GAIN to setInterrupted(true). That call stops the AudioRecord. The capture loop is still spinning on a blocking read and never consults the interrupted flag before reading, so the read returns a negative code, the loop throws, and reportFailure fires because running is still true. Flutter treats that as a session failure and tears down the whole Huddle. A transient phone call can therefore eject the participant rather than pause them. This commit did not touch the capture or focus paths.

Provenance, and my share of it. The record.stop() on focus loss came from my earlier blocker, which was correct on its own terms: discarding frames after the microphone has already read them is not a real mute. The half I did not specify is that stopping the recorder has to be coordinated with the loop that reads from it. That omission is mine.

Bound on this claim. I derived it from the documented behavior of a stopped AudioRecord and from reading the capture loop at this head. I have not measured it on a physical device, so treat the mechanism as argued rather than observed.

Fix shape. Either the interrupted gate has to cover the read path, or the loop has to treat a failed read while interrupted as a pause instead of a fault, and then resume on focus regain. iOS already has the pause shape: its interrupted flag gates send, play and restart. The regression worth having is a native focus-loss then focus-gain cycle that asserts no failure callback fires and that capture resumes.

Residual on the new ordering, non-blocking

The phase tie-break fixes start against join. It does not close the join against leave case, because the revision is not always present on both sides of a tie. The relay deliberately omits the revision on the ingress-mirror removal path, since a mirror's local revision is not the owner's authoritative ordering. When one side of an equal-second pair has no revision, or when the two revisions are equal, the comparator falls through to kind, and the leave kind always sorts after the join kind. The leave then wins regardless of which one actually happened last.

Measured at this head: a same-second leave and rejoin where the leave carries no revision drops the live participant, and the equal-revision pair does the same. The control with both revisions present and distinct resolves correctly.

This is follow-up material, not a gate. Closing it properly means deciding the authoritative order for a mutation the mirror cannot number.

Where this leaves the PR

The focus-interruption chain is a blocker on my own derivation, and it is also the first item of the live human review. The half-removed expect() is a small mechanical fix. The roster authentication gap is real and worth a decision, and it is narrower than the focus chain. I am not approving at this head.

@klopez4212

Copy link
Copy Markdown
Contributor Author

@codex review

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 5efcaada9c

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread mobile/lib/features/channels/mobile_huddle_controller.dart
@brow

brow commented Aug 17, 2026

Copy link
Copy Markdown
Contributor

🤖 Re-reviewed at 5efcaada9c3d29d76e8ab452d3a54642cd082f87. Every number below comes from instruments extracted from this head's own blobs, with the controls run before the arms. No device and no emulator was involved, so treat the native behavior as argued from documented API behavior plus the extracted logic, not as observed on hardware.

Credit: the audio-focus chain is fixed

My earlier blocker was that a transient focus loss ended the call instead of pausing it. The new capture loop closes it. It snapshots the interruption epoch before it reads, waits while interrupted instead of reading, and treats a negative read whose epoch has moved as an expected stop. setInterrupted(true) bumps the epoch before it stops the recorder, and the resume and stop paths wake the waiter.

I ran the old loop shape and the new loop shape against identical inputs:

CONTROL no focus loss          old shape: 100 frames, 0 failures
                               new shape: 100 frames, 0 failures
ARM transient focus loss       old shape: 1 terminal failure   <- the chain I reported
                               new shape: 0 terminal failures  <- fixed
ARM regain before inspect      old shape: 0 failures   new shape: 0 failures

The old arm failing and the new arm passing on the same inputs is what makes this a fix and not a coincidence.

The device receipt in mobile/HUDDLES.md is still open, and this fix is exactly the class it would validate: focus loss, then focus regain, with capture resuming and no session failure.

Above 15 simultaneous senders a quiet speaker can never be heard

This corroborates the missing-expiry finding in the automated review at this head (3798836339). The mechanism there is right. One precondition needs correcting, and the correction is not in the author's favour.

The finding says calls with more than 16 participants. The real precondition is more than 15 simultaneous senders, which is a different and easier condition to reach: the relay admits 25 peers per room while mobile capacity is 15, and a peer only sends while unmuted, so 16 people who take turns talking can trip this in a room of 20.

Measured against this head's selector on both platforms, controls first:

CONTROL free capacity admits a quiet peer      = true
CONTROL louder newcomer evicts the quietest    = true
CONTROL equal-level newcomer is rejected       = true
SENDERS   10: soft talker heard = true    15: true    16/20/25: false
LOUDNESS  against 15 steady holders at -10 dBov:
          -9 dBov heard; -10, -11, -12, -15, -20 never heard
FROZEN    16 senders, 30 minutes of continuous talking: still never heard

The last row is the important one. A peer who spoke loudly and then muted keeps its slot forever, because a muted peer sends nothing and so its recorded level never decays. Talking longer does not heal it.

Provenance, and my share of it: the strictly-louder admission rule came from my own churn blocker and from the automated review, it does fix the churn, and neither of us specified the inactivity expiry that would have stopped it from becoming starvation. The missing expiry is on the askers too.

Fix shape: give a slot an inactivity deadline, or clamp room capacity to the mobile selector capacity so the condition cannot arise. Either one closes it. A regression test worth having: 16 senders where the loudest peer stops sending, and the quiet peer becomes audible within a bounded time.

Android only: one rejected packet skips the playback drain for everybody

This is independent of the selector and it degrades the call for every listener, not only for the starved talker.

In the Android playback loop, a packet whose peer did not earn a slot takes the continue at the slot-allocation step. That continue skips the per-iteration drain block at the bottom of the loop, so every peer's playback loses that drain tick, not only the rejected one. Measured at this head, with a counterfactual that removes only the continue:

CONTROL 10 senders: 0.0% of drain ticks lost    15 senders: 0.0%
ARM     16 senders: 6.3%    18: 16.7%    20: 25.0%    25: 40.0%
counterfactual without the continue: full drain count in every arm

iOS does not share this. Its drain runs on a separate timer tick, so a rejected packet cannot skip it. Fix shape: drop the packet without leaving the iteration, so the drain block still runs.

Correction to my own cost-law comment

Two errors in my earlier comment about recycled-slot silence were mine. The jitter queue is byte-identical between the previous head and this one, so both corrections still apply here.

1. My mutation rule was wrong, and following it produces a green test over the live defect. I said to pick two departed-airtime values in opposite halves of the 21.8 minute cycle. The arms I listed are fine. The rule beside them is not. The correct rule is:

useful iff (departedAirtimeMinutes mod 21.845) is in (0, 10.92)

Measured, 3000-frame arm, arriving counter at 0:

 1.0 min  -> 60.0 s silence, 0/3000 heard      USEFUL
 5.0 min  -> 300.0 s,        0/3000            USEFUL
10.0 min  -> 600.0 s,        0/3000            USEFUL
25.0 min  -> 189.3 s,        0/3000            USEFUL  (residue 3.2 min)
22.0 min  -> 9.3 s,       2536/3000            weak, only 464 frames lost
11.0 min  -> 0.04 s,      2998/3000            FALSE GREEN
15.0 min  -> 0.04 s,      2998/3000            FALSE GREEN
21.8 min  -> 0.04 s,      2998/3000            FALSE GREEN
33.0 min  -> 0.04 s,      2998/3000            FALSE GREEN

"One short and one long" is exactly the wrong heuristic: 15 and 33 minutes both read as long, and both pass on broken code.

2. The cost is a function of two counters, not of the departed peer's airtime alone.

cost_frames = (N - A) mod 65536,  free iff the residue is 0 or at least 32772

N is the departed peer's transmitted frame count, A is the arriving peer's own counter. Verified over 17 pairs with both controls passing. A scorer must credit the three-packet warmup: in the free case the arriving peer is first heard at frame 2, not frame 0. My first run of this law printed mismatches that were only the warmup, so a test that does not allow for it will report a false failure.

A has two reachable magnitudes. A fresh joiner starts near 0, and then the cost is the departed peer's airtime, which is the severe case. A participant who reconnects carries its own counter, and then the cost is only the skew between the two peers.

3. The distribution I gave holds only under a uniform assumption I did not state. Enumerating the period instead of sampling it:

uniform over the full 21.8 min period   free 50.0%   over 1 min 45.4%   over 5 min 27.1%   mean 163.9 s   worst 655.42 s
departed airtime up to  1 min           free  0.0%   mean  30.0 s
departed airtime up to  5 min           free  0.0%   mean 150.0 s
departed airtime up to 11 min           free  0.7%   mean 325.4 s

For any speaker who held the mic under about 10.9 minutes the free fraction is exactly zero, and the silence equals their airtime. So "half the time it is free" is not a mitigation that survives a realistic call. My posted worst case and mean were sampling error against these enumerated values and are close enough to leave standing; the framing around them was not.

This closes the cost-law thread from my side. Further refinement of these constants stays out of your way unless it changes the fix shape or the test arms.

@brow

brow commented Aug 17, 2026

Copy link
Copy Markdown
Contributor

🤖 Re-reviewed at 5efcaada9c3d29d76e8ab452d3a54642cd082f87. One new finding, and one withdrawal of my own earlier text. Everything below is measured in this head's own tree with the controls run first.

The re-admission fix only works in one direction, and the other direction misattributes live audio

Credit first: _emitAdmissionRosterChanges is the right shape, and it does what I asked for. The problem is the staleAdmission guard in the same _handleJoined. When a re-admission arrives with a roster revision lower than the one the client is still holding from the previous connection, the guard suppresses the rebuild. Suppressed means the roster diff compares two identical maps and emits nothing: no removal for the departed peer, no playback clear, no speaker clear.

Two arms, same probe, only the revision order changed:

CONTROL ascending   first=1 second=4   events=[replaced:old->new, left:departed]   peers[4]=new
ARM     descending  first=4 second=1   events=[]                                   peers[4]=old

It does not heal. The client keeps the high retained revision, so the fresh room's own deltas are rejected as stale until its counter climbs back past that value:

rev=2 dropped   rev=3 dropped   rev=4 dropped   rev=5 applied   rev=6 applied   rev=7 applied
final roster: peers=[4, 5, 15, 16, 17]   peer 4 = old occupant   peer 5 = departed participant

No resync is requested, no error is surfaced, and the phase stays connected. The roster is what gates media attribution and speaker identity, so the user-visible result is that audio from the new occupant of a recycled peer index plays under the departed participant's name, and someone who left stays in the roster for the rest of the call. That is why I am raising it as a blocker rather than as a roster nit.

This is a regression, and it comes from the fix. I ran the same probe bytes against the pre-fix version of the file. The descending arm is clean there: no ghosts, roster correct. The old connect() emitted a fresh transport state, so no revision survived a reconnect for the guard to compare against. Carrying state forward is what armed the guard. The intended carry was the previous peer map; the revision came along incidentally, and that is where it landed.

The new test cannot see this. The re-admission test pins revision 1 and then revision 4, so it only exercises the ascending order. It is a genuine test of the arithmetic regression and it does kill that one. The ordered pair is a two-valued axis and only one value is covered.

Provenance: I asked for the re-admission rebuild, it is correct, and the guard interaction it created is on me as much as on you.

Fix candidate, measured rather than suggested. Scope the guard to the current connection: one flag, cleared in connect(), set when an initial roster arrives and when an admission is accepted, and added as a conjunct to staleAdmission. Five added lines across those sites. A lower revision from a new connection then becomes authoritative, while a lower revision from a stale snapshot-versus-delta race inside one connection is still ignored, which is what the guard exists for.

descending arm  -> events=[replaced:old->new, left:departed], peers[4]=new, no ghosts
heal sweep      -> every revision from 2 applied, final roster correct
ascending ctrl  -> unchanged
full mobile suite -> 1529 passed, including both tests the guard was added for

Bound on reachability, stated plainly. The client side is measured. The relay side is a source read only: I did not run the relay. The revision lives in the per-room admission guard, starts at zero, and a room is constructed fresh once an empty one is cleaned up, so a client that retained a high revision and rejoins a rebuilt room is admitted at a low one. Owner migration reaches the same state from the new owner's room. The route I would defend without qualification is room rebuild or owner migration under a live client. I have not executed that end to end, so treat the frequency as unmeasured and the state as reachable.

Withdrawal: my mutation-arm selection rule was wrong

In my earlier comment on recycled-slot silence I told you to pick two departed-airtime values in "opposite halves" of the cycle. That sentence is wrong and I withdraw it. The two arms I actually listed, 5 and 25 minutes, are both valid, and your shipped test does kill the arithmetic regression. The correct selection rule is:

useful iff (departedAirtimeMinutes mod 21.845) is in (0, 10.92)

15 and 33 minutes both read as "long" under my withdrawn phrasing and both pass on broken code. This is the last cost-law text I will add to this PR.

@brow

brow commented Aug 17, 2026

Copy link
Copy Markdown
Contributor

🤖 Lifecycle and teardown pass at 5efcaada9c3d29d76e8ab452d3a54642cd082f87. The tip commit says it closes lifecycle races, so I probed that area. One blocker-shaped finding, and one non-blocking nit at the end. Controls were run first in every pair.

One failing native teardown abandons a community switch

CommunityTransitionCoordinator.run waits on all registered callbacks with a bare Future.wait and no per-callback error handling. MobileHuddleController registers its Huddle teardown there. All four transition entry points (switchCommunity, removeCommunity, authenticateWithCommunity and signOut) await run() before they write to storage. One callback that throws therefore stops the whole transition.

Six arms, controls first:

CONTROL  transition, clean teardown    threw=false  error=null                    phase=idle
ARM      transition, teardown throws   threw=true   error=platformFailure         phase=idle
CONTROL  switch, clean teardown        threw=false  activeIdIsSecond=true         phase=idle
ARM      switch, teardown throws       threw=true   activeIdIsSecond=FALSE        phase=idle
CONTROL  sign out, clean teardown      threw=false  activeIdCleared=true  credentialsRemoved=true
ARM      sign out, teardown throws     threw=true   activeIdCleared=true  credentialsRemoved=true

The two consequences fail in opposite directions:

  • The community switch stops. activeIdIsSecond=false. The microphone did not stop, so the user stays on the old community. The switcher tile awaits the call, so the sheet also stays open. The user selects a different community and nothing happens.
  • Sign out completes only half way. Storage is already cleared, then the error escapes. The one caller is the remove-community dialog action in the settings connection section, and it calls signOut() without await. The error becomes an unhandled asynchronous error, and the credentials are already gone.

The error is an ordinary failure path, not a synthetic one. On iOS the plugin returns the error code audio_session_stop_failed when the audio session fails to go inactive. On Android the plugin returns the same code from a runtime exception. MethodChannelHuddleMedia.stop catches the platform exception and rethrows it as a media error, and dispose calls stop first, so it inherits the error. An audio session that fails to stop while the user leaves a community is a normal failure, not a rare one.

The correct shape is already in this branch, one file away. RelaySessionNotifier._pauseAfterCallbacks uses the same Future.wait over registered callbacks, but it wraps the wait in a try/catch and logs the failure. The two coordinators do not agree, and the one without the guard is the one that gates identity transitions. That difference inside the same branch is the finding. I am not arguing about a design preference.

Fix candidate, measured rather than proposed. Move the error handling inside the map, so each callback is awaited in its own try/catch and a failure is logged and skipped. This matches _pauseAfterCallbacks. Five added lines, plus the foundation import for the log call.

switch arm    -> threw=false, activeIdIsSecond=TRUE   (was false)
sign out arm  -> threw=false, storage result unchanged
all controls  -> unchanged
full mobile suite -> 1529 passed

Test power, which is the more useful half of this. The suite reports 1529 passed both before and after the fix. The totals are identical, so no current test can tell the guarded coordinator from the unguarded one, in either direction. Three test files use the coordinator and none of them has an arm where a callback throws. This is the same shape as the re-admission test I wrote about earlier: the mechanism is covered on its success path only.

Severity, stated plainly. This is a weaker class than the roster misattribution finding. No pubkey boundary is crossed and no audio is attributed to the wrong person. It is a blocker because the trigger is a normal failure, both results are visible to the user during an identity change, and the fix is small and already patterned in this branch.

Non-blocking nit: a pending leave is dropped if the user joins another Huddle

If the member count lookup for a leave is still in flight and the user joins a different Huddle, _finishLeaveLifecycle returns early on its admission epoch check and the rest of the leave never runs:

CONTROL  no later join    endedPublished=1  archived=[the backing channel]
ARM      later join       endedPublished=0  archived=[]

The client publishes no end event and archives nothing for the Huddle it just left. The relay covers the visible part of this. I read the peer removal and auto-end path on the relay: the last peer to leave archives the channel and emits the end event under the admission guard, and it rolls the ended flag back if the archive fails. So the Huddle still ends, but a different actor does it. The remaining point is only that the epoch check asks whether this is still the current admission, while the work it guards belongs to the Huddle being left and would be safe to finish. Worth a look, not a blocker. I did not run the relay, so the backstop above is a source read.

@brow

brow commented Aug 17, 2026

Copy link
Copy Markdown
Contributor

🤖 Two more results at 5efcaada9c3d29d76e8ab452d3a54642cd082f87, both about the lifecycle work in the tip commit. The first is a real defect that nothing reaches yet. The second is about test coverage, not about defects. Controls were run first in both.

end() clears the admission token before it checks who is allowed to call it

end() changes state before it validates. In order: it bumps the generation, sets the active flag false, reads the session, and clears _admissionToken. Only then does it check isCreator and throw StateError('Only the Huddle creator can end it.').

That token is the handle the leave lifecycle needs. So a call from a non-creator throws after the token is already gone, and the leave() that follows sees no token and skips the whole membership and lifecycle step.

Two arms, control first:

CONTROL  leave() only                leftChannelId=<the backing channel>   mediaPhase=stopped
ARM      end() throws, then leave()  leftChannelId=null                    mediaPhase=stopped

The audio side is healthy in both arms, and that is what makes this easy to miss. The microphone is released and the call page closes, so the hangup looks clean. What does not happen is the relay-side leave, so the user stays a member of the backing channel after hanging up, with no retry and nothing surfaced.

Nothing calls end() in production today, and that is why I am not calling this a blocker. I searched all of mobile/lib: the only end() caller anywhere is a single test. The hangup control in both Huddle surfaces routes to leave(). Production reaches start, join and leave, and nothing else on this controller.

I am raising it now rather than later because wiring an End button is the obvious next commit, and the failure mode is silent when it does get wired.

The fix is a one-line reorder: move the token clear to after the authorization check, so a rejected call leaves the token intact.

ARM after the reorder  leftChannelId=<the backing channel>   (control unchanged)
full mobile suite      1529 passed

The lifecycle guards in this commit have very little falsifiable coverage

This is a coverage observation, not a list of defects. The commit is titled to close lifecycle races, so I tested whether the suite can detect those guards being broken. I removed ten guards one at a time, each with an applied-check before running so a mutation that failed to apply could not be scored as a pass, and with the control green at 149 before and after.

M1   _finishLeaveLifecycle dedupe ledger removed         survived
M2   end() dedupe ledger removed                         survived
M3   _finishLeaveLifecycle epoch check (first) removed    survived
M4   _finishLeaveLifecycle pre-archive epoch check        survived
M5   end() pre-announce epoch check removed               survived
M6   _leaveForBackground memoization removed              survived
M7   onDispose of the community-transition hook removed   survived
M8   _leaveForTransition stops awaiting an in-flight start   CAUGHT
M9   start() in-flight dedupe removed                     survived
M10  leave() generation bump removed                      survived

M8 is caught by the community-transition test, and I read the failure to confirm it fails for that reason and not an unrelated error. For M1, M3 and M10 I re-ran the full suite rather than the one file, in case the guard was covered somewhere else: 1529 passed in all three cases.

I also replaced the dedupe ledger's silent early return with a deliberate failure at both sites, and the suite still passed. So no current test drives a duplicate admission through that path at all.

A guard whose removal no test notices is a coverage gap, not proof of a bug, and I am not claiming any of those nine is broken. The useful version of this result is the aggregate: the guards this commit adds are, with one exception, not currently falsifiable. The fix commits you are already writing are the natural place to add arms in the adversarial directions, and the two probes behind the end() finding above are most of a test already.

@jedwards27 jedwards27 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.

Reviewed exact head 5efcaada9c3d29d76e8ab452d3a54642cd082f87 against base f956e6fe06a76e50cbd8fba1a162482e752e7f1a, the PR's foreground voice-MVP scope, VISION.md, TESTING.md, and the mobile Huddles acceptance contract. Requesting changes for one reproduced runtime defect; the current validation also has a deterministic-test gap and lacks inspectable native-device evidence for the MVP's core microphone/route/recovery behavior.

Major — same-second remote join then leave is reconstructed as still joined

desktop/src/features/huddle/hooks/useHuddleParticipantRoster.ts:63-94 orders an unrevisioned PARTICIPANT_LEFT before a revised PARTICIPANT_JOINED for the same participant. That handles the opposite sequence (leave then rejoin), but reverses an ordinary join then leave.

The relay produces exactly this mixed-revision shape: a non-owner ingress join emits the owner's revision (crates/buzz-relay/src/audio/handler.rs:611-677), while ingress disconnect deliberately omits revision (crates/buzz-relay/src/audio/handler.rs:836-878). Nostr timestamps have one-second resolution, so a quick join and disconnect can share created_at; reconstruction then applies leave first and join second. Desktop consequently shows a departed participant until later lifecycle/fallback reconciliation happens.

At the pinned clean head, a source-level repro using start at t=1, remote join at t=2 with roster_revision=1, and remote leave at t=2 without revision produced:

{"expected":["creator"],"actual":["creator","mobile"]}

The repro exited 42. The checked-in suite passes but only covers unrevisioned leave then revised rejoin (useHuddleParticipantRoster.test.mjs:147-172), not the reversed causal sequence. A one-sided null-revision heuristic cannot infer both directions; please make the relay/client ordering contract authoritative for both and add a regression that proves join→leave and leave→rejoin.

Validation/test-quality gaps

  • mobile/test/shared/huddle/huddle_transport_test.dart:357-383 uses a fixed 20 ms sleep and then requires all 50 retained frames. Production drains one frame per zero-duration timer turn (mobile/lib/shared/huddle/huddle_transport.dart:769-773,790-807), so this is scheduler-dependent. I independently reproduced the focused test failing at this exact head with only 30 frames delivered (expected 50); another review execution observed 9 and 1. The first retained sequence began at 50, so this does not establish a product queue defect, but it does make the claimed backpressure regression nondeterministic. Replace wall-clock sleep with deterministic drain synchronization/fake scheduling and mutation-check the cap/drop-oldest behavior.
  • The PR claims physical Pixel/iPhone review but supplies no exact-head receipt, device/runtime identity, or observable outcomes. mobile/HUDDLES.md:111-114 correctly says static tests cannot establish iOS permission, route/headset, interruption recovery, or two-way Desktop audio. Before merge, attach exact-head physical iOS and Android evidence for permission deny/grant, receiver↔speaker and connected headset, interruption→resume without ejection, background/detach microphone release, and two-way Desktop audio. Native interruption paths currently lack a checked-in focus-loss→blocked-read wake/error→gain regression as well.

Exact-head evidence

  • Live PR head and local HEAD: 5efcaada9c3d29d76e8ab452d3a54642cd082f87; clean tree after probes.
  • Full Desktop JS package suite: 4,961 passed, 0 failed.
  • Focused transport test: failed, expected 50 frames, received 30.
  • Focused mobile Huddle UI rows: 14/14 passed in an independent clean-tree execution, including lifecycle/admission, dense roster, reactions, community transition, semantics, and reduced-motion paths.
  • CI is green at this head, but does not exercise the missing causal ordering or provide physical-device proof.

Please fix the roster contract and deterministic regression, then provide the native-device receipts. Any new head needs delta review before this verdict can change.

@brow

brow commented Aug 18, 2026

Copy link
Copy Markdown
Contributor

🤖 Three teardown findings at 5efcaada9, from a lifecycle lap over the seven exit paths on MobileHuddleController. All three are additive to what is already on this PR. Controls and bounds are stated with each one, and where a claim is a source read rather than an execution I say so.

First, the overlap, so you can skip what you already have. Codex's open P2 on this file ("Await failed-session cleanup before changing communities") is about the unawaited _cleanupAfterLocalTeardown future not being tracked by _leaveForTransition. That is a different mechanism from all three below and I am not restating it. Findings 1 and 2 are about the awaited paths failing to cover the current session, which is why fixing the P2 does not fix these.

1. Awaiting _leaveForBackground() from a transition proves nothing about the current session

_leaveForTransition ends in await _leaveForBackground(), and that method is memoized:

Future<void> _leaveForBackground() =>
    _backgroundLeave ??= leave().whenComplete(() => _backgroundLeave = null);

If a background leave is already outstanding, the transition awaits that future. It completes, run() returns, and the current session was never torn down.

arm result
transition with a parked background leave run() returns, mediaDispose=0, transportDispose=0, phase=connected
control, no parked leave, same gate parks first, then mediaDispose=1, phase=idle

The control discriminates in both directions. Traced one step further, to the consequence: switching the relay config after run() returns, as switchCommunity does, leaves the old transport live and a subsequently captured frame still reaches it (transportStillOldCommunity=true, frames observed after the switch). A positive control on the frame path ran first, so the quiet arm is a measured silence and not a dead rig. Net effect: capture and the Huddle socket survive the identity switch and keep publishing audio authenticated to the previous community's key.

This is the same hazard class as the transition fix in 5efcaada9, one layer down: the fix made the coordinator await teardown, and memoization is what makes that await occasionally vacuous.

2. A leave that loses its epoch race abandons the backing channel's lifecycle permanently

_finishLeaveLifecycle awaits the humanCount lookup, then re-checks admissionToken.epoch != _admissionEpoch. A start() or join() in that window bumps the epoch (those are the only two sites that do), the guard fires, and the previous backing channel's end/archive/leave work is dropped with nothing left to finish it.

arm result control
second start, departing user was last human nothing archived, no ended published control: archived, ended published
second start, others remain old backing channel never left control: old backing channel left
join a different huddle same loss joining the same one correctly skips
second start fails old backing channel still not archived, new one is
every later user exit only ever handles the current backing channel

Why it is permanent rather than deferred, and this is the part worth fixing first: the dedupe ledger is written at the top of the method, before the await.

if (!_finishedLifecycleAdmissions.add(admissionToken)) return;

The admission is recorded as finished before the work happens, so the early return leaves a ledger entry asserting completion of work that never ran. No retry can ever pick it up. Moving the add after the epoch re-check, or removing the entry on the guarded return, makes this recoverable.

Reachability is measured, not assumed. Mid-leave, while the lookup is parked, both UI gates are already clear (phase=idle, isInSession=false, controller state false), so a second start is reachable from the UI. The window is the humanCount relay round trip: fetchHistory carries an 8 s default timeout and first awaits the rate-limit gate. The failing-second-start arm needs no timing luck at all.

ref.onDispose reaches the same end state by a different route: local media and transport are released (by HuddleSessionNotifier's own dispose), but nothing is published or archived.

3. The relay backstop covers the archive but not the membership

This is the reason finding 2 is a real leak rather than cosmetic, and it is the one asymmetry I would not have guessed:

  • Backstopped. remove_peer_and_check_ended releases the peer index, bumps the roster revision, and elects exactly one auto-end winner under the guard; the handler archives, emits 48103, and rolls ended back if the archive fails. So a missed end/archive is repaired server-side.
  • Not backstopped. There is no kind 9022 membership removal anywhere in the relay's audio module, and archive_channel in buzz-db only issues UPDATE channels SET archived_at = NOW(); it does not touch channel_members. Both are source reads, not executions, each run with a positive control in the same file so a clean result is not a broken grep.

So the user stays a member of an abandoned, archived backing channel, and nothing anywhere cleans that up.

One precedent worth pointing at: ChannelActions already has an isCommunityValid guard for exactly this "the community moved under me" hazard, but its three call sites are all inside addMembers. leaveChannel, archiveChannel and the huddle-ended publication have no equivalent.

Severity bound, stated plainly

Two publishers can drive the background leave from one real backgrounding, five seconds apart: the relay hook fires from _pauseAfterCallbacks behind a 5 s grace timer, while the app-lifecycle listener fires immediately. That makes the memoized-leave collision routine rather than exotic. But it self-heals on the next pause, so on its own it is one pause cycle of a live mic in the background, not a permanent condition, and I would treat it as non-blocking.

Holding severity down further: there is no UIBackgroundModes key in the iOS Info.plist and no foreground-service declaration in the Android manifest (both checked with positive controls; both files do carry mic permissions, so the greps can see content). The OS should suspend capture shortly after backgrounding. The finding that does not benefit from that bound is finding 1, where the app stays in the foreground across a community switch, and finding 2, which is permanent by construction.

Numbers are scoped to 5efcaada9; the controller is the only in-scope Dart construct that moved across recent heads, so a re-run is owed after the next change to it.

@brow

brow commented Aug 18, 2026

Copy link
Copy Markdown
Contributor

🤖 Correction to my comment above (issuecomment-5323770634), finding 2: I withdraw the closing sentence "Moving the add after the epoch re-check, or removing the entry on the guarded return, makes this recoverable." That fix does not recover the abandoned admission, and the reason is structural rather than a matter of ordering: _HuddleAdmissionToken carries only epoch, the orphaned backingChannelId arrives at _finishLeaveLifecycle as a parameter that the guarded return discards, no controller field or collection retains it, and all three exits read session.ephemeralChannelId, so the moment the guard fires the abandoned channel is unnameable and no ledger change can reconstruct it (scored both ways: add moved to the action site, and the ledger removed entirely, neither changes the outcome). What the fix actually needs is retention of the abandoned admission, its backing channel id plus the parent channel, so a later pass can finish the end/archive/leave work for a channel the current session no longer names. The ledger inversion I described stays a real but separate and smaller item: writing _finishedLifecycleAdmissions.add(...) before the await leaves an entry asserting completion of work that never ran, and reordering it fixes that record without addressing the drop.

Severity, the five arms, and the 9022 backstop asymmetry in that comment are unchanged; only the remedy changed, and it is larger than I stated.

@brow

brow commented Aug 18, 2026

Copy link
Copy Markdown
Contributor

🤖 One requirement on the retention remedy I named in issuecomment-5323859662, because the obvious implementation of it was measured and it is worse than the bug: retaining the abandoned admission and draining it later does recover the orphaned channel, but the drain must skip any retained id equal to the session's current ephemeralChannelId, or a user who backgrounds a Huddle and taps back into the same invite can be ejected from the room they are sitting in (join bumps _admissionEpoch unconditionally before it touches the session, the session's leave() lands on HuddleSessionState.idle, and _openMobileHuddle's re-entry condition is satisfied for the same invite in that state, so same-channel rejoin is an ordinary gesture rather than a corner case). Worth stating explicitly because the over-removing version is green: flutter test test/features/channels/ test/shared/huddle/ passes 778 with it in place, so the suite cannot distinguish a correct drain from one that self-ejects.

@wesbillman
wesbillman dismissed their stale review August 21, 2026 17:24

Dismissed at Wes’s direction to clear the merge block. The findings remain documented; this dismissal does not itself indicate they were fixed.

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: ef3229d621

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread crates/buzz-relay/src/audio/join.rs Outdated

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 3d8ad0b2c8

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread mobile/lib/shared/huddle/huddle_media.dart
brow
brow previously approved these changes Aug 21, 2026

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 3f7cadb17c

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread mobile/lib/shared/community/community_provider.dart Outdated
brow
brow previously approved these changes Aug 21, 2026

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 9a6abf40c0

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

return;
}
if (!_finishedLifecycleAdmissions.add(admissionToken)) return;
if (humansRemaining <= 1 && parentChannelId != null) {

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Keep calls alive for another same-key admission

When one identity has concurrent admissions from desktop and mobile, the kind:39002 snapshot still contains only one human member, so humansRemaining is 1 when either device leaves. This branch then publishes the end event and archives the backing channel even though the relay room still contains the other admission, disconnecting that active device. Base the last-participant decision on active admission IDs/socket count, or let the relay's room-empty path perform the automatic end.

Useful? React with 👍 / 👎.

Signed-off-by: kenny lopez <klopez4212@gmail.com>
@klopez4212
klopez4212 force-pushed the kennylopez-mobile-huddle-transport branch from 9a6abf4 to cff6e82 Compare August 21, 2026 18:37

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: cff6e82414

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment on lines +103 to +107
if !signer_created_backing
|| backing.channel_type != "stream"
|| backing.visibility != "private"
|| backing.ttl_seconds != Some(expected_ttl)
|| backing.archived_at.is_some()

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1 Badge Bind each Huddle backing stream to one parent

When a backing-channel creator publishes otherwise valid kind:48100 starts for the same private stream in two parent channels, both pass this validation because it checks only the backing properties and signer. ensure_membership then independently accepts either parent linkage and auto-adds members of both parents to the single audio room keyed by the backing UUID, causing participants who share no parent channel to hear each other and splitting lifecycle events between channels. Enforce one canonical parent linkage per backing channel, including for concurrent starts.

AGENTS.md reference: AGENTS.md:L180-L182

Useful? React with 👍 / 👎.

@jedwards27 jedwards27 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.

Verdict: COMMENT — code blockers cleared; physical-device acceptance remains a release gate.

Reviewed: aeb741fd31044ec560d953b0986dec2e7e93e2c6..cff6e82414abda715319abd5ef541e6274ee3448 (exact head cff6e82414abda715319abd5ef541e6274ee3448)

Risk: high. This changes a versioned relay/mobile/Desktop audio protocol, peer-occupancy identity and decoder lifecycle, plus iOS/Android microphone, routing, interruption, and app-lifecycle behavior.

The two previously blocking code findings are resolved:

  1. Version negotiation and mixed-generation framing: Desktop and mobile authenticate as protocol v3; the relay validates versions before admission, pins the room version, rejects mismatches without consuming a seat, preserves that pin across churn, and emits upgrade_required. Fan-out retains the released one-byte v1/v2 prefix and uses [peer_index][epoch] only for v3 (desktop/src-tauri/src/huddle/relay_api.rs:110-122, mobile/lib/shared/huddle/huddle_auth.dart:58-85, crates/buzz-relay/src/audio/handler.rs:419-445, crates/buzz-relay/src/audio/room.rs:267-341,462-487). Focused tests cover mismatch/no-admission, pin persistence/reset, v2/v3 bytes, full-vs-mismatch precedence, mesh framing, mobile golden frames, and surfaced upgrade errors. A mutation deleting the mismatch gate causally failed admit_rejects_mismatched_version.

  2. Same-pubkey reconnect cleanup: occupancy is now (peer_index, pubkey, epoch). Relay increments and stamps epochs on index reuse; Desktop and mobile fence stale media before decoder allocation and purge decoder/player, queued ingress, speaker/floor, and native playout state when either pubkey or epoch changes (crates/buzz-relay/src/audio/room.rs:196-217,309-341, desktop/src-tauri/src/huddle/playout.rs:152-177,544-625, mobile/lib/shared/huddle/huddle_transport.dart:509-545,651-675,769-780, mobile/lib/shared/huddle/huddle_session.dart:466-480). Regression tests cover stale frames after reassignment, same-pubkey/new-epoch replacement, re-admission, queued-frame purge, and native playback reset. Removing epoch from mobile occupancy equality causally changed the expected replaced event to joined and failed the regression.

Exact-head validation: focused relay room tests passed 15/15; focused mobile wire/transport/session tests passed 35/35; an independent full Flutter run passed 1,661 tests; an independent full Desktop Tauri workspace run passed. Full local cargo test -p buzz-relay completed 906 passed, 1 failed, 47 ignored; the sole failure, api::mesh_demo::tests::demo_join_forwarded_arm_round_trips_echo (504 vs 200), reproduced alone and its source is unchanged in this PR range, so it is not attributed to this change, but the local package gate is not literally green. All current GitHub checks are completed green, including Mobile, Desktop Core/build, relay/backend integration, Desktop E2E, Rust lint, security, and cross-compiles.

Manual/native evidence: no exact-head physical iOS or Android receipt was supplied or produced. Automated tests do not establish microphone denial → Settings grant → successful join, receiver/speaker/wired/Bluetooth routing, phone-call/audio-session interruption recovery, foreground/background transitions and microphone release, or live two-way Mobile ↔ Desktop relay audio. mobile/HUDDLES.md:108-117 itself identifies these as device acceptance checks and scopes the MVP foreground-only.

There is no remaining code defect from this review. I am not granting an unconditional approval because the evidence does not yet match the native boundary. Please attach exact-head physical iOS and Android acceptance receipts for the documented matrix before release/merge approval. Any new head invalidates this result.

— :bot: Jude’s code review agent

@klopez4212
klopez4212 merged commit 8c0f42e into main Aug 22, 2026
52 of 54 checks passed
@klopez4212
klopez4212 deleted the kennylopez-mobile-huddle-transport branch August 22, 2026 12:28
brow added a commit that referenced this pull request Aug 22, 2026
…nd-join-channels-in-mobile

* origin/main:
  Add mobile Huddles voice MVP (#6056)
  feat(desktop-messages): keep agents addressed across messages (#6315)
  fix(desktop): remove Buzz entity link previews (#6512)

Signed-off-by: Tom Brow <tomb@block.xyz>

# Conflicts:
#	mobile/lib/features/channels/channels_provider.dart
#	mobile/test/features/channels/channels_provider_test.dart
wpfleger96 pushed a commit that referenced this pull request Aug 22, 2026
…ake-fix

* origin/main: (33 commits)
  perf(desktop): make the Projects surface render-cheap (#6460)
  refactor(acp): clarify agent prompt sections (#6501)
  Add mobile Huddles voice MVP (#6056)
  feat(desktop-messages): keep agents addressed across messages (#6315)
  fix(desktop): remove Buzz entity link previews (#6512)
  fix(composer): preserve caret when inserting mentions mid-message (#6531)
  chore(deps): update rust crate async-trait to v0.1.92 (#6094)
  chore(deps): update dependency sonner to v2.0.8 (#6093)
  chore(deps): update rust crate http-body-util to v0.1.4 (#5452)
  chore(deps): update rust crate http to v1.4.2 (#5451)
  chore(deps): update rust crate futures-util to v0.3.33 (#5448)
  chore(deps): update rust crate futures to v0.3.33 (#5445)
  chore(deps): update dependency @tauri-apps/api to v2.11.1 (#5444)
  chore(deps): update ubuntu:24.04 docker digest to 561618e (#5442)
  chore(deps): update swatinem/rust-cache digest to 6323deb (#5441)
  fix(desktop): restore true zoom by scaling the root rem (#6514)
  chore(desktop): drop unused ORIGINAL_CONTENT from empty-edit-delete spec (#6517)
  feat(workflows): clarify workflow setup and activation (#6470)
  perf(desktop): stop the Projects fan refetching on re-entry and running after leave (#6458)
  perf(desktop): keep the member roster off the channel-switch path (#6456)
  ...

Signed-off-by: Duncan <dcfd242e557282d7a1e2cf2e6877522682f1e5c6156dc92ca7d90eaedd3b0f95@buzz.block.builderlab.xyz>
wpfleger96 pushed a commit that referenced this pull request Aug 22, 2026
…ions-sync-fixes

* origin/main: (22 commits)
  Downgrade mobile Huddles to audio protocol v2 (#6558)
  perf(desktop): make the Projects surface render-cheap (#6460)
  refactor(acp): clarify agent prompt sections (#6501)
  Add mobile Huddles voice MVP (#6056)
  feat(desktop-messages): keep agents addressed across messages (#6315)
  fix(desktop): remove Buzz entity link previews (#6512)
  fix(composer): preserve caret when inserting mentions mid-message (#6531)
  chore(deps): update rust crate async-trait to v0.1.92 (#6094)
  chore(deps): update dependency sonner to v2.0.8 (#6093)
  chore(deps): update rust crate http-body-util to v0.1.4 (#5452)
  chore(deps): update rust crate http to v1.4.2 (#5451)
  chore(deps): update rust crate futures-util to v0.3.33 (#5448)
  chore(deps): update rust crate futures to v0.3.33 (#5445)
  chore(deps): update dependency @tauri-apps/api to v2.11.1 (#5444)
  chore(deps): update ubuntu:24.04 docker digest to 561618e (#5442)
  chore(deps): update swatinem/rust-cache digest to 6323deb (#5441)
  fix(desktop): restore true zoom by scaling the root rem (#6514)
  chore(desktop): drop unused ORIGINAL_CONTENT from empty-edit-delete spec (#6517)
  feat(workflows): clarify workflow setup and activation (#6470)
  perf(desktop): stop the Projects fan refetching on re-entry and running after leave (#6458)
  ...

Signed-off-by: Duncan <dcfd242e557282d7a1e2cf2e6877522682f1e5c6156dc92ca7d90eaedd3b0f95@buzz.block.builderlab.xyz>
klopez4212 added a commit that referenced this pull request Aug 22, 2026
## Summary
- arrange Huddle participants in a responsive, equal-weight cluster with
spring enter/exit motion and a `+N` overflow
- spotlight tapped participants over a blurred call surface, with a
roster for hidden participants and no self-avatar action
- add selection haptics across full-screen and drawer controls,
including both end-call buttons
<img width="1080" height="2424" alt="Screenshot_20260819-151448"
src="https://github.com/user-attachments/assets/00b7fdca-2304-4788-9952-e07224798513"
/>
<img width="1080" height="2424" alt="Screenshot_20260819-151422"
src="https://github.com/user-attachments/assets/a0cfc861-0519-44ff-bb56-4c983ed6344c"
/>

## Validation
- `just mobile-check`
- focused participant, drawer-control, and full-screen end-call widget
tests
- Huddle-focused widget suite (15 tests)
- full mobile Flutter suite (1,538 tests)

## Dependency
Built on #6056 and contains only the follow-up interaction work. Merge
after #6056 lands.

---------

Signed-off-by: kenny lopez <klopez4212@gmail.com>
Signed-off-by: Kenny Lopez <klopez4212@gmail.com>
Signed-off-by: Princess Donut <b238ea756dee4d98afa5883fc7f1de61eeabe65bf700e3a5a5a80db5e42e2c2b@buzz.block.builderlab.xyz>
Signed-off-by: Tom Brow <tomb@block.xyz>
Co-authored-by: Carl <3c4caeafb646d23867f1c4832e68211d77e2561946171625f75c3ce1a3f2670f@buzz.block.builderlab.xyz>
Co-authored-by: Princess Donut <b238ea756dee4d98afa5883fc7f1de61eeabe65bf700e3a5a5a80db5e42e2c2b@buzz.block.builderlab.xyz>
Co-authored-by: leader <71e9f2c44a6932b6772caaaccda1911d010463c3e2c6c40410b8329956046801@buzz.block.builderlab.xyz>
Co-authored-by: Tom Brow <tomb@block.xyz>
Co-authored-by: Mongo <9cfd347903944d5b85aa6c93d2ab67381b978a92a31914bca69998968752a1d7@buzz.block.builderlab.xyz>
BradGroux pushed a commit to BradGroux/buzz that referenced this pull request Aug 23, 2026
## Summary
- add foreground mobile Huddles on Android and iOS with native Opus
capture/playback, mute, speaker routing, participants, lifecycle, and
minimized drawer UI
- keep mobile Huddle cards and roster state live, including ended rooms,
relay-resolved profiles, and agents
- broadcast desktop agent TTS through the existing Huddle audio protocol

## Scope
Foreground human-to-human voice MVP only. Agent setup/transcripts,
background calling, recording, and advanced device controls remain out
of scope.

## Validation
- `just mobile-check`
- `just mobile-test` — 1,500 passed
- `just desktop-check` and `just desktop-test` — 4,957 passed
- desktop typecheck, strict Clippy, and Tauri tests — 2,445 passed, 15
ignored
- mobile worktree identity contract checks
- physical Pixel/iPhone behavior reviewed during development

---------

Signed-off-by: kenny lopez <klopez4212@gmail.com>
Signed-off-by: Kenny Lopez <klopez4212@gmail.com>
Signed-off-by: Princess Donut <b238ea756dee4d98afa5883fc7f1de61eeabe65bf700e3a5a5a80db5e42e2c2b@buzz.block.builderlab.xyz>
Signed-off-by: Tom Brow <tomb@block.xyz>
Co-authored-by: Carl <3c4caeafb646d23867f1c4832e68211d77e2561946171625f75c3ce1a3f2670f@buzz.block.builderlab.xyz>
Co-authored-by: Princess Donut <b238ea756dee4d98afa5883fc7f1de61eeabe65bf700e3a5a5a80db5e42e2c2b@buzz.block.builderlab.xyz>
Co-authored-by: leader <71e9f2c44a6932b6772caaaccda1911d010463c3e2c6c40410b8329956046801@buzz.block.builderlab.xyz>
Co-authored-by: Tom Brow <tomb@block.xyz>
Co-authored-by: Mongo <9cfd347903944d5b85aa6c93d2ab67381b978a92a31914bca69998968752a1d7@buzz.block.builderlab.xyz>
BradGroux pushed a commit to BradGroux/buzz that referenced this pull request Aug 23, 2026
## Summary
- arrange Huddle participants in a responsive, equal-weight cluster with
spring enter/exit motion and a `+N` overflow
- spotlight tapped participants over a blurred call surface, with a
roster for hidden participants and no self-avatar action
- add selection haptics across full-screen and drawer controls,
including both end-call buttons
<img width="1080" height="2424" alt="Screenshot_20260819-151448"
src="https://github.com/user-attachments/assets/00b7fdca-2304-4788-9952-e07224798513"
/>
<img width="1080" height="2424" alt="Screenshot_20260819-151422"
src="https://github.com/user-attachments/assets/a0cfc861-0519-44ff-bb56-4c983ed6344c"
/>

## Validation
- `just mobile-check`
- focused participant, drawer-control, and full-screen end-call widget
tests
- Huddle-focused widget suite (15 tests)
- full mobile Flutter suite (1,538 tests)

## Dependency
Built on block#6056 and contains only the follow-up interaction work. Merge
after block#6056 lands.

---------

Signed-off-by: kenny lopez <klopez4212@gmail.com>
Signed-off-by: Kenny Lopez <klopez4212@gmail.com>
Signed-off-by: Princess Donut <b238ea756dee4d98afa5883fc7f1de61eeabe65bf700e3a5a5a80db5e42e2c2b@buzz.block.builderlab.xyz>
Signed-off-by: Tom Brow <tomb@block.xyz>
Co-authored-by: Carl <3c4caeafb646d23867f1c4832e68211d77e2561946171625f75c3ce1a3f2670f@buzz.block.builderlab.xyz>
Co-authored-by: Princess Donut <b238ea756dee4d98afa5883fc7f1de61eeabe65bf700e3a5a5a80db5e42e2c2b@buzz.block.builderlab.xyz>
Co-authored-by: leader <71e9f2c44a6932b6772caaaccda1911d010463c3e2c6c40410b8329956046801@buzz.block.builderlab.xyz>
Co-authored-by: Tom Brow <tomb@block.xyz>
Co-authored-by: Mongo <9cfd347903944d5b85aa6c93d2ab67381b978a92a31914bca69998968752a1d7@buzz.block.builderlab.xyz>
brow added a commit that referenced this pull request Aug 24, 2026
…ifications-pr

* origin/main:
  fix(desktop): emit singular `mention` feed category so alerts route correctly (#6665)
  fix(mobile): recover stale and shuffled messages (#6691)
  feat(mobile): browse and join open channels (#6243)
  show mention counts in channel notifications (#6696)
  fix(desktop): hide selection formatting tray on composer right-click (#6683)
  fix(desktop): stabilize members dialog scrolling (#6670)
  fix(desktop): keep member runtime status off the UI thread (#6445)
  perf(desktop): persist channel heads, collapse thread reads and reply sends (#6572)
  Downgrade desktop Huddles to audio protocol v2 (#6610)
  Polish Huddle participant interactions (#6312)
  Downgrade mobile Huddles to audio protocol v2 (#6558)
  perf(desktop): make the Projects surface render-cheap (#6460)
  refactor(acp): clarify agent prompt sections (#6501)
  Add mobile Huddles voice MVP (#6056)
  feat(desktop-messages): keep agents addressed across messages (#6315)
  fix(desktop): remove Buzz entity link previews (#6512)

Signed-off-by: Tom Brow <tomb@block.xyz>

# Conflicts:
#	mobile/lib/features/channels/channels_provider.dart
#	mobile/lib/shared/auth/auth_provider.dart
#	mobile/lib/shared/community/community_provider.dart
#	mobile/test/shared/auth/auth_provider_test.dart
#	mobile/test/shared/community/community_provider_test.dart
#	scripts/mobile-worktree-overrides.sh
kursmark-sq added a commit to kursmark-sq/buzz that referenced this pull request Aug 24, 2026
* origin/main: (90 commits)
  fix(desktop): bound thread /query and surface load errors, not false-empty (block#6447)
  fix(messages): route edits to the owning composer (block#6575)
  fix(mobile): join starter channels after accepting invite (block#5915)
  Add mobile profile editing (block#6583)
  fix(desktop): align jump-to-latest pill with composer height (block#6606)
  fix(desktop): emit singular `mention` feed category so alerts route correctly (block#6665)
  fix(mobile): recover stale and shuffled messages (block#6691)
  feat(mobile): browse and join open channels (block#6243)
  show mention counts in channel notifications (block#6696)
  fix(desktop): hide selection formatting tray on composer right-click (block#6683)
  fix(desktop): stabilize members dialog scrolling (block#6670)
  fix(desktop): keep member runtime status off the UI thread (block#6445)
  perf(desktop): persist channel heads, collapse thread reads and reply sends (block#6572)
  Downgrade desktop Huddles to audio protocol v2 (block#6610)
  Polish Huddle participant interactions (block#6312)
  Downgrade mobile Huddles to audio protocol v2 (block#6558)
  perf(desktop): make the Projects surface render-cheap (block#6460)
  refactor(acp): clarify agent prompt sections (block#6501)
  Add mobile Huddles voice MVP (block#6056)
  feat(desktop-messages): keep agents addressed across messages (block#6315)
  ...

Signed-off-by: Matt Kursmark <kursmark@squareup.com>

# Conflicts:
#	desktop/src/app/App.tsx
#	desktop/src/app/useCloseWindowShortcut.ts
RossHartmann added a commit to Kiingo/buzz that referenced this pull request Aug 30, 2026
* chore(desktop): drop unused ORIGINAL_CONTENT from empty-edit-delete spec (#6517)

`biome check` fails with `lint/correctness/noUnusedVariables` on
`ORIGINAL_CONTENT` in `desktop/tests/e2e/empty-edit-delete.spec.ts`,
which fails `pnpm check` (Desktop Core) for **every PR touching desktop
paths** — e.g. it currently blocks #6460. It presumably landed while
Desktop Core was path-skipped on the introducing PR.

One-line removal; the constant has no remaining references (the
assertions use `RENDERED_ORIGINAL_CONTENT`).

Signed-off-by: Max Lampert <maxwell@squareup.com>

* fix(desktop): restore true zoom by scaling the root rem (#6514)

## Summary

Follow-up to #5644. Cmd +/- had become a text-only zoom: type scaled
while rem-based padding, gaps, widths, avatars, and controls stayed
frozen, which produced cramped layouts (see [#buzz-frontend
thread](buzz://message?channel=a410ffde-c61f-416a-96e0-c296b5f5ecc9&id=1a758115cf07b00c097f6e988553908c045165325a57637519cfa7ed9c9accec)).

Root cause: #5644 introduced a virtual typography rem so the **Font
size** preference could change text without moving layout — a good
decoupling — but it also routed **Cmd +/- zoom** through that same
px-valued token and pinned the real root at 16px. One decision ("freeze
layout") was applied to two dials that shouldn't share it.

This PR gives each dial one owner and lets CSS compose them:

| Control | Changes | How |
|---|---|---|
| **Cmd +/- zoom** | Everything — true zoom | Scales the real `<html>`
font-size again (`useWebviewZoomShortcuts`) |
| **Font size preference** | Text only | Sets `data-font-size`;
`typography.css` maps it to a unitless `--buzz-type-scale`, mirroring
how density already works |

`--buzz-type-rem` becomes `calc(1rem * var(--buzz-type-scale))` —
rem-relative, so it rides on zoom automatically. Resulting text px = `16
× zoom × scale × token-ratio`. The 13 / 14 / 15px conversation contract
is unchanged at default zoom. Density and the type ramp from #5644 are
untouched.

The preference module no longer does px math or knows about zoom; the
zoom hook no longer imports the preference module. Net deletion in
production code.

## Validation

- `pnpm test` — 5,308 desktop unit tests
- `pnpm check:px-text`, `tsc --noEmit`, biome
- Playwright: `top-chrome-zoom-clearance.spec.ts` (native-chrome
clearance stays fixed under root zoom),
`inbox-refactor-screenshots.spec.ts` (zoomed row padding now asserts
`4.4px` instead of the frozen `4px`), and both `profile.spec.ts` zoom
tests (composed zoom × preference, cross-window storage reset)
- Before/after screenshots at 140% zoom in the comment below

---------

Signed-off-by: morgmart <98432065+morgmart@users.noreply.github.com>
Co-authored-by: Claude <noreply@anthropic.com>

* chore(deps): update swatinem/rust-cache digest to 6323deb (#5441)

This PR contains the following updates:

| Package | Type | Update | Change |
|---|---|---|---|
| [Swatinem/rust-cache](https://redirect.github.com/Swatinem/rust-cache)
([changelog](https://redirect.github.com/Swatinem/rust-cache/compare/e18b497796c12c097a38f9edb9d0641fb99eee32..6323deb102c322ba6fcbdcafc7e3dddab59af2b6))
| action | digest | `e18b497` → `6323deb` |

---

> [!WARNING]
> Some dependencies could not be looked up. Check the [Dependency
Dashboard](../issues/1) for more information.

---

### Configuration

📅 **Schedule**: (UTC)

- Branch creation
  - Between 12:00 AM and 03:59 AM, only on Monday (`* 0-3 * * 1`)
- Automerge
  - At any time (no schedule defined)

🚦 **Automerge**: Enabled.

♻ **Rebasing**: Whenever PR becomes conflicted, or you tick the
rebase/retry checkbox.

👻 **Immortal**: This PR will be recreated if closed unmerged. Get
[config
help](https://redirect.github.com/renovatebot/renovate/discussions) if
that's undesired.

---

- [ ] <!-- rebase-check -->If you want to rebase/retry this PR, check
this box

---

This PR was generated by [Mend Renovate](https://mend.io/renovate/).
View the [repository job
log](https://developer.mend.io/github/block/buzz).

<!--renovate-debug:eyJjcmVhdGVkSW5WZXIiOiI0NC4xMi4wIiwidXBkYXRlZEluVmVyIjoiNDQuMzkuMCIsInRhcmdldEJyYW5jaCI6Im1haW4iLCJsYWJlbHMiOltdfQ==-->

Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>

* chore(deps): update ubuntu:24.04 docker digest to 561618e (#5442)

This PR contains the following updates:

| Package | Type | Update | Change |
|---|---|---|---|
| [ubuntu](https://hub.docker.com/_/ubuntu)
([source](https://git.launchpad.net/cloud-images/+oci/ubuntu-base)) |
container | digest | `4fbb8e6` → `561618e` |

---

> [!WARNING]
> Some dependencies could not be looked up. Check the [Dependency
Dashboard](../issues/1) for more information.

---

### Configuration

📅 **Schedule**: (UTC)

- Branch creation
  - Between 12:00 AM and 03:59 AM, only on Monday (`* 0-3 * * 1`)
- Automerge
  - At any time (no schedule defined)

🚦 **Automerge**: Enabled.

♻ **Rebasing**: Whenever PR becomes conflicted, or you tick the
rebase/retry checkbox.

👻 **Immortal**: This PR will be recreated if closed unmerged. Get
[config
help](https://redirect.github.com/renovatebot/renovate/discussions) if
that's undesired.

---

- [ ] <!-- rebase-check -->If you want to rebase/retry this PR, check
this box

---

This PR was generated by [Mend Renovate](https://mend.io/renovate/).
View the [repository job
log](https://developer.mend.io/github/block/buzz).

<!--renovate-debug:eyJjcmVhdGVkSW5WZXIiOiI0NC4xMi4wIiwidXBkYXRlZEluVmVyIjoiNDQuMjkuNSIsInRhcmdldEJyYW5jaCI6Im1haW4iLCJsYWJlbHMiOltdfQ==-->

Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>

* chore(deps): update dependency @tauri-apps/api to v2.11.1 (#5444)

This PR contains the following updates:

| Package | Change |
[Age](https://docs.renovatebot.com/merge-confidence/) |
[Confidence](https://docs.renovatebot.com/merge-confidence/) |
|---|---|---|---|
| [@tauri-apps/api](https://redirect.github.com/tauri-apps/tauri) |
[`2.11.0` →
`2.11.1`](https://renovatebot.com/diffs/npm/@tauri-apps%2fapi/2.11.0/2.11.1)
|
![age](https://developer.mend.io/api/mc/badges/age/npm/@tauri-apps%2fapi/2.11.1?slim=true)
|
![confidence](https://developer.mend.io/api/mc/badges/confidence/npm/@tauri-apps%2fapi/2.11.0/2.11.1?slim=true)
|

---

> [!WARNING]
> Some dependencies could not be looked up. Check the [Dependency
Dashboard](../issues/1) for more information.

---

### Release Notes

<details>
<summary>tauri-apps/tauri (@&#8203;tauri-apps/api)</summary>

###
[`v2.11.1`](https://redirect.github.com/tauri-apps/tauri/releases/tag/%40tauri-apps/api-v2.11.1):
@&#8203;tauri-apps/api v2.11.1

[Compare
Source](https://redirect.github.com/tauri-apps/tauri/compare/@tauri-apps/api-v2.11.0...@tauri-apps/api-v2.11.1)

<details>
<summary><em><h4>PNPM Audit</h4></em></summary>

```
No known vulnerabilities found
```

</details>

#### \[2.11.1]
##### Enhancements

-
[`916782601`](https://www.github.com/tauri-apps/tauri/commit/9167826011cc3d114bf12dfb301968fae479891f)
([#&#8203;15520](https://redirect.github.com/tauri-apps/tauri/pull/15520)
by [@&#8203;polw1](https://www.github.com/tauri-apps/tauri/../../polw1))
Document that `Monitor.size`, `Monitor.position` and `Monitor.workArea`
are in physical pixels, with examples showing how to convert them to the
logical pixels expected by window creation options via
`toLogical(monitor.scaleFactor)`.

<details>
<summary><em><h4>PNPM Publish</h4></em></summary>

```
> @tauri-apps/api@2.11.1 npm-publish /home/runner/work/tauri/tauri/packages/api
> pnpm build && cd ./dist && pnpm publish --access public --loglevel silly --no-git-checks

> @tauri-apps/api@2.11.1 build /home/runner/work/tauri/tauri/packages/api
> rollup -c --configPlugin typescript

�[36m
�[1m./src/app.ts, ./src/core.ts, ./src/dpi.ts, ./src/event.ts, ./src/image.ts, ./src/index.ts, ./src/menu.ts, ./src/mocks.ts, ./src/path.ts, ./src/tray.ts, ./src/webview.ts, ./src/webviewWindow.ts, ./src/window.ts�[22m → �[1m./dist, ./dist�[22m...�[39m
�[32mcreated �[1m./dist, ./dist�[22m in �[1m883ms�[22m�[39m
�[36m
�[1msrc/index.ts�[22m → �[1m../../crates/tauri/scripts/bundle.global.js�[22m...�[39m
�[32mcreated �[1m../../crates/tauri/scripts/bundle.global.js�[22m in �[1m1.4s�[22m�[39m
npm verbose cli /opt/hostedtoolcache/node/24.16.0/x64/bin/node /opt/hostedtoolcache/node/24.16.0/x64/bin/npm
npm info using npm@11.13.0
npm info using node@v24.16.0
npm silly config load:file:/opt/hostedtoolcache/node/24.16.0/x64/lib/node_modules/npm/npmrc
npm silly config load:file:/tmp/286e8dee195254a4370e608b672019b0/.npmrc
npm silly config load:file:/home/runner/.npmrc
npm silly config load:file:/home/runner/.config/pnpm/rc
npm verbose title npm publish tauri-apps-api-2.11.1.tgz
npm verbose argv "publish" "--ignore-scripts" "tauri-apps-api-2.11.1.tgz" "--access" "public" "--loglevel" "silly"
npm verbose logfile logs-max:10 dir:/home/runner/.npm/_logs/2026-06-17T13_41_23_851Z-
npm verbose logfile /home/runner/.npm/_logs/2026-06-17T13_41_23_851Z-debug-0.log
npm warn Unknown env config "verify-deps-before-run". This will stop working in the next major version of npm. See `npm help npmrc` for supported config options.
npm warn Unknown env config "npm-globalconfig". This will stop working in the next major version of npm. See `npm help npmrc` for supported config options.
npm warn Unknown env config "overrides". This will stop working in the next major version of npm. See `npm help npmrc` for supported config options.
npm warn Unknown env config "_jsr-registry". This will stop working in the next major version of npm. See `npm help npmrc` for supported config options.
npm silly logfile done cleaning log files
npm verbose publish [ 'tauri-apps-api-2.11.1.tgz' ]
npm http cache file:/tmp/286e8dee195254a4370e608b672019b0/tauri-apps-api-2.11.1.tgz 0ms (cache hit)
npm notice
npm notice 📦  @tauri-apps/api@2.11.1
npm notice Tarball Contents
npm notice 99.3kB CHANGELOG.md
npm notice 10.2kB LICENSE_APACHE-2.0
npm notice 1.1kB LICENSE_MIT
npm notice 3.5kB README.md
npm notice 5.9kB app.cjs
npm notice 5.4kB app.d.ts
npm notice 5.5kB app.js
npm notice 11.2kB core.cjs
npm notice 6.5kB core.d.ts
npm notice 10.7kB core.js
npm notice 11.0kB dpi.cjs
npm notice 8.8kB dpi.d.ts
npm notice 10.8kB dpi.js
npm notice 5.8kB event.cjs
npm notice 4.9kB event.d.ts
npm notice 5.7kB event.js
npm notice 2.2kB external/tslib/tslib.es6.cjs
npm notice 2.2kB external/tslib/tslib.es6.js
npm notice 3.0kB image.cjs
npm notice 2.4kB image.d.ts
npm notice 2.9kB image.js
npm notice 738B index.cjs
npm notice 1.2kB index.d.ts
npm notice 669B index.js
npm notice 1.1kB menu.cjs
npm notice 451B menu.d.ts
npm notice 717B menu.js
npm notice 3.6kB menu/base.cjs
npm notice 887B menu/base.d.ts
npm notice 3.6kB menu/base.js
npm notice 2.2kB menu/checkMenuItem.cjs
npm notice 1.5kB menu/checkMenuItem.d.ts
npm notice 2.2kB menu/checkMenuItem.js
npm notice 7.4kB menu/iconMenuItem.cjs
npm notice 6.1kB menu/iconMenuItem.d.ts
npm notice 7.4kB menu/iconMenuItem.js
npm notice 5.1kB menu/menu.cjs
npm notice 4.4kB menu/menu.d.ts
npm notice 5.0kB menu/menu.js
npm notice 1.7kB menu/menuItem.cjs
npm notice 1.3kB menu/menuItem.d.ts
npm notice 1.6kB menu/menuItem.js
npm notice 1.1kB menu/predefinedMenuItem.cjs
npm notice 2.6kB menu/predefinedMenuItem.d.ts
npm notice 1.1kB menu/predefinedMenuItem.js
npm notice 7.1kB menu/submenu.cjs
npm notice 4.8kB menu/submenu.d.ts
npm notice 6.9kB menu/submenu.js
npm notice 9.8kB mocks.cjs
npm notice 5.0kB mocks.d.ts
npm notice 9.7kB mocks.js
npm notice 1.8kB package.json
npm notice 22.7kB path.cjs
npm notice 17.7kB path.d.ts
npm notice 21.7kB path.js
npm notice 7.1kB tray.cjs
npm notice 8.5kB tray.d.ts
npm notice 7.0kB tray.js
npm notice 20.7kB webview.cjs
npm notice 23.8kB webview.d.ts
npm notice 20.5kB webview.js
npm notice 8.4kB webviewWindow.cjs
npm notice 4.9kB webviewWindow.d.ts
npm notice 8.3kB webviewWindow.js
npm notice 68.1kB window.cjs
npm notice 64.9kB window.d.ts
npm notice 67.2kB window.js
npm notice Tarball Details
npm notice name: @tauri-apps/api
npm notice version: 2.11.1
npm notice filename: tauri-apps-api-2.11.1.tgz
npm notice package size: 135.7 kB
npm notice unpacked size: 699.0 kB
npm notice shasum: cd6b13fc26403ca095a02e39ecdbec8048d2872d
npm notice integrity: sha512-M2FPuYND2m+wh[...]sUepJWugQCvAA==
npm notice total files: 67
npm notice
npm http fetch GET https://run-actions-1-azure-eastus.actions.githubusercontent.com/113//idtoken/***/***?api-version=2.0&audience=npm%3Aregistry.npmjs.org 200 76ms
npm http fetch POST 201 https://registry.npmjs.org/-/npm/v1/oidc/token/exchange/package/@tauri-apps%2fapi 674ms
npm verbose oidc Successfully retrieved and set token
npm http fetch GET 200 https://registry.npmjs.org/@tauri-apps%2fapi 54ms (cache miss)
npm notice Publishing to https://registry.npmjs.org/ with tag latest and public access
npm notice publish Signed provenance statement with source and build information from GitHub Actions
npm notice publish Provenance statement published to transparency log: https://search.sigstore.dev/?logIndex=1851797040
npm http fetch PUT 200 https://registry.npmjs.org/@tauri-apps%2fapi 2070ms
+ @tauri-apps/api@2.11.1
npm verbose cwd /tmp/286e8dee195254a4370e608b672019b0
npm verbose os Linux 6.17.0-1018-azure
npm verbose node v24.16.0
npm verbose npm  v11.13.0
npm verbose exit 0
npm info ok
```

</details>

</details>

---

### Configuration

📅 **Schedule**: (UTC)

- Branch creation
  - Between 12:00 AM and 03:59 AM, only on Monday (`* 0-3 * * 1`)
- Automerge
  - At any time (no schedule defined)

🚦 **Automerge**: Enabled.

♻ **Rebasing**: Whenever PR becomes conflicted, or you tick the
rebase/retry checkbox.

👻 **Immortal**: This PR will be recreated if closed unmerged. Get
[config
help](https://redirect.github.com/renovatebot/renovate/discussions) if
that's undesired.

---

- [ ] <!-- rebase-check -->If you want to rebase/retry this PR, check
this box

---

This PR was generated by [Mend Renovate](https://mend.io/renovate/).
View the [repository job
log](https://developer.mend.io/github/block/buzz).

<!--renovate-debug:eyJjcmVhdGVkSW5WZXIiOiI0NC4xMi4wIiwidXBkYXRlZEluVmVyIjoiNDQuMzkuMCIsInRhcmdldEJyYW5jaCI6Im1haW4iLCJsYWJlbHMiOltdfQ==-->

Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>

* chore(deps): update rust crate futures to v0.3.33 (#5445)

This PR contains the following updates:

| Package | Type | Update | Change |
|---|---|---|---|
| [futures](https://rust-lang.github.io/futures-rs)
([source](https://redirect.github.com/rust-lang/futures-rs)) |
dev-dependencies | patch | `0.3.32` → `0.3.34` |

---

> [!WARNING]
> Some dependencies could not be looked up. Check the [Dependency
Dashboard](../issues/1) for more information.

---

### Release Notes

<details>
<summary>rust-lang/futures-rs (futures)</summary>

###
[`v0.3.34`](https://redirect.github.com/rust-lang/futures-rs/blob/HEAD/CHANGELOG.md#0334---2026-08-11)

[Compare
Source](https://redirect.github.com/rust-lang/futures-rs/compare/0.3.33...0.3.34)

- Preserve cloned waker identity.
([#&#8203;3032](https://redirect.github.com/rust-lang/futures-rs/issues/3032))
- Updato `syn` to 3.
([#&#8203;3028](https://redirect.github.com/rust-lang/futures-rs/issues/3028))

###
[`v0.3.33`](https://redirect.github.com/rust-lang/futures-rs/blob/HEAD/CHANGELOG.md#0333---2026-07-18)

[Compare
Source](https://redirect.github.com/rust-lang/futures-rs/compare/0.3.32...0.3.33)

- Fix `ReadLine`'s soundness issue regarding to exception safety.
([#&#8203;3020](https://redirect.github.com/rust-lang/futures-rs/issues/3020))
- Fix unsound `Send` impl for `IterPinRef` and `Iter`.
([#&#8203;3003](https://redirect.github.com/rust-lang/futures-rs/issues/3003))
- Fix stacked borrows violation in `compat01as03` implementation.
([#&#8203;3012](https://redirect.github.com/rust-lang/futures-rs/issues/3012))
- Fix memory leak in `FuturesUnordered::IntoIter`.
([#&#8203;3005](https://redirect.github.com/rust-lang/futures-rs/issues/3005))
- Add `portable-atomic-alloc` feature and use it in `FuturesUnordered`.
([#&#8203;3007](https://redirect.github.com/rust-lang/futures-rs/issues/3007))
- Re-export `alloc::task::Wake`.
([#&#8203;3010](https://redirect.github.com/rust-lang/futures-rs/issues/3010))
- Update `spin` to 0.12.
([#&#8203;3014](https://redirect.github.com/rust-lang/futures-rs/issues/3014))

</details>

---

### Configuration

📅 **Schedule**: (UTC)

- Branch creation
  - Between 12:00 AM and 03:59 AM, only on Monday (`* 0-3 * * 1`)
- Automerge
  - At any time (no schedule defined)

🚦 **Automerge**: Enabled.

♻ **Rebasing**: Whenever PR becomes conflicted, or you tick the
rebase/retry checkbox.

👻 **Immortal**: This PR will be recreated if closed unmerged. Get
[config
help](https://redirect.github.com/renovatebot/renovate/discussions) if
that's undesired.

---

- [ ] <!-- rebase-check -->If you want to rebase/retry this PR, check
this box

---

This PR was generated by [Mend Renovate](https://mend.io/renovate/).
View the [repository job
log](https://developer.mend.io/github/block/buzz).

<!--renovate-debug:eyJjcmVhdGVkSW5WZXIiOiI0NC4xMi4wIiwidXBkYXRlZEluVmVyIjoiNDQuMjkuNSIsInRhcmdldEJyYW5jaCI6Im1haW4iLCJsYWJlbHMiOltdfQ==-->

Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>

* chore(deps): update rust crate futures-util to v0.3.33 (#5448)

This PR contains the following updates:

| Package | Type | Update | Change |
|---|---|---|---|
| [futures-util](https://rust-lang.github.io/futures-rs)
([source](https://redirect.github.com/rust-lang/futures-rs)) |
dependencies | patch | `0.3.32` → `0.3.34` |
| [futures-util](https://rust-lang.github.io/futures-rs)
([source](https://redirect.github.com/rust-lang/futures-rs)) |
workspace.dependencies | patch | `0.3.32` → `0.3.34` |

---

> [!WARNING]
> Some dependencies could not be looked up. Check the [Dependency
Dashboard](../issues/1) for more information.

---

### Release Notes

<details>
<summary>rust-lang/futures-rs (futures-util)</summary>

###
[`v0.3.34`](https://redirect.github.com/rust-lang/futures-rs/blob/HEAD/CHANGELOG.md#0334---2026-08-11)

[Compare
Source](https://redirect.github.com/rust-lang/futures-rs/compare/0.3.33...0.3.34)

- Preserve cloned waker identity.
([#&#8203;3032](https://redirect.github.com/rust-lang/futures-rs/issues/3032))
- Updato `syn` to 3.
([#&#8203;3028](https://redirect.github.com/rust-lang/futures-rs/issues/3028))

###
[`v0.3.33`](https://redirect.github.com/rust-lang/futures-rs/blob/HEAD/CHANGELOG.md#0333---2026-07-18)

[Compare
Source](https://redirect.github.com/rust-lang/futures-rs/compare/0.3.32...0.3.33)

- Fix `ReadLine`'s soundness issue regarding to exception safety.
([#&#8203;3020](https://redirect.github.com/rust-lang/futures-rs/issues/3020))
- Fix unsound `Send` impl for `IterPinRef` and `Iter`.
([#&#8203;3003](https://redirect.github.com/rust-lang/futures-rs/issues/3003))
- Fix stacked borrows violation in `compat01as03` implementation.
([#&#8203;3012](https://redirect.github.com/rust-lang/futures-rs/issues/3012))
- Fix memory leak in `FuturesUnordered::IntoIter`.
([#&#8203;3005](https://redirect.github.com/rust-lang/futures-rs/issues/3005))
- Add `portable-atomic-alloc` feature and use it in `FuturesUnordered`.
([#&#8203;3007](https://redirect.github.com/rust-lang/futures-rs/issues/3007))
- Re-export `alloc::task::Wake`.
([#&#8203;3010](https://redirect.github.com/rust-lang/futures-rs/issues/3010))
- Update `spin` to 0.12.
([#&#8203;3014](https://redirect.github.com/rust-lang/futures-rs/issues/3014))

</details>

---

### Configuration

📅 **Schedule**: (UTC)

- Branch creation
  - Between 12:00 AM and 03:59 AM, only on Monday (`* 0-3 * * 1`)
- Automerge
  - At any time (no schedule defined)

🚦 **Automerge**: Enabled.

♻ **Rebasing**: Whenever PR becomes conflicted, or you tick the
rebase/retry checkbox.

👻 **Immortal**: This PR will be recreated if closed unmerged. Get
[config
help](https://redirect.github.com/renovatebot/renovate/discussions) if
that's undesired.

---

- [ ] <!-- rebase-check -->If you want to rebase/retry this PR, check
this box

---

This PR was generated by [Mend Renovate](https://mend.io/renovate/).
View the [repository job
log](https://developer.mend.io/github/block/buzz).

<!--renovate-debug:eyJjcmVhdGVkSW5WZXIiOiI0NC4xMi4wIiwidXBkYXRlZEluVmVyIjoiNDQuMzkuMCIsInRhcmdldEJyYW5jaCI6Im1haW4iLCJsYWJlbHMiOltdfQ==-->

Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>

* chore(deps): update rust crate http to v1.4.2 (#5451)

This PR contains the following updates:

| Package | Type | Update | Change |
|---|---|---|---|
| [http](https://redirect.github.com/hyperium/http) | dependencies |
patch | `1.4.0` → `1.4.2` |

---

> [!WARNING]
> Some dependencies could not be looked up. Check the [Dependency
Dashboard](../issues/1) for more information.

---

### Release Notes

<details>
<summary>hyperium/http (http)</summary>

###
[`v1.4.2`](https://redirect.github.com/hyperium/http/blob/HEAD/CHANGELOG.md#142-June-8-2026)

[Compare
Source](https://redirect.github.com/hyperium/http/compare/v1.4.1...v1.4.2)

- Fix `uri::Builder` to allow `"*"` as the path when scheme and
authority are also set, used in HTTP/2 requests.
- Fix `Uri` to properly reject `DEL` characters.

###
[`v1.4.1`](https://redirect.github.com/hyperium/http/blob/HEAD/CHANGELOG.md#141-May-25-2026)

[Compare
Source](https://redirect.github.com/hyperium/http/compare/v1.4.0...v1.4.1)

- Fix `PathAndQuery::from_static()` and `from_shared()` to reject inputs
that do not start with `/`.
- Fix `Extend` for `HeaderMap` to clamp max size hint and not overflow.
- Fix `header::IntoIter` that could use-after-free if the generic value
type could panic on drop.
- Fix `header::{IterMut, ValuesIterMut}` to not violate stacked borrows.

</details>

---

### Configuration

📅 **Schedule**: (UTC)

- Branch creation
  - Between 12:00 AM and 03:59 AM, only on Monday (`* 0-3 * * 1`)
- Automerge
  - At any time (no schedule defined)

🚦 **Automerge**: Enabled.

♻ **Rebasing**: Whenever PR becomes conflicted, or you tick the
rebase/retry checkbox.

👻 **Immortal**: This PR will be recreated if closed unmerged. Get
[config
help](https://redirect.github.com/renovatebot/renovate/discussions) if
that's undesired.

---

- [ ] <!-- rebase-check -->If you want to rebase/retry this PR, check
this box

---

This PR was generated by [Mend Renovate](https://mend.io/renovate/).
View the [repository job
log](https://developer.mend.io/github/block/buzz).

<!--renovate-debug:eyJjcmVhdGVkSW5WZXIiOiI0NC4xMi4wIiwidXBkYXRlZEluVmVyIjoiNDQuMzkuMCIsInRhcmdldEJyYW5jaCI6Im1haW4iLCJsYWJlbHMiOltdfQ==-->

Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>

* chore(deps): update rust crate http-body-util to v0.1.4 (#5452)

This PR contains the following updates:

| Package | Type | Update | Change |
|---|---|---|---|
| [http-body-util](https://redirect.github.com/hyperium/http-body) |
dependencies | patch | `0.1.3` → `0.1.5` |

---

> [!WARNING]
> Some dependencies could not be looked up. Check the [Dependency
Dashboard](../issues/1) for more information.

---

### Release Notes

<details>
<summary>hyperium/http-body (http-body-util)</summary>

###
[`v0.1.5`](https://redirect.github.com/hyperium/http-body/compare/http-body-util-v0.1.4...http-body-util-v0.1.5)

[Compare
Source](https://redirect.github.com/hyperium/http-body/compare/http-body-util-v0.1.4...http-body-util-v0.1.5)

###
[`v0.1.4`](https://redirect.github.com/hyperium/http-body/releases/tag/http-body-util-v0.1.4)

[Compare
Source](https://redirect.github.com/hyperium/http-body/compare/http-body-util-v0.1.3...http-body-util-v0.1.4)

#### What's Changed

- Add `Fused` body combinator that always returns `None` once completed.
- Add `BodyExt::into_stream()` to convert a body into a `Stream`.
- Add `Full::into_inner()` to get the full `Buf`.
- Add `InspectFrame` and `InspectErr` combinators.

</details>

---

### Configuration

📅 **Schedule**: (UTC)

- Branch creation
  - Between 12:00 AM and 03:59 AM, only on Monday (`* 0-3 * * 1`)
- Automerge
  - At any time (no schedule defined)

🚦 **Automerge**: Enabled.

♻ **Rebasing**: Whenever PR becomes conflicted, or you tick the
rebase/retry checkbox.

👻 **Immortal**: This PR will be recreated if closed unmerged. Get
[config
help](https://redirect.github.com/renovatebot/renovate/discussions) if
that's undesired.

---

- [ ] <!-- rebase-check -->If you want to rebase/retry this PR, check
this box

---

This PR was generated by [Mend Renovate](https://mend.io/renovate/).
View the [repository job
log](https://developer.mend.io/github/block/buzz).

<!--renovate-debug:eyJjcmVhdGVkSW5WZXIiOiI0NC4xMi4wIiwidXBkYXRlZEluVmVyIjoiNDQuMzkuMCIsInRhcmdldEJyYW5jaCI6Im1haW4iLCJsYWJlbHMiOltdfQ==-->

Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>

* chore(deps): update dependency sonner to v2.0.8 (#6093)

This PR contains the following updates:

| Package | Change |
[Age](https://docs.renovatebot.com/merge-confidence/) |
[Confidence](https://docs.renovatebot.com/merge-confidence/) |
|---|---|---|---|
| [sonner](https://sonner.emilkowal.ski/)
([source](https://redirect.github.com/emilkowalski/sonner)) | [`2.0.7` →
`2.0.8`](https://renovatebot.com/diffs/npm/sonner/2.0.7/2.0.8) |
![age](https://developer.mend.io/api/mc/badges/age/npm/sonner/2.0.8?slim=true)
|
![confidence](https://developer.mend.io/api/mc/badges/confidence/npm/sonner/2.0.7/2.0.8?slim=true)
|

---

> [!WARNING]
> Some dependencies could not be looked up. Check the [Dependency
Dashboard](../issues/1) for more information.

---

### Release Notes

<details>
<summary>emilkowalski/sonner (sonner)</summary>

###
[`v2.0.8`](https://redirect.github.com/emilkowalski/sonner/compare/v2.0.7...ecce1841c55e4a72dfe139a8992b56498660125e)

[Compare
Source](https://redirect.github.com/emilkowalski/sonner/compare/v2.0.7...v2.0.8)

</details>

---

### Configuration

📅 **Schedule**: (UTC)

- Branch creation
  - Between 12:00 AM and 03:59 AM, only on Monday (`* 0-3 * * 1`)
- Automerge
  - At any time (no schedule defined)

🚦 **Automerge**: Enabled.

♻ **Rebasing**: Whenever PR becomes conflicted, or you tick the
rebase/retry checkbox.

👻 **Immortal**: This PR will be recreated if closed unmerged. Get
[config
help](https://redirect.github.com/renovatebot/renovate/discussions) if
that's undesired.

---

- [ ] <!-- rebase-check -->If you want to rebase/retry this PR, check
this box

---

This PR was generated by [Mend Renovate](https://mend.io/renovate/).
View the [repository job
log](https://developer.mend.io/github/block/buzz).

<!--renovate-debug:eyJjcmVhdGVkSW5WZXIiOiI0NC4yOS41IiwidXBkYXRlZEluVmVyIjoiNDQuMzkuMCIsInRhcmdldEJyYW5jaCI6Im1haW4iLCJsYWJlbHMiOltdfQ==-->

Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>

* chore(deps): update rust crate async-trait to v0.1.92 (#6094)

This PR contains the following updates:

| Package | Type | Update | Change |
|---|---|---|---|
| [async-trait](https://redirect.github.com/dtolnay/async-trait) |
dependencies | patch | `0.1.91` → `0.1.92` |

---

> [!WARNING]
> Some dependencies could not be looked up. Check the [Dependency
Dashboard](../issues/1) for more information.

---

### Release Notes

<details>
<summary>dtolnay/async-trait (async-trait)</summary>

###
[`v0.1.92`](https://redirect.github.com/dtolnay/async-trait/releases/tag/0.1.92)

[Compare
Source](https://redirect.github.com/dtolnay/async-trait/compare/0.1.91...0.1.92)

- Resolve double\_must\_use clippy lint in generated code
([#&#8203;303](https://redirect.github.com/dtolnay/async-trait/issues/303))

</details>

---

### Configuration

📅 **Schedule**: (UTC)

- Branch creation
  - Between 12:00 AM and 03:59 AM, only on Monday (`* 0-3 * * 1`)
- Automerge
  - At any time (no schedule defined)

🚦 **Automerge**: Enabled.

♻ **Rebasing**: Whenever PR becomes conflicted, or you tick the
rebase/retry checkbox.

👻 **Immortal**: This PR will be recreated if closed unmerged. Get
[config
help](https://redirect.github.com/renovatebot/renovate/discussions) if
that's undesired.

---

- [ ] <!-- rebase-check -->If you want to rebase/retry this PR, check
this box

---

This PR was generated by [Mend Renovate](https://mend.io/renovate/).
View the [repository job
log](https://developer.mend.io/github/block/buzz).

<!--renovate-debug:eyJjcmVhdGVkSW5WZXIiOiI0NC4yOS41IiwidXBkYXRlZEluVmVyIjoiNDQuMzkuMCIsInRhcmdldEJyYW5jaCI6Im1haW4iLCJsYWJlbHMiOltdfQ==-->

Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>

* fix(composer): preserve caret when inserting mentions mid-message (#6531)

**Category:** fix
**User Impact:** Users can insert mentions earlier in a draft and
continue typing without the caret corrupting the rest of the message.

**Problem:** Caret correction ran after every document change, so typing
a mention before existing text repeatedly advanced across the mention
separator and interleaved spaces into the draft. **Solution:** Limit
correction to the autocomplete settlement it was designed for, with
transaction-level and browser-level regression coverage for known and
unregistered mentions.

<details>
<summary>File changes</summary>

**desktop/src/features/messages/lib/mentionHighlightExtension.ts**
Restricts trailing-space caret advancement to an armed autocomplete
settlement instead of every document change.

**desktop/src/features/messages/lib/mentionHighlightExtension.test.mjs**
Exercises the real ProseMirror plugin state and verifies mid-draft
mention typing, unknown tokens, end-of-message typing, and
completed-mention separators.

**desktop/tests/e2e/mentions.spec.ts**
Reproduces the reported composer workflow in Chromium and covers the
same corruption path for an unregistered `@token`.

</details>

## Reproduction steps

1. Open a channel and enter `hello world` in the composer.
2. Move the caret between `hello` and ` world`.
3. Type ` @bo`, select `bob` from autocomplete, and continue typing
`abc`.
4. Confirm the composer reads `hello @bob abc world` with the caret
after `abc`.
5. Repeat with an unregistered token such as ` @zzq` and confirm the
existing text remains intact.

## Before / After

| Before | After |
| --- | --- |
| Typing after a mid-draft mention walks the caret through the existing
message. | Continued typing stays after the inserted mention. |
| ![Before: mention caret corrupts existing draft
text](https://d24qwcpro867f5.cloudfront.net/repos/buzz/prs/6531/mention-caret-before.gif)
| ![After: caret remains after the inserted
mention](https://d24qwcpro867f5.cloudfront.net/repos/buzz/prs/6531/mention-caret-after.gif)
|

---------

Signed-off-by: Taylor Ho <taylorkmho@gmail.com>
Co-authored-by: Mongo <5398c5fd039b963ce132b3e078e7c4af097dd997517bb5e14c2682fe68c25197@buzz.block.builderlab.xyz>

* fix(desktop): remove Buzz entity link previews (#6512)

**Category:** improvement
**User Impact:** Buzz-native project, repository, issue, and pull
request links now appear once as compact inline chips, with their
details available on hover.

**Problem:** Buzz-native entity links rendered both an inline chip and a
standalone preview card, repeating the same metadata and adding visual
noise to conversations. **Solution:** Exclude Buzz-native links from the
shared standalone-preview extractor while leaving entity parsing intact
for chip tooltips and preserving external web previews and attachment
cards.

<details>
<summary>File changes</summary>

**desktop/src/shared/lib/linkPreview.ts**
Stops Buzz-native preview candidates after parsing, including same-relay
git clone URLs that normalize to repository entities, while allowing
external URLs through the existing snapshot path.

**desktop/src/shared/lib/linkPreview.test.mjs**
Covers project, repository, issue, pull request, markdown-labeled,
same-relay clone, and mixed external-link extraction behavior.

**desktop/src/shared/ui/markdown/useMessageLinkPreviews.test.mjs**
Confirms sent messages no longer merge a standalone Buzz entity card
while external sender snapshots still render.

</details>

## Reproduction steps

1. Open a desktop channel containing a `buzz://project`, `buzz://repo`,
`buzz://issue`, or `buzz://pr` link.
2. Confirm the link renders as an inline entity chip without a second
standalone Buzz card below the message.
3. Hover the chip and confirm its entity metadata remains available.
4. Post an external HTTPS link and confirm its web preview still
renders.
5. Paste a same-relay `/git/<owner>/<repo>` clone URL and confirm it
uses the repository chip without a duplicate card.

## Screenshots

| Before | After |
| --- | --- |
| Inline chip plus redundant standalone Project card | Inline chip is
now the sole presentation |
| ![Before: project chip and duplicate standalone
card](https://raw.githubusercontent.com/block/buzz/7e1a0d6cfb52382ef636d7331cf30ea334429c4d/pr-6512--before.png)
| ![After: project chip without a standalone
card](https://raw.githubusercontent.com/block/buzz/7e1a0d6cfb52382ef636d7331cf30ea334429c4d/pr-6512--after.png)
|

**After — rich metadata stays available on hover**

![After: dark theme with pink accent and Project tooltip showing
description and repository
count](https://raw.githubusercontent.com/block/buzz/7e1a0d6cfb52382ef636d7331cf30ea334429c4d/pr-6512--after-tooltip-rich.png)

## Verification

At commit `3fa74cdd342ac1f6721b7d56a7f111af31e0e6e9`:

- focused link-preview + Markdown unit suites — 119/119 passed
- targeted registered smoke E2E — 8/8 passed, including labeled
same-relay clone metadata, ordinary-link presentation, and in-app
navigation
- `cd desktop && pnpm exec tsc --noEmit` — passed
- `git diff --check origin/main...HEAD` — passed
- pre-push hooks — desktop check, TypeScript, and full desktop unit
suite passed

---------

Signed-off-by: Rizz <302abe414ca6e3134763d2539bfcf145aea2a63fe5f8455204ed602fd40cf381@buzz.block.builderlab.xyz>
Signed-off-by: Taylor Ho <taylorkmho@gmail.com>
Co-authored-by: Rizz <302abe414ca6e3134763d2539bfcf145aea2a63fe5f8455204ed602fd40cf381@buzz.block.builderlab.xyz>
Co-authored-by: Mongo <5398c5fd039b963ce132b3e078e7c4af097dd997517bb5e14c2682fe68c25197@buzz.block.builderlab.xyz>

* feat(desktop-messages): keep agents addressed across messages (#6315)

**Category:** new-feature
**User Impact:** Users can keep selected agents addressed across
consecutive messages without retyping their handles.

**Problem:** Repeated conversations with agents require manually typing
the same mentions on every turn, which adds friction and makes
recipients easy to omit.

**Solution:** The composer can now keep agents automatically addressed
per channel, either from the mention controls or after a successful
inline mention. Addressed agents remain visible in the toolbar, apply to
channel threads, survive send failures safely, and never cross community
boundaries.

## Changes

<details>
<summary>File changes</summary>

**desktop/src-tauri/src/events/message_tags.rs**  
Preserves the automatic-address marker on validated mention reference
tags.

**desktop/src/features/channels/ui/ChannelPane.tsx**  
Wires the appropriate channel, thread, inbox, or forum composer context
without leaking audiences across surfaces.

**desktop/src/features/communities/useCommunityInit.ts**  
Clears composer audience state when the active community changes.

**desktop/src/features/forum/ui/ForumComposer.tsx**  
Wires the appropriate channel, thread, inbox, or forum composer context
without leaking audiences across surfaces.

**desktop/src/features/forum/ui/ForumComposerAutocompletes.tsx**  
Wires the appropriate channel, thread, inbox, or forum composer context
without leaking audiences across surfaces.

**desktop/src/features/home/ui/InboxDetailPane.tsx**  
Wires the appropriate channel, thread, inbox, or forum composer context
without leaking audiences across surfaces.

**desktop/src/features/messages/lib/agentAddressMention.d.mts**  
Defines helpers and types for marked automatic-address mention tags.

**desktop/src/features/messages/lib/agentAddressMention.mjs**  
Defines helpers and types for marked automatic-address mention tags.

**desktop/src/features/messages/lib/agentAddressMention.test.mjs**  
Covers the automatic-address behavior and its failure, keyboard, layout,
or persistence boundaries.

**desktop/src/features/messages/lib/applyEditTagOverlay.mjs**  
Preserves automatic-address metadata when edited message tags are
overlaid.

**desktop/src/features/messages/lib/applyEditTagOverlay.test.mjs**  
Covers the automatic-address behavior and its failure, keyboard, layout,
or persistence boundaries.


**desktop/src/features/messages/lib/autoPinMentionedAgentsPreference.test.mjs**
Covers the automatic-address behavior and its failure, keyboard, layout,
or persistence boundaries.


**desktop/src/features/messages/lib/autoPinMentionedAgentsPreference.ts**
Stores the preference that keeps explicitly mentioned agents addressed
for later messages.

**desktop/src/features/messages/lib/extractMentionPersonas.ts**  
Separates persona recipients from the composer mention orchestration.

**desktop/src/features/messages/lib/persistentAgentAudience.test.mjs**  
Covers the automatic-address behavior and its failure, keyboard, layout,
or persistence boundaries.

**desktop/src/features/messages/lib/persistentAgentAudience.ts**  
Maintains bounded, in-memory, channel-scoped automatic agent audiences.

**desktop/src/features/messages/lib/useMentionSelection.ts**  
Centralizes mention picker selection state and agent-first selection
behavior.

**desktop/src/features/messages/lib/useMentions.ts**  
Exposes explicit picker origins and selection controls while preserving
inline mention behavior.

**desktop/src/features/messages/ui/ComposerAddressControls.test.mjs**  
Covers the automatic-address behavior and its failure, keyboard, layout,
or persistence boundaries.

**desktop/src/features/messages/ui/ComposerAddressControls.tsx**  
Renders compact addressed-agent avatars and the automatic-mention
management entry point.

**desktop/src/features/messages/ui/MentionAutocomplete.test.mjs**  
Covers the automatic-address behavior and its failure, keyboard, layout,
or persistence boundaries.

**desktop/src/features/messages/ui/MentionAutocomplete.tsx**  
Adds automatic-mention controls and options to the existing mention
picker.

**desktop/src/features/messages/ui/MessageAgentAddressPrefix.tsx**  
Shows which agents were automatically addressed on a sent message.

**desktop/src/features/messages/ui/MessageComposer.tsx**  
Integrates automatic audiences, picker controls, accessible feedback,
shortcuts, and send behavior.

**desktop/src/features/messages/ui/MessageComposer.types.ts**  
Defines the simplified channel audience context shared by composer
hosts.

**desktop/src/features/messages/ui/MessageComposerToolbar.tsx**  
Places automatic-address controls in the composer toolbar without
crowding narrow layouts.

**desktop/src/features/messages/ui/MessageRow.tsx**  
Displays automatic-address metadata alongside sent message content.

**desktop/src/features/messages/ui/MessageThreadPanel.tsx**  
Wires the appropriate channel, thread, inbox, or forum composer context
without leaking audiences across surfaces.

**desktop/src/features/messages/ui/composerAgentKeyboard.test.mjs**  
Covers the automatic-address behavior and its failure, keyboard, layout,
or persistence boundaries.


**desktop/src/features/messages/ui/persistentAgentAudienceHosts.test.mjs**
Covers the automatic-address behavior and its failure, keyboard, layout,
or persistence boundaries.

**desktop/src/features/messages/ui/useAddressMentionPulse.test.mjs**  
Covers the automatic-address behavior and its failure, keyboard, layout,
or persistence boundaries.

**desktop/src/features/messages/ui/useAddressMentionPulse.ts**  
Provides success and failure animation signals for addressed-agent
controls.

**desktop/src/features/messages/ui/useAgentAddressLockPicker.test.mjs**
Covers the automatic-address behavior and its failure, keyboard, layout,
or persistence boundaries.

**desktop/src/features/messages/ui/useAgentAddressLockPicker.ts**  
Coordinates adding, removing, and announcing automatically addressed
agents.

**desktop/src/features/messages/ui/useAlwaysAddressShortcut.ts**  
Implements the platform-aware shortcut for toggling automatic
addressing.

**desktop/src/features/messages/ui/useAutoPinMentionedAgents.ts**  
Promotes successfully sent inline agent mentions and provides a single
undoable notification.

**desktop/src/features/messages/ui/useComposerMentionPicker.ts**  
Opens the mention picker without rewriting the current draft.


**desktop/src/features/messages/ui/useMentionSendFlow.helpers.test.mjs**
Covers the automatic-address behavior and its failure, keyboard, layout,
or persistence boundaries.

**desktop/src/features/messages/ui/useMentionSendFlow.helpers.ts**  
Merges automatic and inline recipients, marks outgoing tags, and
restores failed sends safely.

**desktop/src/features/messages/ui/useMentionSendFlow.ts**  
Merges automatic and inline recipients, marks outgoing tags, and
restores failed sends safely.


**desktop/src/features/messages/ui/usePersistentAgentMentionHydration.ts**
Removes the prior draft-text hydration approach now that automatic
audiences stay at composer ingress.

**desktop/src/features/settings/ui/AgentsSettingsPanel.tsx**  
Replaces the old global behavior with explicit composer-level
automatic-mention controls.

**desktop/src/features/settings/ui/PreventSleepSettingsCard.tsx**  
Replaces the old global behavior with explicit composer-level
automatic-mention controls.

**desktop/src/shared/lib/keyboard-shortcuts.ts**  
Defines the user-facing automatic-address keyboard shortcut label.

**desktop/src/shared/ui/VideoReviewCommentMarkdown.tsx**  
Allows automatic-address prefixes to compose with video review
timecodes.

**desktop/tests/e2e/persistent-agent-audience.spec.ts**  
Covers the automatic-address behavior and its failure, keyboard, layout,
or persistence boundaries.

</details>

## Reproduction Steps

1. Open a channel with one or more agents and open the mention picker
from the composer.
2. Select an agent for automatic mentions, then send several messages
without retyping the handle; confirm the agent remains in the composer
control and receives each message.
3. Mention another agent inline, send successfully, and confirm the
agent becomes automatically addressed; use the notification's Undo
action to reverse it.
4. Open a thread in the same channel and confirm the same addressed
agents are available there.
5. Remove an agent from the composer control and confirm later messages
stop addressing it.
6. Switch communities and confirm addressed agents do not carry into the
other community.

## Screenshots

All states below use the dark Buzz theme with a selected lilac accent.

### Addressed composer

Selected agents stay visible at the composer ingress without adding
handles to the draft.

![Two automatically addressed agents in the dark composer with a lilac
accent](https://raw.githubusercontent.com/block/buzz/74c2cbbd80ed630a0c6e00c420505a691bd4994a/pr-6315--01-addressed-composer.png)

### Open mention menu

The @ ingress opens the existing mention menu and shows which agents are
already addressed.

![Open mention menu with automatically addressed agents
highlighted](https://raw.githubusercontent.com/block/buzz/74c2cbbd80ed630a0c6e00c420505a691bd4994a/pr-6315--02-open-mention-menu.png)

### Mention options

The inline options pane controls whether a successful one-time agent
mention carries into later messages.

![Automatic mention options expanded above the mention
menu](https://raw.githubusercontent.com/block/buzz/74c2cbbd80ed630a0c6e00c420505a691bd4994a/pr-6315--03-mention-options.png)

### Agent settings

The same preference is available in **Settings → Agents →
Conversations**.

![Automatic agent mentions preference in the Agents settings
pane](https://raw.githubusercontent.com/block/buzz/74c2cbbd80ed630a0c6e00c420505a691bd4994a/pr-6315--04-agent-settings.png)

---------

Signed-off-by: Taylor Ho <taylorkmho@gmail.com>
Co-authored-by: Rizz <302abe414ca6e3134763d2539bfcf145aea2a63fe5f8455204ed602fd40cf381@buzz.block.builderlab.xyz>
Co-authored-by: Carl <acda9e433d19dcd0e6b6840f7f4b98f3a56f1fab98049d444c087019e6d36560@buzz.block.builderlab.xyz>

* Add mobile Huddles voice MVP (#6056)

## Summary
- add foreground mobile Huddles on Android and iOS with native Opus
capture/playback, mute, speaker routing, participants, lifecycle, and
minimized drawer UI
- keep mobile Huddle cards and roster state live, including ended rooms,
relay-resolved profiles, and agents
- broadcast desktop agent TTS through the existing Huddle audio protocol

## Scope
Foreground human-to-human voice MVP only. Agent setup/transcripts,
background calling, recording, and advanced device controls remain out
of scope.

## Validation
- `just mobile-check`
- `just mobile-test` — 1,500 passed
- `just desktop-check` and `just desktop-test` — 4,957 passed
- desktop typecheck, strict Clippy, and Tauri tests — 2,445 passed, 15
ignored
- mobile worktree identity contract checks
- physical Pixel/iPhone behavior reviewed during development

---------

Signed-off-by: kenny lopez <klopez4212@gmail.com>
Signed-off-by: Kenny Lopez <klopez4212@gmail.com>
Signed-off-by: Princess Donut <b238ea756dee4d98afa5883fc7f1de61eeabe65bf700e3a5a5a80db5e42e2c2b@buzz.block.builderlab.xyz>
Signed-off-by: Tom Brow <tomb@block.xyz>
Co-authored-by: Carl <3c4caeafb646d23867f1c4832e68211d77e2561946171625f75c3ce1a3f2670f@buzz.block.builderlab.xyz>
Co-authored-by: Princess Donut <b238ea756dee4d98afa5883fc7f1de61eeabe65bf700e3a5a5a80db5e42e2c2b@buzz.block.builderlab.xyz>
Co-authored-by: leader <71e9f2c44a6932b6772caaaccda1911d010463c3e2c6c40410b8329956046801@buzz.block.builderlab.xyz>
Co-authored-by: Tom Brow <tomb@block.xyz>
Co-authored-by: Mongo <9cfd347903944d5b85aa6c93d2ab67381b978a92a31914bca69998968752a1d7@buzz.block.builderlab.xyz>

* refactor(acp): clarify agent prompt sections (#6501)

## Why
The ACP prompt puts a machine-specific Workspace prefix before static
base guidance and labels the user-facing agent instruction layer as the
generic System section. Because the cwd varies by launch and worktree,
leading with it reduces reusable prompt-prefix stability.

`[Workspace]` was added in [PR
#1194](https://github.com/block/buzz/pull/1194) as a defensive fix after
a broken `~/.sprout` → `~/.buzz` migration caused agents to scan `$HOME`
and trigger macOS TCC prompts. This change retains that grounding while
shrinking it to the current working directory and moving dynamic
environment context after the static Base prompt.

## What
- Emit the prompt in Base → Workspace → Agent Instructions order
- Reduce Workspace to `Current working directory: <absolute path>`
- Resolve cwd as an absolute native-platform path and preserve Windows
drive/UNC paths instead of checking for a leading `/`
- Emit Agent Instructions for persona and standalone agent instructions
across modern and legacy ACP paths
- Preserve parsing for archived observer frames that used System or the
former Workspace-before-Base order, and align the persona catalog label

## Risk Assessment
Medium-low — this changes prompt framing for every newly created agent
session. Existing archived observer frames remain parseable, and
execution still uses the same ACP working directory. Cwd resolution now
fails clearly instead of substituting `/` when the process directory
cannot be resolved.

## References
- https://github.com/block/buzz/pull/1103
- https://github.com/block/buzz/pull/1194

Generated with Codex

---------

Signed-off-by: Salman Mohammed <smohammed@squareup.com>

* perf(desktop): make the Projects surface render-cheap (#6460)

Diagnostic profiling on a large community (101 issues, 258 PRs) showed
Projects tab switches taking 2.5–3.6s, dominated by single React commits
of 0.5–1.5s and per-render recomputation — fetch work was already off
the main thread; the cost was building the UI.

### Measured: tab click → painted, per tab

| tab | before | after |
|---|---|---:|
| projects | 3,608ms | 320–580ms |
| repositories | 3,126–3,534ms | 310–410ms |
| tasks | 395–1,101ms | ~115ms |
| reviews | 322–2,603ms | ~96ms |
| activity | 597–741ms | ~148ms |

Single-commit ceiling dropped from 1,541ms to ≤200ms (growth steps
25–40ms). Fixes in profiled-cost order:

- **Profile popover body mounts only while open.** `UserProfilePopover`
carried seven query subscriptions plus interaction hooks per instance
even when closed; grids mount hundreds (five per card in people stacks,
one per row author) — measured **~40ms per card**, the dominant share of
the 1.2s card-tab commits. The always-mounted shell is now just the
Radix root + trigger; trigger markup, hover timing, and keyboard
handling are unchanged, and hover/tooltip event continuity is preserved
because the trigger never remounts.
- **Incremental row mounting.** The first 12 cards / 30 rows render in
the first commit; the rest stream in 36–60-per-frame low-priority
transitions. Grouped lists trim across group boundaries via a pure,
tested slicer; the mounted count survives in-place refetches.
- **Activity feed**: was rebuilt unmemoized on every render,
markdown-flattening every issue/PR/comment body in the community just to
sort and keep 30 items (~360+ flattens per render on the measured
community). Now memoized, and bodies stay raw until after the sort+slice
— 30 flattens, once per data change.
- **Contribution graph** (always-visible rail, so every tab paid for
it): ~180 day cells each wrapped in a Radix tooltip with per-cell Intl
date formatting per render. Now memoized, cells precomputed once per
data change, native `title` tooltips. (The activity-bar segments keep
their styled Radix tooltips — pinned by an existing spec.)
- **Rows/cards memoized with identity-stable props**: per-row selection
arrays were rebuilt per row per render (O(n²) — 258 PRs × 258-item
arrays each render) and are now hoisted and shared; people arrays derive
inside the memoized cards; the rail's stat walk over every issue/PR is
memoized.
- **`content-visibility: auto`** on cards and rows so offscreen entries
skip layout and paint; **tab switches run in a React transition** so the
click stays responsive while the new tree mounts.

Remaining known cost (out of scope): cold-entry data readiness — the
work-item and activity queries ship thousands of events to compute
counts (2–4s on a large community; see the fan-lifecycle PR). The
structural fix is a relay-side aggregate; tracked as follow-up.

---------

Signed-off-by: Max Lampert <maxwell@squareup.com>

* Downgrade mobile Huddles to audio protocol v2 (#6558)

## Summary

- downgrade Mobile Huddle authentication and native media configuration
from protocol v3 to the currently deployed relay's v2 contract
- restore the released one-byte relay peer prefix while retaining later
reconnect, roster, and playout-reset reliability fixes
- update Android, iOS, protocol documentation, and focused tests
together

Protocol v2 does not carry v3's occupancy epoch on audio frames, so it
cannot fence the narrow delayed-packet/peer-index-reuse race. This is an
intentional compatibility tradeoff until the relay v3 rollout is ready.

### Related issue

None found.

### Testing

- `just mobile-check`
- `just mobile-test` — 1,661 tests passed
- Android debug build installed and launched on Pixel 10 as
`xyz.block.buzz.mobile.sprout_mobile_profile_settings`; foreground
process verified
- signed iOS Release build installed and launched on iPhone as
`com.buzz.buzzMobile`; running process verified

A live two-device Huddle audio call remains a manual verification step.

Signed-off-by: kenny lopez <klopez4212@gmail.com>

* Polish Huddle participant interactions (#6312)

## Summary
- arrange Huddle participants in a responsive, equal-weight cluster with
spring enter/exit motion and a `+N` overflow
- spotlight tapped participants over a blurred call surface, with a
roster for hidden participants and no self-avatar action
- add selection haptics across full-screen and drawer controls,
including both end-call buttons
<img width="1080" height="2424" alt="Screenshot_20260819-151448"
src="https://github.com/user-attachments/assets/00b7fdca-2304-4788-9952-e07224798513"
/>
<img width="1080" height="2424" alt="Screenshot_20260819-151422"
src="https://github.com/user-attachments/assets/a0cfc861-0519-44ff-bb56-4c983ed6344c"
/>

## Validation
- `just mobile-check`
- focused participant, drawer-control, and full-screen end-call widget
tests
- Huddle-focused widget suite (15 tests)
- full mobile Flutter suite (1,538 tests)

## Dependency
Built on #6056 and contains only the follow-up interaction work. Merge
after #6056 lands.

---------

Signed-off-by: kenny lopez <klopez4212@gmail.com>
Signed-off-by: Kenny Lopez <klopez4212@gmail.com>
Signed-off-by: Princess Donut <b238ea756dee4d98afa5883fc7f1de61eeabe65bf700e3a5a5a80db5e42e2c2b@buzz.block.builderlab.xyz>
Signed-off-by: Tom Brow <tomb@block.xyz>
Co-authored-by: Carl <3c4caeafb646d23867f1c4832e68211d77e2561946171625f75c3ce1a3f2670f@buzz.block.builderlab.xyz>
Co-authored-by: Princess Donut <b238ea756dee4d98afa5883fc7f1de61eeabe65bf700e3a5a5a80db5e42e2c2b@buzz.block.builderlab.xyz>
Co-authored-by: leader <71e9f2c44a6932b6772caaaccda1911d010463c3e2c6c40410b8329956046801@buzz.block.builderlab.xyz>
Co-authored-by: Tom Brow <tomb@block.xyz>
Co-authored-by: Mongo <9cfd347903944d5b85aa6c93d2ab67381b978a92a31914bca69998968752a1d7@buzz.block.builderlab.xyz>

* Downgrade desktop Huddles to audio protocol v2 (#6610)

## Summary
- negotiate Huddle audio protocol v2 on desktop
- decode the released one-byte peer-index prefix
- retain roster-driven playout resets and document the missing v3 epoch
fence

## Testing
- `just desktop-tauri-fmt-check`
- `just desktop-tauri-clippy`
- `just desktop-tauri-test`

Signed-off-by: kenny lopez <klopez4212@gmail.com>

* perf(desktop): persist channel heads, collapse thread reads and reply sends (#6572)

## Summary

Lands the build-now items from the desktop latency plan
(#ui-performance-deep-dive) as one change. Every perceived-latency hot
path a user hits on launch, channel open, thread open, and reply send
drops one or more round trips.

**A1 — persisted channel heads (the big one).** Native WAL SQLite cache
(`desktop/src-tauri/src/channel_head_cache.rs`) keyed by `{pubkey,
relayUrl}` scope, 32 rows/scope LRU, 1 MiB per-row drop cap,
schema-version reset, corrupt-row tolerance, checkpointed on shutdown.
Three blocking-pool commands: `channel_head_cache_load` / `_store` /
`_clear`. On the renderer side, `CommunityQueryProvider` kicks off
hydration of up to 12 heads when it constructs the query client — the
app, splash and relay preconnect mount immediately; only
`useChannelMessagesQuery` awaits the seed (`channelHeadHydration`), then
consumes a one-shot hydrated gate so a hydrated channel pays **zero**
`get_channel_window` calls on mount and exactly **one** on the
post-subscription refresh, whose response replaces page zero wholesale.
That refresh fires whether live-subscription setup succeeds or fails,
and is sequenced behind hydration so it is always a distinct
authoritative fetch (see Review follow-ups). Bounds-only persisted heads
(zero rows) are not hydrated and take the cold loading path. The
timeline loading latch recognizes native-hydrated rows as restart-safe
so they paint immediately instead of holding a skeleton. The cache is a
paint accelerator only — the relay response is always authoritative.
Replaces the legacy localStorage `messageSnapshot.ts` (removed, -401
lines).

Kill switch: `VITE_BUZZ_CHANNEL_HEAD_CACHE=off` at build time or
`localStorage["buzz-channel-head-cache"] = "off"` at runtime. Cache is
cleared on community removal and scoped per identity, so a replaced
signer never sees the previous identity's rows.

**B1 — thread aux in one response.** Relay thread filters accept
`include_aux`; the bridge appends the same authorized two-hop
reactions/edits/deletions closure a channel window gets
(`build_aux_query` shared with the window path). Renderer
`useThreadReplies` drops its two follow-up aux fetches. `next_cursor` is
computed from reply-kind rows only since aux rows are unpaged.
Documented in `docs/bridge-channel-window.md`. Thread queries keep
`staleTime: 0` (`bcfe04e2f`): an earlier revision raised it to 30s,
which CI's `thread-unread.spec.ts` caught — once the user leaves a
channel, the live subscription stops feeding that thread's cache, so a
reopen must always take the (now single) authoritative read.

**B2 — cached root on reply send.** `send_channel_message` gains
`root_event_id`; when the renderer already holds the parent (channel or
thread cache) it passes the NIP-10 root, and native signs without the
relay round trip that `resolve_thread_ref` used to make. Strict hex
parse; `root_event_id` requires `parent_event_id`; absent root falls
back to the existing relay resolution. The renderer never sends a
guessed root.

**B4** general HTTP pool idle 10s→300s, max idle per host 1→2. **B5**
relay preconnect fires as soon as identity is ready instead of waiting
for `requestIdleCallback`. One e2e test (`relay-reconnect.spec.ts`
"service restart close resets accumulated backoff") had been relying on
the idle-callback batching to skip past its own seeded dial failures
before the channel list painted; `8133d70bb` makes it wait for the
connected state instead (test-only, still fails with the 1012 backoff
reset disabled). **B6** profile freshness 60s→10 min (both the in-memory
entry check and the query `staleTime`). Tradeoff: another user's
display-name/avatar edit can take up to 10 min to propagate to a client
that already holds their profile (relay reconnect refetches
`users-batch` but resolves from the still-fresh per-pubkey entry); your
own edits still evict the entry immediately (`evictUsersBatchEntries` in
`useUpdateProfileMutation`).

### Related issue
Follows #6456/#6457/#6459/#6460 (already merged). #6455 is the
measurement instrument and is intentionally not folded in. No duplicate
PR found.

### Review follow-ups
Addressing Carl's reviews
[5001114109](https://github.com/block/buzz/pull/6572#pullrequestreview-5001114109)
and
[5002596542](https://github.com/block/buzz/pull/6572#pullrequestreview-5002596542),
each pushed as new commits (no rebase):

- `4f06b7770` fix(desktop): mount app while channel heads hydrate;
always revalidate — provider no longer gates children on the cache load;
`refreshAfterSubscribe` runs on subscribe failure too; bounds-only heads
skipped at seed; seed merges into an existing window store. +3 tests.
- `35834cb31` fix(relay): drain aux closure hops across the page clamp —
`query_all_pages` walks the `(created_at, id)` keyset via
`until`/`before_id` until a short page (`AUX_PAGE_LIMIT` =
`DEFAULT_MAX_PAGE_LIMIT`, `AUX_MAX_PAGES` = 64 warn+truncate) so
one-shot `limit: 1000` newest-first no longer drops the oldest
edits/deletions. +3 tests; `docs/bridge-channel-window.md` updated.
- `db21b0531` merge of `origin/main` `e23632941` (#6558, #6312 — no
overlap).
- `5a5566c0f` fix(desktop): sequence post-subscribe refresh behind
channel head hydration — `refreshChannelWindowMessages` awaits
`channelHeadHydration()` and, for a hydration-seeded query (`data !==
undefined && dataUpdatedAt === 0`), the in-flight snapshot fetch before
invalidating. Without this, a subscription that settles before the
SQLite load invalidated a data-less in-flight query; TanStack dedupes
that onto the existing fetch (`query-core` `fetch()` only cancels when
`state.data` exists), which returned the seeded snapshot — 0
authoritative fetches. Regression test reproduces Carl's exact ordering
(fails at `35834cb31` with 0 calls), plus a cold-channel guard that the
fix does not double-fetch.
- `b129231c8` fix(desktop): let concurrent post-hydration refreshes
share one window fetch — found independently by Max and Wren reviewing
`5a5566c0f`: subscribe settlement + reconnect both wake on the same
snapshot promise and both invalidate; the second (default
`cancelRefetch: true`) cancelled and replaced the first authoritative
fetch (3 queryFn calls, not 2, and the cancelled Tauri invoke still hits
the relay). The seeded branch now invalidates with `cancelRefetch:
false` so a second waker joins the in-flight fetch; cold/warm keep the
default (`test_canceled_stale_fetch_cannot_overwrite_catch_up_window`
relies on it). Concurrent regression test fails at `5a5566c0f` with 3.

### Testing
At `b129231c8` (PR head; verified in one shell with `git rev-parse HEAD`
= `b129231c8`): `pnpm check`, `tsc --noEmit`, desktop unit 5,393 / 0,
Playwright `boot-splash` + `channel-head-restart` + `relay-reconnect` +
`relay-reconnect-affordance` + `thread-unread` 34 / 34 on a fresh
`build:e2e`, pre-push hooks green.

At `5a5566c0f`: `pnpm check`, `tsc --noEmit`, desktop unit 5,392 / 0,
Playwright `boot-splash` + `channel-head-restart` + `relay-reconnect` +
`relay-reconnect-affordance` + `thread-unread` 34 / 34 on a fresh
`build:e2e`, pre-push hooks green.

At `35834cb31`: desktop unit 5,390 / 0; `cargo test -p buzz-relay --lib`
910 / 0; fmt + clippy `-D warnings` clean; Playwright 32 / 32 (same
specs minus affordance); GitHub CI green on every job except Smoke (3)
(unrelated project-review row-count + messaging timing flake, per Carl)
and Unit Tests (sherpa cache skeleton, below).

Earlier, all at `8133d70bb` (this PR head is `0c492366d` = 8133d70bb + a
comments-only commit correcting two `profile/hooks.ts` freshness
comments from 60s to 10 min; pre-push desktop check/typecheck/test
5,387/0 re-ran at 0c492366d) in one shell; `origin/main` = `040b203f7`
at PR open, since moved to `4baccd539` (#6558, mobile only — zero file
overlap, `git merge-tree` clean):

- `just desktop-test` — 5,387 passed / 0 failed (includes new hook-level
call-count test: cold = 1, stale-prefetched = 1, hydrated = 0 on mount
then 1 on invalidate with wholesale replacement)
- Playwright smoke `relay-reconnect.spec.ts` + `thread-unread.spec.ts` +
`channel-head-restart.spec.ts` — 30/30 (thread-unread was 8/13 at
`7acbf951b`; relay-reconnect was 15/16 at `bcfe04e2f`). The restart spec
persists a head, reloads into a fresh mock relay with the head fetch
held 5s, asserts the persisted row paints within 2s, exactly one
`get_channel_window` after open, and the stale row is removed when the
authoritative page lands.
- `pnpm typecheck`, `pnpm check` — clean

At `7acbf951b` (everything except the two-line `useThreadReplies.ts`
staleTime revert and the test-only `relay-reconnect.spec.ts` change),
also green in one shell:
- `just desktop-tauri-test` — 2,859 passed / 0 failed across the
workspace (channel_head_cache: wire shape, LRU+caps, schema reset,
corrupt-row skip)
- `just test-unit` — 632 passed (buzz-core/auth); `cargo test -p
buzz-relay --lib` — 908 passed / 0 failed
- `just check` components: fmt-check, clippy, desktop-check,
desktop-typecheck, desktop-tauri-fmt-check, desktop-tauri-clippy,
web-check, mobile-check, file-size-check — all green
- `just desktop-build`, `web-build`, `desktop-tauri-check`,
`mobile-test` (1,661 passed) — all green

CI note: the "Unit Tests" job goes red on this PR and on `main` whenever
it hits a poisoned `rust-cache` entry (an empty-directory skeleton of
`target/sherpa-onnx-prebuilt` that `sherpa-onnx-sys` build.rs trusts),
surfacing as `could not find native static library sherpa-onnx-c-api` in
`buzz-voice` — a crate this PR doesn't touch. Deleting the cache entry
and rerunning turned the job green at `0c492366d` (28/28); it re-poisons
on the next `main` push until the workflow clears that directory after
cache restore.

Reviewed in-channel by Wren (9 / 9 / 9.5) and Eva (9 / 9 / 9), and
line-by-line by me before opening; the staleTime fix re-verified by Wren
and me independently; the relay-reconnect test fix bisected and verified
by me.

---------

Signed-off-by: Perci <5a968df9a7494b4e019b9ecf739e088ba61097b4312124e9a88ae5b42e3f5f3e@buzz.block.builderlab.xyz>
Signed-off-by: Max <d8473ee32b973aa31a21a65adddcc4b69cc2a8a4dee8121ecd51926e0cddbc02@buzz.block.builderlab.xyz>
Signed-off-by: Wren <5217c5c2f7bfb4333e46d17c98a9255a52dadee18dcd43a43536b95e6776dfa0@buzz.block.builderlab.xyz>
Signed-off-by: Meli <5aaa86bce934fc3445fc254aab560a40923f10252f92107e665073dede0e04d3@buzz.block.builderlab.xyz>
Co-authored-by: Perci <5a968df9a7494b4e019b9ecf739e088ba61097b4312124e9a88ae5b42e3f5f3e@buzz.block.builderlab.xyz>
Co-authored-by: Max <d8473ee32b973aa31a21a65adddcc4b69cc2a8a4dee8121ecd51926e0cddbc02@buzz.block.builderlab.xyz>
Co-authored-by: Wren <5217c5c2f7bfb4333e46d17c98a9255a52dadee18dcd43a43536b95e6776dfa0@buzz.block.builderlab.xyz>
Co-authored-by: Meli <5aaa86bce934fc3445fc254aab560a40923f10252f92107e665073dede0e04d3@buzz.block.builderlab.xyz>

* fix(desktop): keep member runtime status off the UI thread (#6445)

## Summary
- skip managed-agent runtime discovery when the members sidebar has no
local managed bots
- run runtime listing disk, process, and mutex work on Tauri’s blo…
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.

4 participants