From a64cf789b56f86e542480fa0836622c2b4029ef2 Mon Sep 17 00:00:00 2001 From: kenny lopez Date: Sun, 23 Aug 2026 11:15:45 +0100 Subject: [PATCH 1/5] Fix mobile Huddle agent voice turn states Signed-off-by: kenny lopez --- desktop/src-tauri/src/huddle/playout.rs | 93 +++- .../channels/channel_detail_page.dart | 1 + .../huddle_call_avatar.dart | 67 ++- .../huddle_call_participants.dart | 4 + .../huddle_participant_cluster.dart | 6 + .../channel_detail_page/huddle_sheet.dart | 27 +- .../widgets/bouncing_dots_indicator.dart | 90 ++++ .../channels/channel_detail_page_test.dart | 440 +++++++++++++----- 8 files changed, 567 insertions(+), 161 deletions(-) create mode 100644 mobile/lib/shared/widgets/bouncing_dots_indicator.dart diff --git a/desktop/src-tauri/src/huddle/playout.rs b/desktop/src-tauri/src/huddle/playout.rs index 5bfce3adad5..66c80f5d6c6 100644 --- a/desktop/src-tauri/src/huddle/playout.rs +++ b/desktop/src-tauri/src/huddle/playout.rs @@ -44,6 +44,9 @@ const SPEAKER_LEVEL_TICK_MS: u64 = 50; /// Per-peer arrival window for the TTS interrupt frame counter. const FRAME_WINDOW: std::time::Duration = std::time::Duration::from_millis(500); const REMOTE_RELEASE_DEBOUNCE: std::time::Duration = std::time::Duration::from_millis(500); +/// Match Mobile's speaking treatment: an open microphone can emit continuous +/// non-DTX Opus for room tone, so packet type alone is not evidence of speech. +const REMOTE_SPEECH_LEVEL_DBOV: i8 = -55; /// Playout clock: NetEq emits 10 ms frames, so we tick at 10 ms. const PLAYOUT_TICK_MS: u64 = 10; @@ -86,19 +89,30 @@ fn normalized_speaker_level(level_dbov: i8) -> f32 { ((f32::from(level_dbov) + 60.0) / 48.0).clamp(0.0, 1.0) } +fn is_remote_speech_frame(is_dtx: bool, level_dbov: i8) -> bool { + !is_dtx && level_dbov >= REMOTE_SPEECH_LEVEL_DBOV +} + fn update_remote_release_deadline( peer: u8, - is_dtx: bool, + is_speech: bool, remote_floor_owners: &std::collections::HashSet, deadlines: &mut std::collections::HashMap, now: tokio::time::Instant, ) { - if !is_dtx { - deadlines.remove(&peer); - } else if remote_floor_owners.contains(&peer) { - deadlines - .entry(peer) - .or_insert(now + REMOTE_RELEASE_DEBOUNCE); + if remote_floor_owners.contains(&peer) { + if is_speech { + // Refresh from audible speech itself. Some mobile capture paths + // stop producing packets once speech ends, so waiting for a DTX + // or quiet packet can otherwise hold the human floor forever. + deadlines.insert(peer, now + REMOTE_RELEASE_DEBOUNCE); + } else { + // Preserve the deadline from the last audible frame. Continuous + // room-tone packets must not keep extending the human floor. + deadlines + .entry(peer) + .or_insert(now + REMOTE_RELEASE_DEBOUNCE); + } } } @@ -437,19 +451,20 @@ pub(crate) async fn run_playout_recv_loop( continue; } let is_dtx = (header.flags & FLAG_DTX) != 0; - // Only count non-DTX arrivals toward the UI's - // active-speaker set. DTX/comfort packets are emitted - // by an idle peer to keep the codec alive — they - // don't mean the peer is speaking, and shouldn't - // make their tile flash for the 500 ms speaker tick. + let is_remote_speech = + is_remote_speech_frame(is_dtx, header.level_dbov); + // Only count audible arrivals toward the UI's + // active-speaker set. An open mobile microphone can + // continuously emit non-DTX room tone, so require an + // audible level before treating a packet as speech. update_remote_release_deadline( peer_idx, - is_dtx, + is_remote_speech, &remote_floor_owners, &mut remote_release_deadlines, tokio::time::Instant::now(), ); - if !is_dtx { + if is_remote_speech { active_indices.insert(peer_idx); let level = normalized_speaker_level(header.level_dbov); speaker_levels @@ -497,7 +512,7 @@ pub(crate) async fn run_playout_recv_loop( // Count only remote-human speech toward floor onset. // Agent audio still plays, but it must not acquire the // human floor or suppress another agent's response. - if !is_dtx && remote_human { + if is_remote_speech && remote_human { if last_frame_reset.elapsed() >= FRAME_WINDOW { frame_counts.clear(); last_frame_reset = tokio::time::Instant::now(); @@ -507,6 +522,14 @@ pub(crate) async fn run_playout_recv_loop( if *count >= REMOTE_SPEECH_THRESHOLD { human_floor.enter_remote(peer_idx); remote_floor_owners.insert(peer_idx); + // The threshold-crossing frame is processed + // before this peer becomes an owner. Arm its + // release here so silence need not arrive in a + // later packet to let queued TTS continue. + remote_release_deadlines.insert( + peer_idx, + tokio::time::Instant::now() + REMOTE_RELEASE_DEBOUNCE, + ); if tts_active.load(Ordering::Acquire) { tts_cancel.store(true, Ordering::Release); } @@ -647,18 +670,18 @@ mod tests { use super::*; #[test] - fn continuous_dtx_does_not_extend_remote_floor_deadline() { + fn continuous_silence_does_not_extend_remote_floor_deadline() { let peer = 7; let started = tokio::time::Instant::now(); let owners = std::collections::HashSet::from([peer]); let mut deadlines = std::collections::HashMap::new(); - update_remote_release_deadline(peer, true, &owners, &mut deadlines, started); + update_remote_release_deadline(peer, false, &owners, &mut deadlines, started); let armed = deadlines[&peer]; for elapsed_ms in [100, 200, 300, 400] { update_remote_release_deadline( peer, - true, + false, &owners, &mut deadlines, started + std::time::Duration::from_millis(elapsed_ms), @@ -678,11 +701,32 @@ mod tests { } #[test] - fn dtx_from_non_owner_does_not_arm_remote_floor_deadline() { + fn last_speech_frame_arms_remote_floor_release_without_follow_up_audio() { + let peer = 7; + let started = tokio::time::Instant::now(); + let owners = std::collections::HashSet::from([peer]); + let mut deadlines = std::collections::HashMap::new(); + + update_remote_release_deadline(peer, true, &owners, &mut deadlines, started); + let armed = started + REMOTE_RELEASE_DEBOUNCE; + assert_eq!(deadlines[&peer], armed); + + let human_floor = HumanFloor::new(); + human_floor.enter_remote(peer); + let mut owners = owners; + release_expired_remote_floors(armed, &mut owners, &mut deadlines, &human_floor); + + assert!(!human_floor.is_blocked()); + assert!(owners.is_empty()); + assert!(deadlines.is_empty()); + } + + #[test] + fn silence_from_non_owner_does_not_arm_remote_floor_deadline() { let mut deadlines = std::collections::HashMap::new(); update_remote_release_deadline( 7, - true, + false, &std::collections::HashSet::new(), &mut deadlines, tokio::time::Instant::now(), @@ -690,6 +734,15 @@ mod tests { assert!(deadlines.is_empty()); } + #[test] + fn remote_speech_requires_non_dtx_audio_above_the_activity_floor() { + assert!(!is_remote_speech_frame(true, 0)); + assert!(!is_remote_speech_frame(false, -127)); + assert!(!is_remote_speech_frame(false, -56)); + assert!(is_remote_speech_frame(false, -55)); + assert!(is_remote_speech_frame(false, -12)); + } + #[test] fn speaker_level_maps_conversational_range() { assert_eq!(normalized_speaker_level(-127), 0.0); diff --git a/mobile/lib/features/channels/channel_detail_page.dart b/mobile/lib/features/channels/channel_detail_page.dart index a5493de11b8..d07cf2425b2 100644 --- a/mobile/lib/features/channels/channel_detail_page.dart +++ b/mobile/lib/features/channels/channel_detail_page.dart @@ -19,6 +19,7 @@ import '../../shared/relay/relay.dart'; import '../../shared/theme/theme.dart'; import '../../shared/widgets/avatar_image.dart'; import '../../shared/widgets/buzz_loading_indicator.dart'; +import '../../shared/widgets/bouncing_dots_indicator.dart'; import '../../shared/widgets/concentric_sheet_surface.dart'; import '../../shared/widgets/frosted_app_bar.dart'; import '../../shared/widgets/frosted_scaffold.dart'; diff --git a/mobile/lib/features/channels/channel_detail_page/huddle_call_avatar.dart b/mobile/lib/features/channels/channel_detail_page/huddle_call_avatar.dart index d89ca027e19..120d5719ef7 100644 --- a/mobile/lib/features/channels/channel_detail_page/huddle_call_avatar.dart +++ b/mobile/lib/features/channels/channel_detail_page/huddle_call_avatar.dart @@ -7,6 +7,7 @@ class _HuddleCallAvatar extends HookConsumerWidget { required this.fallbackLabel, required this.active, required this.speakerLevel, + required this.preparingResponse, required this.onTap, this.isSelf = false, this.frameSize = _huddleAvatarFrameSize, @@ -17,6 +18,7 @@ class _HuddleCallAvatar extends HookConsumerWidget { final String? fallbackLabel; final bool active; final double speakerLevel; + final bool preparingResponse; final VoidCallback? onTap; final bool isSelf; final double frameSize; @@ -24,6 +26,14 @@ class _HuddleCallAvatar extends HookConsumerWidget { @override Widget build(BuildContext context, WidgetRef ref) { final reducedMotion = MediaQuery.disableAnimationsOf(context); + final responseHasStarted = useRef(false); + if (!preparingResponse) { + responseHasStarted.value = false; + } else if (active) { + responseHasStarted.value = true; + } + final showPreparingResponse = + preparingResponse && !active && !responseHasStarted.value; final scale = frameSize / _huddleAvatarFrameSize; final avatarRadius = _huddleAvatarRadius * scale; final speakingRingSize = _huddleSpeakingRingSize * scale; @@ -65,10 +75,16 @@ class _HuddleCallAvatar extends HookConsumerWidget { isSelf: isSelf, ); + final semanticStates = [ + label, + if (showPreparingResponse) 'preparing a response', + if (active) 'speaking', + ].join(', '); + return SizedBox( width: frameSize, child: Semantics( - label: active ? '$label, speaking' : label, + label: semanticStates, hint: onTap == null ? null : 'Tap to focus participant', button: onTap != null, onTap: onTap, @@ -121,15 +137,46 @@ class _HuddleCallAvatar extends HookConsumerWidget { ), ), ), - AvatarImage( - imageUrl: profile?.avatarUrl, - radius: avatarRadius, - backgroundColor: context.colors.primaryContainer, - fallback: Icon( - LucideIcons.userRound, - size: fallbackIconSize, - color: context.colors.onPrimaryContainer, - ), + AnimatedSwitcher( + duration: reducedMotion + ? Duration.zero + : const Duration(milliseconds: 180), + switchInCurve: Curves.easeOutCubic, + switchOutCurve: Curves.easeInCubic, + transitionBuilder: (child, animation) => + FadeTransition(opacity: animation, child: child), + child: showPreparingResponse + ? Container( + key: ValueKey( + 'huddle-agent-preparing-response-$pubkey', + ), + width: avatarRadius * 2, + height: avatarRadius * 2, + decoration: BoxDecoration( + shape: BoxShape.circle, + color: context.colors.primaryContainer, + ), + alignment: Alignment.center, + child: BouncingDotsIndicator( + color: context.colors.onPrimaryContainer, + dotSize: 6 * scale, + gap: 4 * scale, + semanticLabel: + '$label is preparing a response', + ), + ) + : AvatarImage( + key: ValueKey('huddle-avatar-image-$pubkey'), + imageUrl: profile?.avatarUrl, + radius: avatarRadius, + backgroundColor: + context.colors.primaryContainer, + fallback: Icon( + LucideIcons.userRound, + size: fallbackIconSize, + color: context.colors.onPrimaryContainer, + ), + ), ), ], ), diff --git a/mobile/lib/features/channels/channel_detail_page/huddle_call_participants.dart b/mobile/lib/features/channels/channel_detail_page/huddle_call_participants.dart index ee2a564b2e3..5e0d7922d0b 100644 --- a/mobile/lib/features/channels/channel_detail_page/huddle_call_participants.dart +++ b/mobile/lib/features/channels/channel_detail_page/huddle_call_participants.dart @@ -10,6 +10,7 @@ class _HuddleCallParticipants extends StatelessWidget { required this.localPubkey, required this.activeSpeakerPubkeys, required this.speakerLevels, + required this.workingAgentPubkeys, required this.retryTooltip, required this.retryIcon, required this.onRetry, @@ -25,6 +26,7 @@ class _HuddleCallParticipants extends StatelessWidget { final String? localPubkey; final Set activeSpeakerPubkeys; final Map speakerLevels; + final Set workingAgentPubkeys; final String retryTooltip; final IconData retryIcon; final VoidCallback onRetry; @@ -126,6 +128,7 @@ class _HuddleCallParticipants extends StatelessWidget { speakerLevel: localPubkey == null ? 0 : speakerLevels[localPubkey] ?? 0, + preparingResponse: false, isSelf: true, onTap: null, ), @@ -145,6 +148,7 @@ class _HuddleCallParticipants extends StatelessWidget { fallbackLabels: fallbackLabels, activeSpeakerPubkeys: activeSpeakerPubkeys, speakerLevels: speakerLevels, + workingAgentPubkeys: workingAgentPubkeys, movementDuration: movementDuration, entryDuration: entryDuration, exitDuration: exitDuration, diff --git a/mobile/lib/features/channels/channel_detail_page/huddle_participant_cluster.dart b/mobile/lib/features/channels/channel_detail_page/huddle_participant_cluster.dart index 7f0f80b17d9..d966d260580 100644 --- a/mobile/lib/features/channels/channel_detail_page/huddle_participant_cluster.dart +++ b/mobile/lib/features/channels/channel_detail_page/huddle_participant_cluster.dart @@ -40,6 +40,7 @@ class _HuddleParticipantCluster extends HookWidget { required this.fallbackLabels, required this.activeSpeakerPubkeys, required this.speakerLevels, + required this.workingAgentPubkeys, required this.movementDuration, required this.entryDuration, required this.exitDuration, @@ -52,6 +53,7 @@ class _HuddleParticipantCluster extends HookWidget { final Map fallbackLabels; final Set activeSpeakerPubkeys; final Map speakerLevels; + final Set workingAgentPubkeys; final Duration movementDuration; final Duration entryDuration; final Duration exitDuration; @@ -139,6 +141,7 @@ class _HuddleParticipantCluster extends HookWidget { fallbackLabel: fallbackLabels[pubkey], active: activeSpeakerPubkeys.contains(pubkey), speakerLevel: speakerLevels[pubkey] ?? 0, + preparingResponse: workingAgentPubkeys.contains(pubkey), slot: lastSlots[pubkey]!, present: visiblePubkeySet.contains(pubkey), movementDuration: movementDuration, @@ -173,6 +176,7 @@ class _HuddleAnimatedParticipant extends StatelessWidget { required this.fallbackLabel, required this.active, required this.speakerLevel, + required this.preparingResponse, required this.slot, required this.present, required this.movementDuration, @@ -186,6 +190,7 @@ class _HuddleAnimatedParticipant extends StatelessWidget { final String? fallbackLabel; final bool active; final double speakerLevel; + final bool preparingResponse; final _HuddleClusterSlot slot; final bool present; final Duration movementDuration; @@ -237,6 +242,7 @@ class _HuddleAnimatedParticipant extends StatelessWidget { fallbackLabel: fallbackLabel, active: active, speakerLevel: speakerLevel, + preparingResponse: preparingResponse, frameSize: frameSize, onTap: onTap, ), diff --git a/mobile/lib/features/channels/channel_detail_page/huddle_sheet.dart b/mobile/lib/features/channels/channel_detail_page/huddle_sheet.dart index 6f1b21339ce..4d02342c2d2 100644 --- a/mobile/lib/features/channels/channel_detail_page/huddle_sheet.dart +++ b/mobile/lib/features/channels/channel_detail_page/huddle_sheet.dart @@ -31,10 +31,12 @@ class _HuddleParticipantProfileUpdates extends Notifier { ), ), ); + final backingMembers = + ref.watch(channelMembersProvider(channelId)).value ?? + const []; final members = session.wasAdmitted - ? const [] - : ref.watch(channelMembersProvider(channelId)).value ?? - const []; + ? backingMembers.where((member) => member.isBot) + : backingMembers; final relayState = ref.watch(relaySessionProvider); final participantPubkeys = _huddleParticipantPubkeys( sessionParticipantPubkeys: session.ephemeralChannelId == channelId @@ -553,7 +555,12 @@ class _MobileHuddleCallPage extends ConsumerWidget { final participantPubkeys = _huddleParticipantPubkeys( sessionParticipantPubkeys: session.participantPubkeys, currentPubkey: localPubkey, - members: session.wasAdmitted ? const [] : backingMembers, + // Audio peers remain authoritative for humans after admission. Agents + // are logical Huddle participants as soon as their bot membership is + // published, before their send-only audio connection starts speaking. + members: session.wasAdmitted + ? backingMembers.where((member) => member.isBot) + : backingMembers, ); final remotePubkeys = participantPubkeys .where((pubkey) => pubkey != localPubkey) @@ -564,6 +571,17 @@ class _MobileHuddleCallPage extends ConsumerWidget { ); final profiles = ref.watch(userCacheProvider); final directoryDisplayNames = ref.watch(agentDirectoryDisplayNamesProvider); + final huddleTypingEntries = ref.watch( + channelTypingProvider(invite.ephemeralChannelId), + ); + final parentAgentPubkeys = ref.watch( + agentMentionPubkeysProvider(invite.parentChannelId), + ); + final workingAgentPubkeys = { + for (final entry in huddleTypingEntries) + if (parentAgentPubkeys.contains(entry.pubkey.toLowerCase())) + entry.pubkey.toLowerCase(), + }; final reactionSenderName = _huddleReactionSenderName( localPubkey: localPubkey, profile: localPubkey == null ? null : profiles[localPubkey], @@ -651,6 +669,7 @@ class _MobileHuddleCallPage extends ConsumerWidget { localPubkey: localPubkey, activeSpeakerPubkeys: session.activeSpeakerPubkeys, speakerLevels: session.speakerLevels, + workingAgentPubkeys: workingAgentPubkeys, retryTooltip: retryTooltip, retryIcon: retryIcon, onRetry: onRetry, diff --git a/mobile/lib/shared/widgets/bouncing_dots_indicator.dart b/mobile/lib/shared/widgets/bouncing_dots_indicator.dart new file mode 100644 index 00000000000..6026c1db0ff --- /dev/null +++ b/mobile/lib/shared/widgets/bouncing_dots_indicator.dart @@ -0,0 +1,90 @@ +import 'dart:math' show pi, sin; + +import 'package:flutter/material.dart'; +import 'package:flutter_hooks/flutter_hooks.dart'; + +/// Three subtly bouncing dots for short, indeterminate waiting states. +class BouncingDotsIndicator extends HookWidget { + /// The color of each dot. + final Color color; + + /// The diameter of each dot. + final double dotSize; + + /// The horizontal space between dots. + final double gap; + + /// The accessibility announcement for this waiting state. + final String semanticLabel; + + /// Creates a three-dot indeterminate activity indicator. + const BouncingDotsIndicator({ + required this.color, + required this.semanticLabel, + this.dotSize = 6, + this.gap = 4, + super.key, + }); + + @override + Widget build(BuildContext context) { + final reducedMotion = MediaQuery.disableAnimationsOf(context); + final animation = useAnimationController( + duration: const Duration(milliseconds: 1000), + ); + + useEffect(() { + if (reducedMotion) { + animation + ..stop() + ..value = 0; + } else { + animation.repeat(); + } + return animation.stop; + }, [animation, reducedMotion]); + + return Semantics( + label: semanticLabel, + liveRegion: true, + child: ExcludeSemantics( + child: AnimatedBuilder( + animation: animation, + builder: (context, _) => Row( + key: const ValueKey('bouncing-dots-indicator'), + mainAxisSize: MainAxisSize.min, + children: [ + for (var index = 0; index < 3; index++) ...[ + if (index > 0) SizedBox(width: gap), + Transform.translate( + key: ValueKey('bouncing-dot-$index'), + offset: Offset( + 0, + reducedMotion + ? 0 + : -dotSize * + 0.32 * + _positiveWave(animation.value, index), + ), + child: DecoratedBox( + decoration: BoxDecoration( + shape: BoxShape.circle, + color: color, + ), + child: SizedBox.square(dimension: dotSize), + ), + ), + ], + ], + ), + ), + ), + ); + } +} + +double _positiveWave(double progress, int index) { + // Match desktop onboarding's 120 ms stagger while keeping the motion soft. + final staggeredProgress = progress - (index * 0.12); + return sin(staggeredProgress * 2 * pi).clamp(0, 1).toDouble(); +} diff --git a/mobile/test/features/channels/channel_detail_page_test.dart b/mobile/test/features/channels/channel_detail_page_test.dart index 3aabad062ec..ae3da6aae88 100644 --- a/mobile/test/features/channels/channel_detail_page_test.dart +++ b/mobile/test/features/channels/channel_detail_page_test.dart @@ -220,6 +220,7 @@ Widget _buildTestable({ ReadStateNotifier? readStateNotifier, _FakeMessagesNotifier? messagesNotifier, _FakeTypingNotifier? typingNotifier, + _FakeTypingNotifier? huddleTypingNotifier, String? canvasContent, String? initialMessageId, String? initialThreadRootId, @@ -259,6 +260,11 @@ Widget _buildTestable({ channelTypingProvider( _channelId, ).overrideWith(() => typingNotifier ?? _FakeTypingNotifier(typing)), + channelTypingProvider(_huddleChannelId).overrideWith( + () => + huddleTypingNotifier ?? + _FakeTypingNotifier(const [], channelId: _huddleChannelId), + ), userCacheProvider.overrideWith( () => userCacheNotifier ?? _FakeUserCacheNotifier(users), ), @@ -5622,6 +5628,123 @@ void main() { ); }); + testWidgets('shows response dots only until the agent starts speaking', ( + tester, + ) async { + final now = DateTime.now().millisecondsSinceEpoch ~/ 1000; + final typing = _FakeTypingNotifier([ + TypingEntry( + pubkey: 'agent', + expiresAtMs: DateTime.now().millisecondsSinceEpoch + 8000, + ), + ], channelId: _huddleChannelId); + final transport = _HuddleTestTransport( + peers: const { + 1: HuddlePeer(pubkey: 'self', peerIndex: 1, epoch: 0), + 2: HuddlePeer(pubkey: 'agent', peerIndex: 2, epoch: 0), + }, + ); + + await tester.pumpWidget( + _buildTestable( + messages: [ + _huddleMsg( + id: 'working-agent-call', + kind: EventKind.huddleStarted, + pubkey: 'self', + createdAt: now, + ), + ], + users: const { + 'agent': UserProfile(pubkey: 'agent', displayName: 'Pollen'), + 'self': UserProfile(pubkey: 'self', displayName: 'Self'), + }, + members: [ + ChannelMember( + pubkey: 'agent', + role: 'bot', + joinedAt: DateTime(2025), + ), + ], + loadChannelBotPubkeys: () async => const {'agent'}, + huddleTypingNotifier: typing, + relayConfigNotifier: _HuddleRelayConfigNotifier(), + huddleCurrentPubkey: 'self', + huddleMediaFactory: _HuddleTestMedia.new, + huddleTransportFactory: (_) => transport, + ), + ); + await tester.pumpAndSettle(); + + await tester.tap(find.widgetWithText(FilledButton, 'Join')); + await tester.pump(); + await tester.pump(const Duration(milliseconds: 500)); + await tester.pump(const Duration(milliseconds: 200)); + + expect( + find.byKey(const ValueKey('huddle-agent-preparing-response-agent')), + findsOneWidget, + ); + expect( + find.byKey(const ValueKey('huddle-avatar-image-agent')), + findsNothing, + ); + expect( + find.bySemanticsLabel('Pollen, preparing a response'), + findsOneWidget, + ); + final dotFinder = find.byKey(const ValueKey('bouncing-dot-2')); + final initialDotOffset = tester + .widget(dotFinder) + .transform + .getTranslation() + .y; + await tester.pump(const Duration(milliseconds: 120)); + expect( + tester.widget(dotFinder).transform.getTranslation().y, + isNot(initialDotOffset), + ); + + transport.emitRemoteAudio(peerIndex: 2); + await tester.pump(); + await tester.pump(const Duration(milliseconds: 200)); + + expect( + find.byKey(const ValueKey('huddle-agent-preparing-response-agent')), + findsNothing, + ); + expect( + find.byKey(const ValueKey('huddle-avatar-image-agent')), + findsOneWidget, + ); + expect(find.bySemanticsLabel('Pollen, speaking'), findsOneWidget); + + // A brief audio gap must not revive the waiting state while the same + // working signal is still active. + await tester.pump(const Duration(milliseconds: 650)); + expect( + find.byKey(const ValueKey('huddle-agent-preparing-response-agent')), + findsNothing, + ); + expect( + find.byKey(const ValueKey('huddle-avatar-image-agent')), + findsOneWidget, + ); + + typing.setEntries(const []); + await tester.pump(); + await tester.pump(const Duration(milliseconds: 200)); + + expect( + find.byKey(const ValueKey('huddle-agent-preparing-response-agent')), + findsNothing, + ); + expect( + find.byKey(const ValueKey('huddle-avatar-image-agent')), + findsOneWidget, + ); + }); + testWidgets( 'opens the sparse full-screen call with avatar and audio controls', (tester) async { @@ -6530,138 +6653,197 @@ void main() { }, ); - testWidgets('springs admitted participants in and out', (tester) async { - const addedMemberPubkey = - 'aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa'; - final now = DateTime.now().millisecondsSinceEpoch ~/ 1000; - final membersNotifier = _MutableHuddleMembersNotifier(const []); - final transport = _HuddleTestTransport(); + testWidgets( + 'shows admitted agents from membership before their audio peer joins', + (tester) async { + const addedMemberPubkey = + 'aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa'; + final now = DateTime.now().millisecondsSinceEpoch ~/ 1000; + final membersNotifier = _MutableHuddleMembersNotifier(const []); + final transport = _HuddleTestTransport(); - await tester.pumpWidget( - _buildTestable( - messages: [ - _huddleMsg( - id: 'authoritative-live-roster', - kind: EventKind.huddleStarted, - pubkey: 'self', - createdAt: now, - ), - ], - users: const { - 'desktop': UserProfile(pubkey: 'desktop', displayName: 'Miles'), - 'self': UserProfile(pubkey: 'self', displayName: 'Self'), - addedMemberPubkey: UserProfile( - pubkey: addedMemberPubkey, - displayName: 'Added member', - ), - }, - huddleMembersNotifier: membersNotifier, - relayConfigNotifier: _HuddleRelayConfigNotifier(), - relaySessionNotifier: _ReconnectingRelaySession(), - huddleCurrentPubkey: 'self', - huddleMediaFactory: _HuddleTestMedia.new, - huddleTransportFactory: (_) => transport, - ), - ); - await tester.pumpAndSettle(); - await tester.tap(find.widgetWithText(FilledButton, 'Join')); - await tester.pumpAndSettle(); + await tester.pumpWidget( + _buildTestable( + messages: [ + _huddleMsg( + id: 'authoritative-live-roster', + kind: EventKind.huddleStarted, + pubkey: 'self', + createdAt: now, + ), + ], + users: const { + 'desktop': UserProfile(pubkey: 'desktop', displayName: 'Miles'), + 'self': UserProfile(pubkey: 'self', displayName: 'Self'), + addedMemberPubkey: UserProfile( + pubkey: addedMemberPubkey, + displayName: 'Added member', + ), + }, + huddleMembersNotifier: membersNotifier, + relayConfigNotifier: _HuddleRelayConfigNotifier(), + relaySessionNotifier: _ReconnectingRelaySession(), + huddleCurrentPubkey: 'self', + huddleMediaFactory: _HuddleTestMedia.new, + huddleTransportFactory: (_) => transport, + ), + ); + await tester.pumpAndSettle(); + await tester.tap(find.widgetWithText(FilledButton, 'Join')); + await tester.pumpAndSettle(); - final desktopAvatar = find.byKey( - const ValueKey('huddle-speaking-ring-desktop'), - ); - final initialDesktopCenter = tester.getCenter(desktopAvatar); + final desktopAvatar = find.byKey( + const ValueKey('huddle-speaking-ring-desktop'), + ); + final initialDesktopCenter = tester.getCenter(desktopAvatar); - membersNotifier.replace([ - ChannelMember( - pubkey: addedMemberPubkey, - role: 'member', - joinedAt: DateTime(2025), - ), - ]); - await tester.pump(); - await tester.pump(); - expect( - find.byKey( - const ValueKey('huddle-participant-avatar-$addedMemberPubkey'), - ), - findsNothing, - ); + membersNotifier.replace([ + ChannelMember( + pubkey: addedMemberPubkey, + role: 'bot', + joinedAt: DateTime(2025), + ), + ]); + await tester.pump(); + await tester.pump(const Duration(milliseconds: 100)); + expect( + find.byKey( + const ValueKey('huddle-participant-avatar-$addedMemberPubkey'), + ), + findsOneWidget, + ); - transport.emitPeerJoin( - const HuddlePeer(pubkey: addedMemberPubkey, peerIndex: 3, epoch: 0), - ); - await tester.pump(); + final addedMemberScale = find.byKey( + const ValueKey('huddle-participant-entry-scale-$addedMemberPubkey'), + ); + expect( + tester.widget(addedMemberScale).transform.storage[0], + closeTo(0.72, 0.01), + ); + await tester.pump(const Duration(milliseconds: 120)); + final movingDesktopCenter = tester.getCenter(desktopAvatar); + expect( + tester.widget(addedMemberScale).transform.storage[0], + greaterThan(0.72), + ); + expect( + (movingDesktopCenter - initialDesktopCenter).distance, + greaterThan(1), + ); + expect( + find.byKey( + const ValueKey('huddle-participant-avatar-$addedMemberPubkey'), + ), + findsOneWidget, + ); + expect( + find.byKey(const ValueKey('huddle-participant-avatar-desktop')), + findsOneWidget, + ); - final addedMemberScale = find.byKey( - const ValueKey('huddle-participant-entry-scale-$addedMemberPubkey'), - ); - expect( - tester.widget(addedMemberScale).transform.storage[0], - closeTo(0.72, 0.01), - ); - await tester.pump(const Duration(milliseconds: 120)); - final movingDesktopCenter = tester.getCenter(desktopAvatar); - expect( - tester.widget(addedMemberScale).transform.storage[0], - greaterThan(0.72), - ); - expect( - (movingDesktopCenter - initialDesktopCenter).distance, - greaterThan(1), - ); - expect( - find.byKey( - const ValueKey('huddle-participant-avatar-$addedMemberPubkey'), - ), - findsOneWidget, - ); - expect( - find.byKey(const ValueKey('huddle-participant-avatar-desktop')), - findsOneWidget, - ); + await tester.pumpAndSettle(); + final settledDesktopCenter = tester.getCenter(desktopAvatar); + expect( + (settledDesktopCenter - movingDesktopCenter).distance, + greaterThan(1), + ); + expect( + (settledDesktopCenter - initialDesktopCenter).distance, + greaterThan(1), + ); + expect( + tester.widget(addedMemberScale).transform.storage[0], + closeTo(1, 0.01), + ); - await tester.pumpAndSettle(); - final settledDesktopCenter = tester.getCenter(desktopAvatar); - expect( - (settledDesktopCenter - movingDesktopCenter).distance, - greaterThan(1), - ); - expect( - (settledDesktopCenter - initialDesktopCenter).distance, - greaterThan(1), - ); - expect( - tester.widget(addedMemberScale).transform.storage[0], - closeTo(1, 0.01), - ); + // The avatar reflects logical membership, so an audio connection ending + // does not remove the agent. Removing its membership does. + transport.emitPeerJoin( + const HuddlePeer(pubkey: addedMemberPubkey, peerIndex: 3, epoch: 0), + ); + await tester.pump(); + transport.emitPeerLeave(3); + await tester.pump(); + expect( + find.byKey( + const ValueKey('huddle-participant-avatar-$addedMemberPubkey'), + ), + findsOneWidget, + ); + await tester.pumpAndSettle(); + expect( + find.byKey( + const ValueKey('huddle-participant-avatar-$addedMemberPubkey'), + ), + findsOneWidget, + ); - transport.emitPeerLeave(3); - await tester.pump(); - expect( - find.byKey( - const ValueKey('huddle-participant-avatar-$addedMemberPubkey'), - ), - findsOneWidget, - ); - await tester.pump(const Duration(milliseconds: 54)); - expect( - tester.widget(addedMemberScale).transform.storage[0], - greaterThan(1.05), - ); - await tester.pump(const Duration(milliseconds: 140)); - expect( - tester.widget(addedMemberScale).transform.storage[0], - lessThan(1), - ); - await tester.pumpAndSettle(); - expect( - find.byKey( - const ValueKey('huddle-participant-avatar-$addedMemberPubkey'), - ), - findsNothing, - ); - }); + membersNotifier.replace(const []); + await tester.pumpAndSettle(); + expect( + find.byKey( + const ValueKey('huddle-participant-avatar-$addedMemberPubkey'), + ), + findsNothing, + ); + }, + ); + + testWidgets( + 'does not show admitted human membership without an audio peer', + (tester) async { + const invitedHumanPubkey = + 'bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb'; + final now = DateTime.now().millisecondsSinceEpoch ~/ 1000; + final membersNotifier = _MutableHuddleMembersNotifier(const []); + + await tester.pumpWidget( + _buildTestable( + messages: [ + _huddleMsg( + id: 'human-membership-is-not-audio-presence', + kind: EventKind.huddleStarted, + pubkey: 'self', + createdAt: now, + ), + ], + users: const { + 'desktop': UserProfile(pubkey: 'desktop', displayName: 'Desktop'), + 'self': UserProfile(pubkey: 'self', displayName: 'Self'), + invitedHumanPubkey: UserProfile( + pubkey: invitedHumanPubkey, + displayName: 'Invited human', + ), + }, + huddleMembersNotifier: membersNotifier, + relayConfigNotifier: _HuddleRelayConfigNotifier(), + huddleCurrentPubkey: 'self', + huddleMediaFactory: _HuddleTestMedia.new, + huddleTransportFactory: (_) => _HuddleTestTransport(), + ), + ); + await tester.pumpAndSettle(); + await tester.tap(find.widgetWithText(FilledButton, 'Join')); + await tester.pumpAndSettle(); + + membersNotifier.replace([ + ChannelMember( + pubkey: invitedHumanPubkey, + role: 'member', + joinedAt: DateTime(2025), + ), + ]); + await tester.pump(); + await tester.pump(const Duration(milliseconds: 100)); + + expect( + find.byKey( + const ValueKey('huddle-participant-avatar-$invitedHumanPubkey'), + ), + findsNothing, + ); + }, + ); testWidgets('top-right call end leaves audio and the backing channel', ( tester, @@ -13861,10 +14043,14 @@ final class _HuddleTestTransport implements HuddleTransportClient { ); } - void emitRemoteAudio({int levelDbov = -30, int sequence = 1}) { + void emitRemoteAudio({ + int peerIndex = 1, + int levelDbov = -30, + int sequence = 1, + }) { _remoteFrames.add( HuddleRemoteAudioFrame( - peerIndex: 1, + peerIndex: peerIndex, epoch: 0, header: HuddleAudioHeader( sequence: sequence, From 34b028c4f97d4cb1106ef2e56d8281017bcf20d6 Mon Sep 17 00:00:00 2001 From: kenny lopez Date: Sun, 23 Aug 2026 15:58:24 +0100 Subject: [PATCH 2/5] Address Huddle agent review feedback Signed-off-by: kenny lopez --- .../huddle_call_avatar.dart | 31 +++++- .../huddle_participant_overlay.dart | 29 +++-- .../channel_detail_page/huddle_sheet.dart | 65 +++++------ .../channels/channel_detail_page_test.dart | 105 +++++++++++++++++- 4 files changed, 177 insertions(+), 53 deletions(-) diff --git a/mobile/lib/features/channels/channel_detail_page/huddle_call_avatar.dart b/mobile/lib/features/channels/channel_detail_page/huddle_call_avatar.dart index 120d5719ef7..4a523ebd981 100644 --- a/mobile/lib/features/channels/channel_detail_page/huddle_call_avatar.dart +++ b/mobile/lib/features/channels/channel_detail_page/huddle_call_avatar.dart @@ -1,5 +1,7 @@ part of '../channel_detail_page.dart'; +const _huddleAgentResponseResetDelay = Duration(milliseconds: 1200); + class _HuddleCallAvatar extends HookConsumerWidget { const _HuddleCallAvatar({ required this.pubkey, @@ -26,12 +28,29 @@ class _HuddleCallAvatar extends HookConsumerWidget { @override Widget build(BuildContext context, WidgetRef ref) { final reducedMotion = MediaQuery.disableAnimationsOf(context); - final responseHasStarted = useRef(false); - if (!preparingResponse) { - responseHasStarted.value = false; - } else if (active) { - responseHasStarted.value = true; - } + final responseHasStarted = useState(false); + final responseResetTimer = useRef(null); + useEffect(() { + if (active) { + responseResetTimer.value?.cancel(); + responseResetTimer.value = null; + responseHasStarted.value = true; + } else if (preparingResponse) { + responseResetTimer.value?.cancel(); + responseResetTimer.value = null; + } else if (responseHasStarted.value && responseResetTimer.value == null) { + responseResetTimer.value = Timer(_huddleAgentResponseResetDelay, () { + responseResetTimer.value = null; + responseHasStarted.value = false; + }); + } + return null; + }, [active, preparingResponse, responseHasStarted.value]); + useEffect( + () => + () => responseResetTimer.value?.cancel(), + const [], + ); final showPreparingResponse = preparingResponse && !active && !responseHasStarted.value; final scale = frameSize / _huddleAvatarFrameSize; diff --git a/mobile/lib/features/channels/channel_detail_page/huddle_participant_overlay.dart b/mobile/lib/features/channels/channel_detail_page/huddle_participant_overlay.dart index 247d1168306..215009365f4 100644 --- a/mobile/lib/features/channels/channel_detail_page/huddle_participant_overlay.dart +++ b/mobile/lib/features/channels/channel_detail_page/huddle_participant_overlay.dart @@ -5,6 +5,7 @@ const _huddleParticipantSpotlightRadius = 68.0; void _showHuddleParticipantSpotlight({ required BuildContext context, + required String ephemeralChannelId, required String pubkey, required bool isSelf, }) { @@ -12,6 +13,7 @@ void _showHuddleParticipantSpotlight({ context: context, barrierLabel: 'Dismiss participant', childBuilder: (dialogContext) => _HuddleParticipantSpotlight( + ephemeralChannelId: ephemeralChannelId, pubkey: pubkey, isSelf: isSelf, onDismiss: () => Navigator.of(dialogContext).pop(), @@ -19,11 +21,15 @@ void _showHuddleParticipantSpotlight({ ); } -void _showHuddleParticipantRoster({required BuildContext context}) { +void _showHuddleParticipantRoster({ + required BuildContext context, + required String ephemeralChannelId, +}) { _showHuddleParticipantOverlay( context: context, barrierLabel: 'Dismiss participant list', - childBuilder: (_) => const _HuddleParticipantRoster(), + childBuilder: (_) => + _HuddleParticipantRoster(ephemeralChannelId: ephemeralChannelId), ); } @@ -123,22 +129,24 @@ class _HuddleParticipantOverlay extends StatelessWidget { class _HuddleParticipantSpotlight extends ConsumerWidget { const _HuddleParticipantSpotlight({ + required this.ephemeralChannelId, required this.pubkey, required this.isSelf, required this.onDismiss, }); + final String ephemeralChannelId; final String pubkey; final bool isSelf; final VoidCallback onDismiss; @override Widget build(BuildContext context, WidgetRef ref) { - final participantPresent = ref.watch( - huddleSessionProvider.select( - (session) => isSelf || session.participantPubkeys.contains(pubkey), - ), - ); + final participantPresent = + isSelf || + ref + .watch(_huddleLogicalParticipantPubkeysProvider(ephemeralChannelId)) + .contains(pubkey); if (!participantPresent) { WidgetsBinding.instance.addPostFrameCallback((_) { if (context.mounted) onDismiss(); @@ -233,13 +241,16 @@ class _HuddleParticipantSpotlight extends ConsumerWidget { } class _HuddleParticipantRoster extends ConsumerWidget { - const _HuddleParticipantRoster(); + const _HuddleParticipantRoster({required this.ephemeralChannelId}); + + final String ephemeralChannelId; @override Widget build(BuildContext context, WidgetRef ref) { final session = ref.watch(huddleSessionProvider); final localPubkey = session.currentPubkey?.toLowerCase(); - final pubkeys = session.participantPubkeys + final pubkeys = ref + .watch(_huddleLogicalParticipantPubkeysProvider(ephemeralChannelId)) .where((pubkey) => pubkey != localPubkey) .skip(_huddleClusterVisibleParticipantCount) .toList(growable: false); diff --git a/mobile/lib/features/channels/channel_detail_page/huddle_sheet.dart b/mobile/lib/features/channels/channel_detail_page/huddle_sheet.dart index 4d02342c2d2..3c1802cd85a 100644 --- a/mobile/lib/features/channels/channel_detail_page/huddle_sheet.dart +++ b/mobile/lib/features/channels/channel_detail_page/huddle_sheet.dart @@ -12,6 +12,23 @@ final _huddleParticipantProfileUpdatesProvider = NotifierProvider.autoDispose _HuddleParticipantProfileUpdates.new, ); +final _huddleLogicalParticipantPubkeysProvider = Provider.autoDispose + .family, String>((ref, channelId) { + final session = ref.watch(huddleSessionProvider); + final backingMembers = + ref.watch(channelMembersProvider(channelId)).value ?? + const []; + return _huddleParticipantPubkeys( + sessionParticipantPubkeys: session.ephemeralChannelId == channelId + ? session.participantPubkeys + : const [], + currentPubkey: session.currentPubkey, + members: session.wasAdmitted + ? backingMembers.where((member) => member.isBot) + : backingMembers, + ); + }); + class _HuddleParticipantProfileUpdates extends Notifier { _HuddleParticipantProfileUpdates(this.channelId); @@ -21,29 +38,9 @@ class _HuddleParticipantProfileUpdates extends Notifier { @override int build() { - final session = ref.watch( - huddleSessionProvider.select( - (state) => ( - ephemeralChannelId: state.ephemeralChannelId, - currentPubkey: state.currentPubkey, - participantPubkeys: state.participantPubkeys, - wasAdmitted: state.wasAdmitted, - ), - ), - ); - final backingMembers = - ref.watch(channelMembersProvider(channelId)).value ?? - const []; - final members = session.wasAdmitted - ? backingMembers.where((member) => member.isBot) - : backingMembers; final relayState = ref.watch(relaySessionProvider); - final participantPubkeys = _huddleParticipantPubkeys( - sessionParticipantPubkeys: session.ephemeralChannelId == channelId - ? session.participantPubkeys - : const [], - currentPubkey: session.currentPubkey, - members: members, + final participantPubkeys = ref.watch( + _huddleLogicalParticipantPubkeysProvider(channelId), ); final subscriptionVersion = ++_subscriptionVersion; _clearSubscription(); @@ -549,18 +546,11 @@ class _MobileHuddleCallPage extends ConsumerWidget { !unavailable && session.microphonePermissionRequired; final localPubkey = session.currentPubkey?.toLowerCase(); - final backingMembers = - ref.watch(channelMembersProvider(invite.ephemeralChannelId)).value ?? - const []; - final participantPubkeys = _huddleParticipantPubkeys( - sessionParticipantPubkeys: session.participantPubkeys, - currentPubkey: localPubkey, - // Audio peers remain authoritative for humans after admission. Agents - // are logical Huddle participants as soon as their bot membership is - // published, before their send-only audio connection starts speaking. - members: session.wasAdmitted - ? backingMembers.where((member) => member.isBot) - : backingMembers, + // Audio peers remain authoritative for humans after admission. Agents are + // logical Huddle participants as soon as their bot membership is published, + // before their send-only audio connection starts speaking. + final participantPubkeys = ref.watch( + _huddleLogicalParticipantPubkeysProvider(invite.ephemeralChannelId), ); final remotePubkeys = participantPubkeys .where((pubkey) => pubkey != localPubkey) @@ -677,12 +667,15 @@ class _MobileHuddleCallPage extends ConsumerWidget { final isSelf = pubkey == localPubkey || pubkey.isEmpty; _showHuddleParticipantSpotlight( context: context, + ephemeralChannelId: invite.ephemeralChannelId, pubkey: pubkey, isSelf: isSelf, ); }, - onOverflowTap: () => - _showHuddleParticipantRoster(context: context), + onOverflowTap: () => _showHuddleParticipantRoster( + context: context, + ephemeralChannelId: invite.ephemeralChannelId, + ), ), ), if (connected) diff --git a/mobile/test/features/channels/channel_detail_page_test.dart b/mobile/test/features/channels/channel_detail_page_test.dart index ae3da6aae88..295447540e8 100644 --- a/mobile/test/features/channels/channel_detail_page_test.dart +++ b/mobile/test/features/channels/channel_detail_page_test.dart @@ -5745,6 +5745,69 @@ void main() { ); }); + testWidgets('does not revive response dots when typing follows audio', ( + tester, + ) async { + final now = DateTime.now().millisecondsSinceEpoch ~/ 1000; + final typing = _FakeTypingNotifier(const [], channelId: _huddleChannelId); + final transport = _HuddleTestTransport( + peers: const { + 1: HuddlePeer(pubkey: 'self', peerIndex: 1, epoch: 0), + 2: HuddlePeer(pubkey: 'agent', peerIndex: 2, epoch: 0), + }, + ); + + await tester.pumpWidget( + _buildTestable( + messages: [ + _huddleMsg( + id: 'audio-before-working-signal', + kind: EventKind.huddleStarted, + pubkey: 'self', + createdAt: now, + ), + ], + users: const { + 'agent': UserProfile(pubkey: 'agent', displayName: 'Pollen'), + 'self': UserProfile(pubkey: 'self', displayName: 'Self'), + }, + huddleTypingNotifier: typing, + relayConfigNotifier: _HuddleRelayConfigNotifier(), + huddleCurrentPubkey: 'self', + huddleMediaFactory: _HuddleTestMedia.new, + huddleTransportFactory: (_) => transport, + ), + ); + await tester.pumpAndSettle(); + await tester.tap(find.widgetWithText(FilledButton, 'Join')); + await tester.pumpAndSettle(); + + transport.emitRemoteAudio(peerIndex: 2); + await tester.pump(); + expect(find.bySemanticsLabel('Pollen, speaking'), findsOneWidget); + + // Let the active-speaker window close before the independently + // transported typing signal arrives. + await tester.pump(const Duration(milliseconds: 650)); + typing.setEntries([ + TypingEntry( + pubkey: 'agent', + expiresAtMs: DateTime.now().millisecondsSinceEpoch + 8000, + ), + ]); + await tester.pump(); + await tester.pump(const Duration(milliseconds: 200)); + + expect( + find.byKey(const ValueKey('huddle-agent-preparing-response-agent')), + findsNothing, + ); + expect( + find.byKey(const ValueKey('huddle-avatar-image-agent')), + findsOneWidget, + ); + }); + testWidgets( 'opens the sparse full-screen call with avatar and audio controls', (tester) async { @@ -6383,6 +6446,7 @@ void main() { testWidgets( 'caps a dense Huddle roster at ten avatars with an overflow count', (tester) async { + const membershipAgentPubkey = 'membership-agent'; tester.view.physicalSize = const Size(390, 844); tester.view.devicePixelRatio = 1; addTearDown(tester.view.resetPhysicalSize); @@ -6416,6 +6480,10 @@ void main() { ], users: { 'self': const UserProfile(pubkey: 'self', displayName: 'Self'), + membershipAgentPubkey: const UserProfile( + pubkey: membershipAgentPubkey, + displayName: 'Membership agent', + ), for (final pubkey in remotePubkeys) pubkey: UserProfile(pubkey: pubkey, displayName: pubkey), }, @@ -6426,6 +6494,11 @@ void main() { role: 'member', joinedAt: DateTime(2025), ), + ChannelMember( + pubkey: membershipAgentPubkey, + role: 'bot', + joinedAt: DateTime(2025), + ), ], relayConfigNotifier: _HuddleRelayConfigNotifier(), huddleCurrentPubkey: 'self', @@ -6468,7 +6541,7 @@ void main() { find.byKey(const ValueKey('huddle-participant-overflow')), findsOneWidget, ); - expect(find.text('+14'), findsOneWidget); + expect(find.text('+15'), findsOneWidget); await tester.tap( find.byKey(const ValueKey('huddle-participant-overflow')), @@ -6479,7 +6552,7 @@ void main() { find.byKey(const ValueKey('huddle-participant-roster')), findsOneWidget, ); - expect(find.text('14 more people'), findsOneWidget); + expect(find.text('15 more people'), findsOneWidget); expect( find.byKey(const ValueKey('huddle-participant-roster-row-guest-10')), findsOneWidget, @@ -6500,6 +6573,20 @@ void main() { find.byKey(const ValueKey('huddle-participant-roster-row-guest-23')), findsOneWidget, ); + final membershipAgentRow = find.byKey( + const ValueKey( + 'huddle-participant-roster-row-$membershipAgentPubkey', + ), + ); + await tester.scrollUntilVisible( + membershipAgentRow, + 200, + scrollable: find.descendant( + of: find.byKey(const ValueKey('huddle-participant-roster-list')), + matching: find.byType(Scrollable), + ), + ); + expect(membershipAgentRow, findsOneWidget); await tester.tapAt(const Offset(8, 8)); await tester.pumpAndSettle(); expect( @@ -6741,6 +6828,20 @@ void main() { findsOneWidget, ); + await tester.tap( + find.byKey( + const ValueKey('huddle-participant-avatar-$addedMemberPubkey'), + ), + ); + await tester.pumpAndSettle(); + expect( + find.byKey(const ValueKey('huddle-participant-spotlight')), + findsOneWidget, + ); + expect(find.text('Added member'), findsOneWidget); + await tester.tapAt(const Offset(8, 8)); + await tester.pumpAndSettle(); + await tester.pumpAndSettle(); final settledDesktopCenter = tester.getCenter(desktopAvatar); expect( From 4a8df4a3166630c0b2fc30ebb9493df9f21ed87c Mon Sep 17 00:00:00 2001 From: kenny lopez Date: Mon, 24 Aug 2026 18:37:24 +0100 Subject: [PATCH 3/5] Classify Huddle-only bots as working agents and announce preparing state P1: workingAgentPubkeys only admitted typing authors classified as parent-channel agents, but native Huddle enrollment requires ephemeral bot membership while parent enrollment is best-effort. A valid Huddle-only bot rendered in the roster but never showed the preparing animation. Union authoritative ephemeral Huddle bot membership with the parent/known-agent source when classifying typing authors. P2: the outer _HuddleCallAvatar Semantics uses excludeSemantics, which suppressed the child BouncingDotsIndicator's live region, so the preparing transition was not announced to assistive tech. Mark the outer node a live region while preparing. Adds regression coverage for the Huddle-only-bot preparing case and an effective-live-region assertion on the preparing transition. Co-authored-by: Mongo <9cfd347903944d5b85aa6c93d2ab67381b978a92a31914bca69998968752a1d7@buzz.block.builderlab.xyz> Signed-off-by: kenny lopez --- .../huddle_call_avatar.dart | 4 + .../channel_detail_page/huddle_sheet.dart | 14 +++- .../channels/channel_detail_page_test.dart | 84 +++++++++++++++++++ 3 files changed, 101 insertions(+), 1 deletion(-) diff --git a/mobile/lib/features/channels/channel_detail_page/huddle_call_avatar.dart b/mobile/lib/features/channels/channel_detail_page/huddle_call_avatar.dart index 4a523ebd981..d5781e10675 100644 --- a/mobile/lib/features/channels/channel_detail_page/huddle_call_avatar.dart +++ b/mobile/lib/features/channels/channel_detail_page/huddle_call_avatar.dart @@ -106,6 +106,10 @@ class _HuddleCallAvatar extends HookConsumerWidget { label: semanticStates, hint: onTap == null ? null : 'Tap to focus participant', button: onTap != null, + // The outer node excludes descendant semantics, so the child + // indicator's live region never reaches assistive tech. Promote this + // node to a live region while preparing so the label change announces. + liveRegion: showPreparingResponse, onTap: onTap, excludeSemantics: true, child: GestureDetector( diff --git a/mobile/lib/features/channels/channel_detail_page/huddle_sheet.dart b/mobile/lib/features/channels/channel_detail_page/huddle_sheet.dart index 3c1802cd85a..0371f31be68 100644 --- a/mobile/lib/features/channels/channel_detail_page/huddle_sheet.dart +++ b/mobile/lib/features/channels/channel_detail_page/huddle_sheet.dart @@ -567,9 +567,21 @@ class _MobileHuddleCallPage extends ConsumerWidget { final parentAgentPubkeys = ref.watch( agentMentionPubkeysProvider(invite.parentChannelId), ); + // Ephemeral Huddle bot membership is authoritative for native enrollment; + // parent classification is only best-effort and may miss a valid Huddle + // bot. Union both so a Huddle-only agent still enters the preparing state. + final huddleBotPubkeys = { + for (final member + in ref + .watch(channelMembersProvider(invite.ephemeralChannelId)) + .value ?? + const []) + if (member.isBot) member.pubkey.trim().toLowerCase(), + }; final workingAgentPubkeys = { for (final entry in huddleTypingEntries) - if (parentAgentPubkeys.contains(entry.pubkey.toLowerCase())) + if (parentAgentPubkeys.contains(entry.pubkey.toLowerCase()) || + huddleBotPubkeys.contains(entry.pubkey.toLowerCase())) entry.pubkey.toLowerCase(), }; final reactionSenderName = _huddleReactionSenderName( diff --git a/mobile/test/features/channels/channel_detail_page_test.dart b/mobile/test/features/channels/channel_detail_page_test.dart index 295447540e8..aa671cf277a 100644 --- a/mobile/test/features/channels/channel_detail_page_test.dart +++ b/mobile/test/features/channels/channel_detail_page_test.dart @@ -5693,6 +5693,17 @@ void main() { find.bySemanticsLabel('Pollen, preparing a response'), findsOneWidget, ); + // The preparing transition must be announced live. The outer avatar node + // excludes descendant semantics, so it must itself become a live region. + expect( + tester + .getSemantics( + find.byKey(const ValueKey('huddle-participant-avatar-agent')), + ) + .flagsCollection + .isLiveRegion, + isTrue, + ); final dotFinder = find.byKey(const ValueKey('bouncing-dot-2')); final initialDotOffset = tester .widget(dotFinder) @@ -5745,6 +5756,79 @@ void main() { ); }); + testWidgets( + 'shows preparing indicator for a Huddle-only bot without a parent role', + (tester) async { + // Regression: a valid ephemeral Huddle bot with no parent bot role and + // no directory identity must still enter the preparing state from its + // Huddle typing. Classification must derive from authoritative + // ephemeral Huddle bot membership, not parent-channel classification. + final now = DateTime.now().millisecondsSinceEpoch ~/ 1000; + final typing = _FakeTypingNotifier([ + TypingEntry( + pubkey: 'agent', + expiresAtMs: DateTime.now().millisecondsSinceEpoch + 8000, + ), + ], channelId: _huddleChannelId); + final transport = _HuddleTestTransport( + peers: const { + 1: HuddlePeer(pubkey: 'self', peerIndex: 1, epoch: 0), + 2: HuddlePeer(pubkey: 'agent', peerIndex: 2, epoch: 0), + }, + ); + + await tester.pumpWidget( + _buildTestable( + messages: [ + _huddleMsg( + id: 'huddle-only-working-agent-call', + kind: EventKind.huddleStarted, + pubkey: 'self', + createdAt: now, + ), + ], + users: const { + 'agent': UserProfile(pubkey: 'agent', displayName: 'Pollen'), + 'self': UserProfile(pubkey: 'self', displayName: 'Self'), + }, + // Bot membership is supplied ONLY through the ephemeral Huddle, and + // deliberately not through the parent channel (`members`). + huddleMembers: [ + ChannelMember( + pubkey: 'agent', + role: 'bot', + joinedAt: DateTime(2025), + ), + ], + huddleTypingNotifier: typing, + relayConfigNotifier: _HuddleRelayConfigNotifier(), + huddleCurrentPubkey: 'self', + huddleMediaFactory: _HuddleTestMedia.new, + huddleTransportFactory: (_) => transport, + ), + ); + await tester.pumpAndSettle(); + + await tester.tap(find.widgetWithText(FilledButton, 'Join')); + await tester.pump(); + await tester.pump(const Duration(milliseconds: 500)); + await tester.pump(const Duration(milliseconds: 200)); + + expect( + find.byKey(const ValueKey('huddle-agent-preparing-response-agent')), + findsOneWidget, + ); + expect( + find.byKey(const ValueKey('huddle-avatar-image-agent')), + findsNothing, + ); + expect( + find.bySemanticsLabel('Pollen, preparing a response'), + findsOneWidget, + ); + }, + ); + testWidgets('does not revive response dots when typing follows audio', ( tester, ) async { From c769694573edf207f25b768c286b920213eaa724 Mon Sep 17 00:00:00 2001 From: kenny lopez Date: Mon, 24 Aug 2026 19:15:55 +0100 Subject: [PATCH 4/5] Select roster fields for the Huddle logical participant provider The extracted logical-participant provider watched the whole Huddle session, so it recomputed at the 50 ms speaker-level flush cadence. That tore down and recreated the downstream kind-0 profile subscription ~20x per second per speaking client. Restore the field-level select so speaker-level changes no longer restart the profile feed. Adds a regression test asserting the profile subscription count is stable across continuous speaker-level updates (fails on the whole-session watch). Co-authored-by: Mongo <9cfd347903944d5b85aa6c93d2ab67381b978a92a31914bca69998968752a1d7@buzz.block.builderlab.xyz> Signed-off-by: kenny lopez --- .../channel_detail_page/huddle_sheet.dart | 14 ++- .../channels/channel_detail_page_test.dart | 87 +++++++++++++++++++ 2 files changed, 100 insertions(+), 1 deletion(-) diff --git a/mobile/lib/features/channels/channel_detail_page/huddle_sheet.dart b/mobile/lib/features/channels/channel_detail_page/huddle_sheet.dart index 0371f31be68..5b0abb5766e 100644 --- a/mobile/lib/features/channels/channel_detail_page/huddle_sheet.dart +++ b/mobile/lib/features/channels/channel_detail_page/huddle_sheet.dart @@ -14,7 +14,19 @@ final _huddleParticipantProfileUpdatesProvider = NotifierProvider.autoDispose final _huddleLogicalParticipantPubkeysProvider = Provider.autoDispose .family, String>((ref, channelId) { - final session = ref.watch(huddleSessionProvider); + // Select only roster-relevant fields. Watching the whole session would + // recompute at the 50 ms speaker-level flush cadence, restarting the + // downstream profile subscription ~20x/sec while anyone is speaking. + final session = ref.watch( + huddleSessionProvider.select( + (state) => ( + ephemeralChannelId: state.ephemeralChannelId, + currentPubkey: state.currentPubkey, + participantPubkeys: state.participantPubkeys, + wasAdmitted: state.wasAdmitted, + ), + ), + ); final backingMembers = ref.watch(channelMembersProvider(channelId)).value ?? const []; diff --git a/mobile/test/features/channels/channel_detail_page_test.dart b/mobile/test/features/channels/channel_detail_page_test.dart index aa671cf277a..79baf5450d3 100644 --- a/mobile/test/features/channels/channel_detail_page_test.dart +++ b/mobile/test/features/channels/channel_detail_page_test.dart @@ -5892,6 +5892,64 @@ void main() { ); }); + testWidgets( + 'does not restart the profile subscription on speaker-level updates', + (tester) async { + // Regression: the logical-participant provider must select only + // roster-relevant session fields. Watching the whole session would + // recompute at the 50 ms speaker-level flush cadence, tearing down and + // recreating the kind-0 profile subscription ~20x/sec while anyone is + // speaking. + final now = DateTime.now().millisecondsSinceEpoch ~/ 1000; + final relaySession = _ProfileSubscriptionRelaySession(); + final transport = _HuddleTestTransport( + peers: const { + 1: HuddlePeer(pubkey: 'self', peerIndex: 1, epoch: 0), + 2: HuddlePeer(pubkey: 'agent', peerIndex: 2, epoch: 0), + }, + ); + + await tester.pumpWidget( + _buildTestable( + messages: [ + _huddleMsg( + id: 'speaker-level-profile-churn', + kind: EventKind.huddleStarted, + pubkey: 'self', + createdAt: now, + ), + ], + users: const { + 'agent': UserProfile(pubkey: 'agent', displayName: 'Pollen'), + 'self': UserProfile(pubkey: 'self', displayName: 'Self'), + }, + relaySessionNotifier: relaySession, + relayConfigNotifier: _HuddleRelayConfigNotifier(), + huddleCurrentPubkey: 'self', + huddleMediaFactory: _HuddleTestMedia.new, + huddleTransportFactory: (_) => transport, + ), + ); + await tester.pumpAndSettle(); + await tester.tap(find.widgetWithText(FilledButton, 'Join')); + await tester.pumpAndSettle(); + + final baseline = relaySession.profileSubscriptions; + expect(baseline, greaterThan(0)); + + // Drive continuous speaker-level flushes: each frame within the 600 ms + // active window refreshes the level and schedules a 50 ms flush that + // republishes the whole session state. + for (var i = 0; i < 10; i++) { + transport.emitRemoteAudio(peerIndex: 2, sequence: i + 1); + await tester.pump(const Duration(milliseconds: 50)); + } + await tester.pumpAndSettle(); + + expect(relaySession.profileSubscriptions, baseline); + }, + ); + testWidgets( 'opens the sparse full-screen call with avatar and audio controls', (tester) async { @@ -13717,6 +13775,35 @@ class _IdentityUpdateRelaySession extends RelaySessionNotifier { } } +class _ProfileSubscriptionRelaySession extends RelaySessionNotifier { + int profileSubscriptions = 0; + + @override + SessionState build() => const SessionState(status: SessionStatus.connected); + + @override + Future> fetchHistory( + NostrFilter filter, { + Duration timeout = const Duration(seconds: 8), + }) async => const []; + + @override + Future publish( + NostrEvent event, { + Duration timeout = const Duration(seconds: 8), + }) async => event; + + @override + Future subscribe( + NostrFilter filter, + void Function(NostrEvent) onEvent, { + void Function(String message)? onClosed, + }) async { + if (filter.kinds.contains(0)) profileSubscriptions++; + return () {}; + } +} + class _HuddleReactionRelaySession extends RelaySessionNotifier { NostrFilter? reactionFilter; void Function(NostrEvent)? _reactionListener; From 467e2519aa793f242209bf80335c459314a2a939 Mon Sep 17 00:00:00 2001 From: kenny lopez Date: Tue, 25 Aug 2026 09:14:48 +0100 Subject: [PATCH 5/5] Revive Huddle preparing dots for a genuinely new agent turn The avatar used a single audio-seen latch (responseHasStarted): audio set it true and any later working signal within the 1.2 s cooldown cancelled the reset, keeping preparing UI and live-region semantics suppressed. That conflated late same-turn typing (correctly suppressed) with a new turn after the previous working signal already cleared (wrongly suppressed), so fast consecutive agent responses lost both the waiting cue and its accessibility announcement. Track whether the working signal has completed a cycle since audio latched. A false->true working transition after a completed cycle now clears the latch so preparing shows again, while late typing that never cleared stays suppressed. Adds a causal regression: audio -> late same-turn typing suppressed -> working clears -> new working within cooldown => preparing indicator and live semantics return. Co-authored-by: Mongo <9cfd347903944d5b85aa6c93d2ab67381b978a92a31914bca69998968752a1d7@buzz.block.builderlab.xyz> Signed-off-by: kenny lopez --- .../huddle_call_avatar.dart | 31 ++++++ .../channels/channel_detail_page_test.dart | 103 ++++++++++++++++++ 2 files changed, 134 insertions(+) diff --git a/mobile/lib/features/channels/channel_detail_page/huddle_call_avatar.dart b/mobile/lib/features/channels/channel_detail_page/huddle_call_avatar.dart index d5781e10675..7442cb8155d 100644 --- a/mobile/lib/features/channels/channel_detail_page/huddle_call_avatar.dart +++ b/mobile/lib/features/channels/channel_detail_page/huddle_call_avatar.dart @@ -30,20 +30,51 @@ class _HuddleCallAvatar extends HookConsumerWidget { final reducedMotion = MediaQuery.disableAnimationsOf(context); final responseHasStarted = useState(false); final responseResetTimer = useRef(null); + // Tracks the previous working signal so we can detect its edges, and + // whether the working signal has cleared since audio latched the response. + // A completed working cycle distinguishes a genuinely new turn from late + // typing that merely trails the turn already spoken. + final wasPreparing = useRef(false); + final workingCycleCompleted = useRef(false); useEffect(() { + final preparingStarted = preparingResponse && !wasPreparing.value; + final preparingCleared = !preparingResponse && wasPreparing.value; + wasPreparing.value = preparingResponse; if (active) { + // Audio for the current turn latches that a response has begun and + // starts a fresh turn, so any prior working cycle no longer applies. responseResetTimer.value?.cancel(); responseResetTimer.value = null; responseHasStarted.value = true; + workingCycleCompleted.value = false; + } else if (preparingStarted && + responseHasStarted.value && + workingCycleCompleted.value) { + // A new working turn began after the previous turn's working signal + // already cleared, so the "already spoke" suppression no longer + // applies — allow the preparing indicator to show again. + responseResetTimer.value?.cancel(); + responseResetTimer.value = null; + responseHasStarted.value = false; + workingCycleCompleted.value = false; } else if (preparingResponse) { + // Working signal (including late typing for the turn just spoken) holds + // the suppression alive; keep the reset timer cancelled. responseResetTimer.value?.cancel(); responseResetTimer.value = null; } else if (responseHasStarted.value && responseResetTimer.value == null) { responseResetTimer.value = Timer(_huddleAgentResponseResetDelay, () { responseResetTimer.value = null; responseHasStarted.value = false; + workingCycleCompleted.value = false; }); } + // Record that this turn's working signal has completed a cycle once it + // clears after audio latched, so the next working turn is not mistaken + // for trailing typing. + if (preparingCleared && responseHasStarted.value) { + workingCycleCompleted.value = true; + } return null; }, [active, preparingResponse, responseHasStarted.value]); useEffect( diff --git a/mobile/test/features/channels/channel_detail_page_test.dart b/mobile/test/features/channels/channel_detail_page_test.dart index 79baf5450d3..08b7e570c8f 100644 --- a/mobile/test/features/channels/channel_detail_page_test.dart +++ b/mobile/test/features/channels/channel_detail_page_test.dart @@ -5892,6 +5892,109 @@ void main() { ); }); + testWidgets('revives response dots for a new turn after audio completes', ( + tester, + ) async { + // Regression: a completed working cycle followed by a genuinely new one + // must show the preparing indicator again. A single audio-seen latch + // conflates late same-turn typing (suppress) with a fresh turn (show). + final now = DateTime.now().millisecondsSinceEpoch ~/ 1000; + final typing = _FakeTypingNotifier(const [], channelId: _huddleChannelId); + final transport = _HuddleTestTransport( + peers: const { + 1: HuddlePeer(pubkey: 'self', peerIndex: 1, epoch: 0), + 2: HuddlePeer(pubkey: 'agent', peerIndex: 2, epoch: 0), + }, + ); + + await tester.pumpWidget( + _buildTestable( + messages: [ + _huddleMsg( + id: 'new-turn-after-audio', + kind: EventKind.huddleStarted, + pubkey: 'self', + createdAt: now, + ), + ], + users: const { + 'agent': UserProfile(pubkey: 'agent', displayName: 'Pollen'), + 'self': UserProfile(pubkey: 'self', displayName: 'Self'), + }, + huddleMembers: [ + ChannelMember( + pubkey: 'agent', + role: 'bot', + joinedAt: DateTime(2025), + ), + ], + huddleTypingNotifier: typing, + relayConfigNotifier: _HuddleRelayConfigNotifier(), + huddleCurrentPubkey: 'self', + huddleMediaFactory: _HuddleTestMedia.new, + huddleTransportFactory: (_) => transport, + ), + ); + await tester.pumpAndSettle(); + await tester.tap(find.widgetWithText(FilledButton, 'Join')); + await tester.pumpAndSettle(); + + // Turn 1: audio speaks, then late same-turn typing must stay suppressed. + transport.emitRemoteAudio(peerIndex: 2); + await tester.pump(); + await tester.pump(const Duration(milliseconds: 650)); + typing.setEntries([ + TypingEntry( + pubkey: 'agent', + expiresAtMs: DateTime.now().millisecondsSinceEpoch + 8000, + ), + ]); + await tester.pump(); + await tester.pump(const Duration(milliseconds: 200)); + expect( + find.byKey(const ValueKey('huddle-agent-preparing-response-agent')), + findsNothing, + ); + + // Turn 1's working signal completes. + typing.setEntries(const []); + await tester.pump(); + await tester.pump(const Duration(milliseconds: 200)); + + // Turn 2: a fresh working signal within the 1.2 s cooldown must show the + // preparing indicator and announce it live again. + typing.setEntries([ + TypingEntry( + pubkey: 'agent', + expiresAtMs: DateTime.now().millisecondsSinceEpoch + 8000, + ), + ]); + await tester.pump(); + await tester.pump(const Duration(milliseconds: 200)); + + expect( + find.byKey(const ValueKey('huddle-agent-preparing-response-agent')), + findsOneWidget, + ); + expect( + find.byKey(const ValueKey('huddle-avatar-image-agent')), + findsNothing, + ); + expect( + find.bySemanticsLabel('Pollen, preparing a response'), + findsOneWidget, + ); + expect( + tester + .getSemantics( + find.byKey(const ValueKey('huddle-participant-avatar-agent')), + ) + .flagsCollection + .isLiveRegion, + isTrue, + ); + }); + testWidgets( 'does not restart the profile subscription on speaker-level updates', (tester) async {