-
Notifications
You must be signed in to change notification settings - Fork 4k
Fix mobile Huddle agent voice turn states #6611
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
a64cf78
34b028c
4a8df4a
c769694
467e251
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -1,12 +1,15 @@ | ||
| part of '../channel_detail_page.dart'; | ||
|
|
||
| const _huddleAgentResponseResetDelay = Duration(milliseconds: 1200); | ||
|
|
||
| class _HuddleCallAvatar extends HookConsumerWidget { | ||
| const _HuddleCallAvatar({ | ||
| required this.pubkey, | ||
| required this.profile, | ||
| required this.fallbackLabel, | ||
| required this.active, | ||
| required this.speakerLevel, | ||
| required this.preparingResponse, | ||
| required this.onTap, | ||
| this.isSelf = false, | ||
| this.frameSize = _huddleAvatarFrameSize, | ||
|
|
@@ -17,13 +20,70 @@ 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; | ||
|
|
||
| @override | ||
| Widget build(BuildContext context, WidgetRef ref) { | ||
| final reducedMotion = MediaQuery.disableAnimationsOf(context); | ||
| final responseHasStarted = useState(false); | ||
| final responseResetTimer = useRef<Timer?>(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( | ||
| () => | ||
| () => responseResetTimer.value?.cancel(), | ||
| const [], | ||
| ); | ||
| final showPreparingResponse = | ||
| preparingResponse && !active && !responseHasStarted.value; | ||
| final scale = frameSize / _huddleAvatarFrameSize; | ||
| final avatarRadius = _huddleAvatarRadius * scale; | ||
| final speakingRingSize = _huddleSpeakingRingSize * scale; | ||
|
|
@@ -65,12 +125,22 @@ 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, | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
When a screen reader is enabled and an agent enters the preparing state, the enclosing Useful? React with 👍 / 👎. |
||
| 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( | ||
|
|
@@ -121,15 +191,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, | ||
| ), | ||
| ), | ||
| ), | ||
| ], | ||
| ), | ||
|
|
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
When an agent begins another turn before its previous typing entry clears,
preparingResponseremains true because typing entries are upserted with an eight-second TTL; this branch cancels the reset timer without clearingresponseHasStarted. Consequently, after the agent has spoken once, a rapid follow-up response keeps showing the avatar instead of the preparation dots. Fresh evidence in the revised state machine is this new timer-cancellation path, so the latch needs to distinguish a new typing turn rather than relying only on a false interval.Useful? React with 👍 / 👎.