diff --git a/mobile/lib/features/channels/agent_reply_handoff.dart b/mobile/lib/features/channels/agent_reply_handoff.dart new file mode 100644 index 0000000000..e4f63559fb --- /dev/null +++ b/mobile/lib/features/channels/agent_reply_handoff.dart @@ -0,0 +1,103 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_hooks/flutter_hooks.dart'; +import 'agent_typing_bubble.dart'; +import 'channel_typing_provider.dart'; +import 'timeline_message.dart'; + +/// Keeps completion handoffs scoped to newly arriving replies, never history. +({Map messageIds, List typing}) useAgentReplyHandoffs( + String channelId, + List typing, + List messages, +) { + final tracker = useMemoized(_ReplyHandoffs.new, [channelId]); + return tracker.update(typing, messages); +} + +/// Limits the handoff to a fresh arrival, avoiding replay when scrolling back. +bool isRecentAgentReplyHandoff(Map entries, String id) => + (entries[id] ?? 0) > DateTime.now().millisecondsSinceEpoch; + +class _ReplyHandoffs { + Set? seen; + final pending = <(String, String?), TypingEntry>{}; + final completed = <(String, String?), int>{}; + final handoffs = {}; + + ({Map messageIds, List typing}) update( + List typing, + List messages, + ) { + final now = DateTime.now().millisecondsSinceEpoch; + pending.removeWhere((_, entry) => entry.expiresAtMs <= now); + completed.removeWhere((_, at) => at + 8000 <= now); + for (final entry in typing) { + final scope = (entry.pubkey.toLowerCase(), entry.threadHeadId); + if ((completed[scope] ?? -1) < entry.expiresAtMs - 8000) { + pending[scope] = entry; + } + } + final ids = messages.map((message) => message.id).toSet(); + handoffs.removeWhere((id, expires) => !ids.contains(id) || expires <= now); + if (seen != null) { + for (final message in messages) { + if (seen!.contains(message.id) || message.isSystem) continue; + final scope = ( + message.pubkey.toLowerCase(), + message.rootId ?? message.parentId, + ); + final activity = pending[scope]; + if (activity != null && + message.createdAt * 1000 >= activity.expiresAtMs - 8000) { + handoffs[message.id] = now + 1000; + completed[scope] = message.createdAt * 1000; + pending.remove(scope); + } + } + } + seen = ids; + return ( + messageIds: handoffs, + typing: [ + for (final entry in typing) + if ((completed[(entry.pubkey.toLowerCase(), entry.threadHeadId)] ?? + -1) < + entry.expiresAtMs - 8000) + entry, + ], + ); + } +} + +/// Grows the pending bubble into the real message using its final content size. +class AgentReplyHandoff extends HookWidget { + const AgentReplyHandoff({ + super.key, + required this.animate, + required this.child, + }); + final bool animate; + final Widget child; + + @override + Widget build(BuildContext context) { + final reduced = MediaQuery.disableAnimationsOf(context); + final revealed = useState(!animate || reduced); + useEffect(() { + if (!revealed.value) { + WidgetsBinding.instance.addPostFrameCallback((_) { + if (context.mounted) revealed.value = true; + }); + } + return null; + }, const []); + if (!animate || reduced) return child; + return AnimatedSize( + key: const ValueKey('agent-reply-handoff'), + duration: const Duration(milliseconds: 220), + curve: const Cubic(0.23, 1, 0.32, 1), + alignment: Alignment.topLeft, + child: revealed.value ? child : const AgentTypingBubble(), + ); + } +} diff --git a/mobile/lib/features/channels/agent_typing_bubble.dart b/mobile/lib/features/channels/agent_typing_bubble.dart new file mode 100644 index 0000000000..e3aa60179b --- /dev/null +++ b/mobile/lib/features/channels/agent_typing_bubble.dart @@ -0,0 +1,24 @@ +import 'package:flutter/material.dart'; +import '../../shared/theme/theme.dart'; +import 'typing_dots.dart'; + +/// Pending reply with three staggered dots using the desktop typing bounce. +class AgentTypingBubble extends StatelessWidget { + const AgentTypingBubble({super.key}); + + @override + Widget build(BuildContext context) { + return Container( + key: const ValueKey('agent-typing-bubble'), + padding: const EdgeInsets.symmetric( + horizontal: Grid.xs, + vertical: Grid.xxs, + ), + decoration: BoxDecoration( + color: context.colors.secondaryContainer, + borderRadius: BorderRadius.circular(Radii.container), + ), + child: const SizedBox(height: 24, child: TypingDots()), + ); + } +} diff --git a/mobile/lib/features/channels/channel_detail_page.dart b/mobile/lib/features/channels/channel_detail_page.dart index 3f90c616c9..73e7f2cc94 100644 --- a/mobile/lib/features/channels/channel_detail_page.dart +++ b/mobile/lib/features/channels/channel_detail_page.dart @@ -1,5 +1,6 @@ import 'package:buzz/shared/widgets/feedback_snackbar.dart'; import 'message_swipe_reply.dart'; +import 'agent_reply_handoff.dart'; import 'huddle_timeline.dart'; import '../../shared/widgets/modal_presentation.dart'; import '../../shared/widgets/ios_scroll_to_top.dart'; @@ -57,8 +58,8 @@ import 'channel_management_provider.dart'; import 'channel_sections/channel_sections_provider.dart'; import 'channel_messages_provider.dart'; import 'channel_typing_provider.dart'; -import 'conversation_activity.dart'; -import 'conversation_activity_state.dart'; +import 'inline_agent_typing.dart'; +import 'typing_motion_preview.dart'; import 'channel_ids_by_name_provider.dart'; import 'channels_provider.dart'; import 'unread_badge/observed_unread_event.dart'; @@ -102,7 +103,6 @@ part 'channel_detail_page/empty_messages.dart'; part 'channel_detail_page/oldest_navigation.dart'; part 'channel_detail_page/system_rows.dart'; part 'channel_detail_page/thread_working.dart'; -part 'channel_detail_page/composer_activity.dart'; part 'channel_detail_page/activity_members_sheet.dart'; part 'channel_detail_page/huddle_sheet.dart'; part 'channel_detail_page/huddle_call_avatar.dart'; @@ -445,8 +445,8 @@ class _ResolvedChannelDetailPage extends HookConsumerWidget { channelBotPubkeys: channelBotPubkeysState.asData?.value ?? const {}, ); - // As on desktop, the user's agents working in a thread show on its root - // message. Me counts thread typing as its own conversation's. + // Keep the working-agent reply link on channel and DM roots. Solo + // conversations already flatten their replies into the timeline. final threadWorking = _threadWorkingAgents( ref, personalChat @@ -457,6 +457,26 @@ class _ResolvedChannelDetailPage extends HookConsumerWidget { ).select((all) => _threadTypingKey(all, currentPubkey)), ), ); + final ownedThreadTypers = { + for (final agents in threadWorking.values) ...agents, + }; + // Agent work is visible in every DM, even when its response is threaded. + // Ordinary channels keep other thread activity inside the thread view, + // but, as on desktop, show the user's own agents working in any thread. + final typingEntries = ref + .watch( + visibleTypingProvider( + channel.id, + selfPubkey: currentPubkey, + include: (e) => + personalChat || + e.threadHeadId == null || + ownedThreadTypers.contains(e.pubkey.toLowerCase()) || + (resolvedChannel.isDm && + agentPubkeys.contains(e.pubkey.toLowerCase())), + ), + ) + .entries; useEffect(() { if (sessionStatus != SessionStatus.connected || identitySubscriptionPubkeys.isEmpty) { @@ -821,6 +841,11 @@ class _ResolvedChannelDetailPage extends HookConsumerWidget { data: (events) { return _MessageList( notifier: ref.watch(messagesProvider.notifier), + inlineTyping: + resolvedChannel.isMember && + !resolvedChannel.isArchived + ? typingEntries + : const [], threadWorking: threadWorking, stickyDateHeaderState: stickyDateHeaderState, entries: timelineEntries, @@ -889,15 +914,6 @@ class _ResolvedChannelDetailPage extends HookConsumerWidget { child: Column( mainAxisSize: MainAxisSize.min, children: [ - _ComposerActivity( - scope: ActivityScope( - channelId: channel.id, - selfPubkey: currentPubkey, - allThreads: personalChat, - ), - channel: resolvedChannel, - messages: timelineMessages, - ), ?assistant.pendingNotice, ComposeBar( channelId: channel.id, diff --git a/mobile/lib/features/channels/channel_detail_page/composer_activity.dart b/mobile/lib/features/channels/channel_detail_page/composer_activity.dart deleted file mode 100644 index 9417d3c5fd..0000000000 --- a/mobile/lib/features/channels/channel_detail_page/composer_activity.dart +++ /dev/null @@ -1,62 +0,0 @@ -part of '../channel_detail_page.dart'; - -/// Desktop's conversation activity above the channel composer. Its rows open -/// the threads the user's agents work in, except in Me, which has none. -class _ComposerActivity extends StatelessWidget { - const _ComposerActivity({ - required this.scope, - required this.channel, - required this.messages, - }); - - final ActivityScope scope; - final Channel channel; - final List messages; - - void _openThread(BuildContext context, String rootId) { - // The sheet closed a frame ago; this page may have gone meanwhile. - if (!context.mounted) return; - final root = messages - .where((message) => message.id.toLowerCase() == rootId.toLowerCase()) - .firstOrNull; - Navigator.of(context).push( - MaterialPageRoute( - builder: (_) => root == null - // As desktop opens a thread by its link, load a root that is not - // in the timeline, then show its thread. - ? ChannelDetailPage( - channel: channel, - initialThreadRootId: rootId, - initialThreadRouteBehavior: - InitialThreadRouteBehavior.replaceCurrentRoute, - ) - : ThreadDetailPage( - threadHead: root, - allMessages: messages, - channelId: scope.channelId, - currentPubkey: scope.selfPubkey, - isMember: channel.isMember, - isArchived: channel.isArchived, - ), - ), - ); - } - - @override - Widget build(BuildContext context) { - final child = ConversationActivity( - scope: scope, - messages: messages, - onOpenThread: scope.allThreads - ? null - : (rootId) => _openThread(context, rootId), - ); - if (MediaQuery.disableAnimationsOf(context)) return child; - return AnimatedSize( - duration: const Duration(milliseconds: 180), - curve: Curves.easeOutCubic, - alignment: Alignment.bottomCenter, - child: child, - ); - } -} diff --git a/mobile/lib/features/channels/channel_detail_page/message_bubble.dart b/mobile/lib/features/channels/channel_detail_page/message_bubble.dart index 47d17ef9f3..325541d7b3 100644 --- a/mobile/lib/features/channels/channel_detail_page/message_bubble.dart +++ b/mobile/lib/features/channels/channel_detail_page/message_bubble.dart @@ -3,6 +3,7 @@ part of '../channel_detail_page.dart'; class _MessageBubble extends HookConsumerWidget { final TimelineMessage message; final bool showAuthor; + final bool typingHandoff; final bool showAvatar; final bool startsDay; final bool endsDay; @@ -20,6 +21,7 @@ class _MessageBubble extends HookConsumerWidget { const _MessageBubble({ required this.message, required this.showAuthor, + this.typingHandoff = false, this.showAvatar = true, this.startsDay = false, this.endsDay = false, @@ -337,77 +339,86 @@ class _MessageBubble extends HookConsumerWidget { ], ), ), - MessageContent( - key: swipeContentKey, - content: message.content, - bubble: true, - hasReactions: message.reactions.isNotEmpty, - bubbleEnd: showAvatar, - bubbleStart: showAuthor, - ownMessage: ownMessage, - trimTrailingSpacing: endsDay, - mentionNames: resolvedMentionNames, - mentionLabels: mentionLabels, - agentMentionPubkeys: agentMentionPubkeys, - channelNames: channelNames, - tags: message.tags, - baseStyle: messageBodyTextStyle.copyWith( - color: context.colors.onSurface, + AgentReplyHandoff( + key: ValueKey( + 'reply-handoff-${message.id}', ), - scaleEmojiOnly: true, - mediaCarouselTrailingOverflow: Grid.gutter, - onMediaReply: allMessages == null - ? null - : () { - if (!context.mounted) return; - Navigator.of(context).push( - MaterialPageRoute( - builder: (_) => - ThreadDetailPage( - threadHead: message, - allMessages: allMessages!, - channelId: - currentChannelId, - currentPubkey: - currentPubkey, - isMember: isMember, - isArchived: isArchived, - ), - ), - ); - }, - onMediaMore: (viewerContext, imageUrl) => - showImageActions( - context: viewerContext, - ref: ref, - message: message, - channelId: currentChannelId, - imageUrl: imageUrl, - canManageMessage: canManageMessage, - onDeleted: () { - if (viewerContext.mounted) { - Navigator.of( - viewerContext, - ).maybePop(); - } - }, - ), - onChannelTap: (channelId) { - openChannelLink( - context: context, - ref: ref, - channelId: channelId, - currentChannelId: currentChannelId, - ); - }, - onMentionTap: (pubkey) => - showUserProfileSheet( - context, - pubkey, - names: channelIdentityNamesProvider( - currentChannelId, + animate: typingHandoff, + child: MessageContent( + key: swipeContentKey, + content: message.content, + bubble: true, + hasReactions: + message.reactions.isNotEmpty, + bubbleEnd: showAvatar, + bubbleStart: showAuthor, + ownMessage: ownMessage, + trimTrailingSpacing: endsDay, + mentionNames: resolvedMentionNames, + mentionLabels: mentionLabels, + agentMentionPubkeys: agentMentionPubkeys, + channelNames: channelNames, + tags: message.tags, + baseStyle: messageBodyTextStyle.copyWith( + color: context.colors.onSurface, + ), + scaleEmojiOnly: true, + mediaCarouselTrailingOverflow: + Grid.gutter, + onMediaReply: allMessages == null + ? null + : () { + if (!context.mounted) return; + Navigator.of(context).push( + MaterialPageRoute( + builder: (_) => + ThreadDetailPage( + threadHead: message, + allMessages: + allMessages!, + channelId: + currentChannelId, + currentPubkey: + currentPubkey, + isMember: isMember, + isArchived: isArchived, + ), + ), + ); + }, + onMediaMore: (viewerContext, imageUrl) => + showImageActions( + context: viewerContext, + ref: ref, + message: message, + channelId: currentChannelId, + imageUrl: imageUrl, + canManageMessage: canManageMessage, + onDeleted: () { + if (viewerContext.mounted) { + Navigator.of( + viewerContext, + ).maybePop(); + } + }, ), - ), + onChannelTap: (channelId) { + openChannelLink( + context: context, + ref: ref, + channelId: channelId, + currentChannelId: currentChannelId, + ); + }, + onMentionTap: (pubkey) => + showUserProfileSheet( + context, + pubkey, + names: channelIdentityNamesProvider( + currentChannelId, + ), + ), + ), ), ], ), diff --git a/mobile/lib/features/channels/channel_detail_page/message_list.dart b/mobile/lib/features/channels/channel_detail_page/message_list.dart index 553230cd2d..c5a90fd09c 100644 --- a/mobile/lib/features/channels/channel_detail_page/message_list.dart +++ b/mobile/lib/features/channels/channel_detail_page/message_list.dart @@ -3,6 +3,7 @@ part of '../channel_detail_page.dart'; class _MessageList extends HookConsumerWidget { final ValueNotifier stickyDateHeaderState; final List entries; + final List inlineTyping; /// Agents typing in each thread, by lowercase root message ID. final Map> threadWorking; @@ -27,6 +28,7 @@ class _MessageList extends HookConsumerWidget { const _MessageList({ required this.entries, + this.inlineTyping = const [], this.threadWorking = const {}, required this.stickyDateHeaderState, required this.allMessages, @@ -51,6 +53,11 @@ class _MessageList extends HookConsumerWidget { @override Widget build(BuildContext context, WidgetRef ref) { + final handoff = useAgentReplyHandoffs(channelId, inlineTyping, allMessages); + final displayedTyping = handoff.typing; + final workingAgents = { + for (final agents in threadWorking.values) ...agents, + }; final appView = View.of(context); final localSendAnimations = ref.watch( localMessageSendAnimationProvider(channelId), @@ -798,6 +805,27 @@ class _MessageList extends HookConsumerWidget { }, ); + if (entries.isEmpty && + (displayedTyping.isNotEmpty || TypingMotionPreview.enabled)) { + return Align( + alignment: Alignment.bottomCenter, + child: Padding( + padding: EdgeInsets.fromLTRB( + Grid.gutter, + 0, + Grid.gutter, + composerBottomInset, + ), + child: TypingMotionPreview.enabled + ? TypingMotionPreview(channelId: channelId) + : InlineAgentTyping( + channelId: channelId, + entries: displayedTyping, + knownAgents: workingAgents, + ), + ), + ); + } if (entries.isEmpty) return const _EmptyMessages(); final channelNamesMap = ref.watch(channelIdsByNameProvider); @@ -974,7 +1002,16 @@ class _MessageList extends HookConsumerWidget { message.createdAt, nextMessage.createdAt, ), - showAuthor: showAuthor, + showAuthor: + showAuthor || + isRecentAgentReplyHandoff( + handoff.messageIds, + message.id, + ), + typingHandoff: isRecentAgentReplyHandoff( + handoff.messageIds, + message.id, + ), showAvatar: showAvatar, hasReplies: entry.summary != null, replyFooter: @@ -1006,6 +1043,17 @@ class _MessageList extends HookConsumerWidget { restoreComposerFocus: restoreComposerFocus, ), ], + if (index == 0 && + (displayedTyping.isNotEmpty || + TypingMotionPreview.enabled)) + if (TypingMotionPreview.enabled) + TypingMotionPreview(channelId: channelId) + else + InlineAgentTyping( + channelId: channelId, + entries: displayedTyping, + knownAgents: workingAgents, + ), ], ), ), diff --git a/mobile/lib/features/channels/conversation_activity.dart b/mobile/lib/features/channels/conversation_activity.dart deleted file mode 100644 index e9b8ef2688..0000000000 --- a/mobile/lib/features/channels/conversation_activity.dart +++ /dev/null @@ -1,230 +0,0 @@ -import 'package:flutter/material.dart'; -import 'package:hooks_riverpod/hooks_riverpod.dart'; - -import '../../shared/profile/user_cache_provider.dart'; -import '../../shared/theme/theme.dart'; -import 'channel_identity_names_provider.dart'; -import 'conversation_activity_state.dart'; -import 'small_avatar.dart'; -import 'timeline_message.dart'; -import 'typing_dots.dart'; -import 'working_now_sheet.dart'; - -/// Desktop's conversation activity above the composer: the user's agents -/// working here, which open the "Working now" list, and everyone else typing. -class ConversationActivity extends ConsumerWidget { - const ConversationActivity({ - super.key, - required this.scope, - this.messages = const [], - this.onOpenThread, - }); - - final ActivityScope scope; - - /// Loaded messages, to name the threads agents work in. - final List messages; - - /// Opens a thread by its root. Null where threads do not open. - final void Function(String rootId)? onOpenThread; - - @override - Widget build(BuildContext context, WidgetRef ref) { - final activity = watchConversationActivity(ref, scope); - if (activity == null) return const SizedBox.shrink(); - final (:agents, :typers) = activity; - if (agents.isEmpty && typers.isEmpty) return const SizedBox.shrink(); - final labels = watchChannelIdentityLabels(ref, scope.channelId, { - ...agents.keys, - ...typers, - }); - String name(String pubkey) => labels[pubkey] ?? pubkey.substring(0, 10); - // Desktop's strings, in TypingIndicator. - final working = agents.isEmpty - ? '' - : '${agents.keys.map(name).join(', ')}' - '${agents.length == 1 ? ' is working' : ' are working'}'; - final typing = typers.isEmpty - ? '' - : '${typers.take(2).map(name).join(', ')}' - '${typers.length > 2 ? ' and ${typers.length - 2} others' : ''}' - '${typers.length == 1 ? ' is typing' : ' are typing'}'; - final status = [working, typing].where((text) => text.isNotEmpty); - return Padding( - key: const ValueKey('conversation-activity'), - padding: const EdgeInsets.fromLTRB(Grid.twelve, 0, Grid.twelve, Grid.xxs), - // The status speaks for both sides, as desktop's live region does. It - // spans the whole row, so it has a place on screen when only agents - // show. - child: Semantics( - container: true, - liveRegion: true, - label: status.join('. '), - child: Row( - children: [ - Expanded( - child: ExcludeSemantics( - child: Align( - alignment: AlignmentDirectional.centerStart, - child: typing.isEmpty ? null : _Typers(typing), - ), - ), - ), - if (agents.isNotEmpty) - _WorkingAgents( - agents: agents.keys.toList(), - label: agents.length == 1 - ? 'Activity: ${name(agents.keys.single)} working' - : 'Activity: ${agents.length} agents working', - // As in desktop's Solo chat, Me opens a lone agent's activity - // directly. - onTap: scope.allThreads && agents.length == 1 - ? () => showAgentActivity( - context, - channelId: scope.channelId, - pubkey: agents.keys.single, - ) - : () => showWorkingNowSheet( - context, - scope: scope, - messages: messages, - onOpenThread: onOpenThread, - ), - ), - ], - ), - ), - ); - } -} - -class _Typers extends StatelessWidget { - const _Typers(this.text); - final String text; - - @override - Widget build(BuildContext context) => FractionallySizedBox( - // Desktop keeps the typing text to half the row. - widthFactor: 0.5, - alignment: AlignmentDirectional.centerStart, - child: Align( - alignment: AlignmentDirectional.centerStart, - child: Container( - padding: const EdgeInsets.symmetric( - horizontal: Grid.xxs, - vertical: Grid.half, - ), - decoration: BoxDecoration( - color: context.colors.surfaceContainerHighest, - borderRadius: BorderRadius.circular(Radii.container), - ), - child: Row( - mainAxisSize: MainAxisSize.min, - children: [ - const TypingDots(dotSize: 5), - const SizedBox(width: Grid.xxs), - Flexible( - child: Text( - text, - maxLines: 1, - overflow: TextOverflow.ellipsis, - // The theme's muted ink is too faint on this fill in dark - // themes, so the names use the full foreground. - style: context.textTheme.labelSmall?.copyWith( - color: context.colors.onSurface, - ), - ), - ), - ], - ), - ), - ), - ); -} - -class _WorkingAgents extends ConsumerWidget { - const _WorkingAgents({ - required this.agents, - required this.label, - required this.onTap, - }); - final List agents; - final String label; - final VoidCallback onTap; - - @override - Widget build(BuildContext context, WidgetRef ref) { - final userCache = ref.watch(userCacheProvider); - // Desktop shows at most four avatars, then a count. - final shown = agents.take(4).toList(); - // Desktop's 24px tiles, each overlapping the next by 4px. - const tile = 24.0, step = tile - Grid.half; - return Semantics( - container: true, - button: true, - label: label, - onTap: onTap, - excludeSemantics: true, - child: InkWell( - key: const ValueKey('conversation-activity-agents'), - onTap: onTap, - borderRadius: BorderRadius.circular(Radii.container), - child: ConstrainedBox( - constraints: const BoxConstraints(minHeight: 44, minWidth: 44), - child: Padding( - padding: const EdgeInsets.symmetric(horizontal: Grid.half), - child: Row( - mainAxisSize: MainAxisSize.min, - children: [ - SizedBox( - width: tile + step * (shown.length - 1), - height: tile, - child: Stack( - children: [ - for (final (i, pubkey) in shown.indexed) - PositionedDirectional( - start: step * i, - child: Container( - padding: const EdgeInsets.all(Grid.quarter), - decoration: BoxDecoration( - color: context.colors.surfaceContainerHighest, - borderRadius: BorderRadius.circular( - Radii.container, - ), - boxShadow: [ - BoxShadow( - color: Colors.black.withValues(alpha: 0.08), - blurRadius: 2, - offset: const Offset(0, 1), - ), - ], - ), - child: SmallAvatar( - pubkey: pubkey, - userCache: userCache, - size: tile - 2 * Grid.quarter, - agent: true, - ), - ), - ), - ], - ), - ), - if (agents.length > shown.length) - Padding( - padding: const EdgeInsetsDirectional.only(start: Grid.xxs), - child: Text( - '+${agents.length - shown.length}', - style: context.textTheme.labelSmall?.copyWith( - color: context.colors.onSurfaceVariant, - ), - ), - ), - ], - ), - ), - ), - ), - ); - } -} diff --git a/mobile/lib/features/channels/conversation_activity_state.dart b/mobile/lib/features/channels/conversation_activity_state.dart deleted file mode 100644 index 359f6bdd7f..0000000000 --- a/mobile/lib/features/channels/conversation_activity_state.dart +++ /dev/null @@ -1,84 +0,0 @@ -import 'package:hooks_riverpod/hooks_riverpod.dart'; - -import '../../shared/me/me_assistants.dart'; -import 'channel_typing_provider.dart'; - -/// The conversation a composer belongs to: a channel, or one of its threads. -class ActivityScope { - const ActivityScope({ - required this.channelId, - required this.selfPubkey, - this.threadRootId, - this.allThreads = false, - }); - - final String channelId; - final String? selfPubkey; - - /// Null for the channel conversation. - final String? threadRootId; - - /// Counts typing in any thread as this conversation's, as Me does. - final bool allThreads; -} - -/// Who is active in a conversation, as desktop's composer shows it. -typedef ConversationActivityState = ({ - /// The user's agents typing here, sorted by pubkey as on desktop, each with - /// the thread roots it types in. A null root is the channel conversation. - Map> agents, - - /// Everyone else typing in this conversation itself, in first-seen order. - List typers, -}); - -const _idle = (agents: >{}, typers: []); - -/// Desktop's composer activity for [scope], from live typing only. As on -/// desktop, the user's agents are their inventory, and a channel view counts -/// those agents' thread work while a thread view does not. Null while the -/// inventory first loads, so an agent does not show as a typing person -/// before it is known. -ConversationActivityState? watchConversationActivity( - WidgetRef ref, - ActivityScope scope, -) { - final threadRootId = scope.threadRootId; - final entries = ref - .watch( - visibleTypingProvider( - scope.channelId, - selfPubkey: scope.selfPubkey, - include: (entry) => - threadRootId == null || entry.threadHeadId == threadRootId, - ), - ) - .entries; - if (entries.isEmpty) return _idle; - final inventory = ref.watch(meAgentInventoryProvider); - if (inventory.isLoading && !inventory.hasValue) return null; - final owned = { - for (final pubkey in inventory.value ?? const {}) - pubkey.toLowerCase(), - }; - final agents = >{}; - final typers = []; - for (final entry in entries) { - final pubkey = entry.pubkey.toLowerCase(); - if (owned.contains(pubkey)) { - final roots = agents.putIfAbsent(pubkey, () => []); - if (!roots.contains(entry.threadHeadId)) roots.add(entry.threadHeadId); - } else if ((scope.allThreads || entry.threadHeadId == threadRootId) && - !typers.contains(pubkey)) { - typers.add(pubkey); - } - } - final sorted = agents.keys.toList()..sort(); - return ( - agents: {for (final pubkey in sorted) pubkey: agents[pubkey]!}, - typers: [ - for (final pubkey in typers) - if (!agents.containsKey(pubkey)) pubkey, - ], - ); -} diff --git a/mobile/lib/features/channels/inline_agent_typing.dart b/mobile/lib/features/channels/inline_agent_typing.dart new file mode 100644 index 0000000000..adb1c391cc --- /dev/null +++ b/mobile/lib/features/channels/inline_agent_typing.dart @@ -0,0 +1,113 @@ +import 'package:flutter/material.dart'; +import 'package:hooks_riverpod/hooks_riverpod.dart'; + +import '../../shared/mentions/agent_identity_provider.dart'; +import '../../shared/profile/user_cache_provider.dart'; +import '../../shared/theme/theme.dart'; +import '../../shared/widgets/avatar_image.dart'; +import 'agent_typing_bubble.dart'; +import 'channel_identity_names_provider.dart'; +import 'channel_typing_provider.dart'; +import 'typing_avatar_cluster.dart'; + +/// One pending reply, shared by everyone currently typing. +class InlineAgentTyping extends ConsumerWidget { + const InlineAgentTyping({ + super.key, + required this.channelId, + required this.entries, + this.previewAvatars = const {}, + this.knownAgents = const {}, + }); + final String channelId; + final List entries; + final Map previewAvatars; + + /// Lowercase pubkeys known to be agents, such as the user's own. + final Set knownAgents; + + @override + Widget build(BuildContext context, WidgetRef ref) { + final keys = entries.map((entry) => entry.pubkey.toLowerCase()).toSet(); + if (keys.isEmpty) return const SizedBox.shrink(); + // Bound profile resolution and avatar construction independently of fanout. + final visibleKeys = keys.take(keys.length > 4 ? 3 : 4).toList(); + final profiles = ref.watch(userCacheProvider); + final bots = ref.watch(agentMentionPubkeysProvider(channelId)); + final labels = watchChannelIdentityLabels( + ref, + channelId, + visibleKeys.where((key) => !previewAvatars.containsKey(key)), + ); + final name = previewAvatars.containsKey(visibleKeys.first) + ? 'Demo agent' + : labels[visibleKeys.first] ?? 'Someone'; + final title = keys.length == 1 + ? name + : '$name and ${keys.length - 1} ${keys.length == 2 ? 'other' : 'others'}'; + return Semantics( + liveRegion: true, + label: '$title ${keys.length == 1 ? 'is' : 'are'} typing', + child: ExcludeSemantics( + child: Padding( + padding: const EdgeInsets.only(top: Grid.xs, bottom: Grid.xxs), + child: Row( + crossAxisAlignment: CrossAxisAlignment.end, + children: [ + TypingAvatarCluster( + count: keys.length, + avatars: { + for (final key in visibleKeys) + key: + previewAvatars[key] ?? + AvatarImage( + key: ValueKey('inline-agent-typing-$key'), + imageUrl: + (profiles[key] ?? + ref + .read(userCacheProvider.notifier) + .get(key)) + ?.avatarUrl, + radius: messageAvatarSize / 2, + isAgent: + knownAgents.contains(key) || + bots.contains(key) || + (profiles[key]?.isAgent ?? false), + backgroundColor: context.controlSurface, + fallback: Text( + profiles[key]?.initial ?? + key.substring(0, 1).toUpperCase(), + ), + ), + }, + ), + const SizedBox(width: messageAvatarContentGap), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Padding( + padding: const EdgeInsets.only(left: Grid.xs), + child: Text( + title, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: messageUsernameTextStyle.copyWith( + color: context.colors.onSurface, + fontSize: 13.1, + height: 17 / 13.1, + ), + ), + ), + const SizedBox(height: Grid.xxs), + const AgentTypingBubble(), + ], + ), + ), + ], + ), + ), + ), + ); + } +} diff --git a/mobile/lib/features/channels/thread_detail_helpers.dart b/mobile/lib/features/channels/thread_detail_helpers.dart index be95178a0c..fc450bd95e 100644 --- a/mobile/lib/features/channels/thread_detail_helpers.dart +++ b/mobile/lib/features/channels/thread_detail_helpers.dart @@ -152,26 +152,6 @@ class _ThreadTailIntent { } } -/// Desktop's conversation activity above the thread composer, animated -/// in size unless motion is reduced. -class _ThreadTypingIndicator extends StatelessWidget { - final ActivityScope scope; - - const _ThreadTypingIndicator({required this.scope}); - - @override - Widget build(BuildContext context) { - final child = ConversationActivity(scope: scope); - if (MediaQuery.disableAnimationsOf(context)) return child; - return AnimatedSize( - duration: const Duration(milliseconds: 180), - curve: Curves.easeOutCubic, - alignment: Alignment.bottomCenter, - child: child, - ); - } -} - /// Display evidence while the authoritative thread query has no value (still /// loading, or failed on first load): the route snapshot plus live, cached /// and optimistic replies. The page keeps the query's loading/error status, diff --git a/mobile/lib/features/channels/thread_detail_page.dart b/mobile/lib/features/channels/thread_detail_page.dart index b6a60046e9..3890476226 100644 --- a/mobile/lib/features/channels/thread_detail_page.dart +++ b/mobile/lib/features/channels/thread_detail_page.dart @@ -33,8 +33,9 @@ import 'channel_management_provider.dart' show currentPubkeyProvider; import 'channel_identity_names_provider.dart'; import 'channel_link_navigation.dart'; import 'channel_messages_provider.dart'; -import 'conversation_activity.dart'; -import 'conversation_activity_state.dart'; +import 'channel_typing_provider.dart'; +import 'inline_agent_typing.dart'; +import '../../shared/me/me_assistants.dart'; import 'thread_replies_provider.dart'; import 'channel_ids_by_name_provider.dart'; import 'channels_provider.dart'; @@ -783,12 +784,16 @@ class ThreadDetailPage extends HookConsumerWidget { keys: [threadHead.id, readingContentKey(allMsgs), navigationBottomInset], ); - // Desktop's activity for this thread, other than the user's own typing. - final activityScope = ActivityScope( - channelId: channelId, - selfPubkey: currentPubkey, - threadRootId: threadHead.id, - ); + // Thread-scoped typing indicators (exclude self). + final threadTyping = ref + .watch( + visibleTypingProvider( + channelId, + selfPubkey: currentPubkey, + include: (e) => e.threadHeadId == threadHead.id, + ), + ) + .entries; // The root of the entire thread chain. If the current thread head is // itself a root message its rootId is null, so fall back to its own id. @@ -848,6 +853,33 @@ class ThreadDetailPage extends HookConsumerWidget { return null; }, [viewportHeight]); + // Typing now occupies the timeline, so follow its changing height only + // when the reader was already following the tail. + useEffect(() { + final shouldFollow = + isMember && + !isArchived && + initialTailSettle.isComplete && + !tailIntent.isDragging && + !userOptedOutOfTailFollow.value && + (followsThreadTail.value || threadTailIsVisible()); + if (shouldFollow) followsThreadTail.value = true; + // Positioned-list item positions still describe the old row height in + // this frame. Correct after layout rather than accepting that stale + // tail position as proof the new typing group is visible. + tailIntent.schedule( + allowed: shouldFollow, + revalidate: () => + context.mounted && + itemScrollController.isAttached && + !tailIntent.isDragging && + !userOptedOutOfTailFollow.value && + followsThreadTail.value, + action: correctThreadTailInstantly, + ); + return null; + }, [threadTyping.isNotEmpty]); + void updateComposerDockHeight(double height) { listViewport.reportAfterLayout(); final previousHeight = composerDockHeight.value; @@ -962,6 +994,12 @@ class ThreadDetailPage extends HookConsumerWidget { itemPositionsListener: itemPositionsListener, bottomInset: timelineBottomInset, replies: replies, + knownAgents: threadTyping.isEmpty + ? const {} + : ref.watch(meAgentInventoryProvider).value ?? const {}, + inlineTyping: isMember && !isArchived + ? threadTyping + : const [], relayReplyState: relayReplyState, onRetryReplies: () => ref.invalidate(threadRepliesProvider(repliesArgs)), @@ -995,7 +1033,6 @@ class ThreadDetailPage extends HookConsumerWidget { child: Column( mainAxisSize: MainAxisSize.min, children: [ - _ThreadTypingIndicator(scope: activityScope), ComposeBar( channelId: channelId, focusNode: composerFocusNode, diff --git a/mobile/lib/features/channels/thread_detail_page/message_list.dart b/mobile/lib/features/channels/thread_detail_page/message_list.dart index cfd7572b5d..d5f111cdf5 100644 --- a/mobile/lib/features/channels/thread_detail_page/message_list.dart +++ b/mobile/lib/features/channels/thread_detail_page/message_list.dart @@ -11,6 +11,8 @@ class _ThreadMessageList extends HookWidget { final ItemPositionsListener itemPositionsListener; final double bottomInset; final List replies; + final List inlineTyping; + final Set knownAgents; final AsyncValue> relayReplyState; final VoidCallback onRetryReplies; final Map localSendAnimations; @@ -40,6 +42,8 @@ class _ThreadMessageList extends HookWidget { required this.itemPositionsListener, required this.bottomInset, required this.replies, + required this.inlineTyping, + required this.knownAgents, required this.relayReplyState, required this.onRetryReplies, required this.localSendAnimations, @@ -138,6 +142,12 @@ class _ThreadMessageList extends HookWidget { ], ), ), + if (replies.isEmpty && inlineTyping.isNotEmpty) + InlineAgentTyping( + channelId: channelId, + entries: inlineTyping, + knownAgents: knownAgents, + ), ], ), ); @@ -331,6 +341,13 @@ class _ThreadMessageList extends HookWidget { isMember: isMember, isArchived: isArchived, ), + if (nextReply == null && + inlineTyping.isNotEmpty) + InlineAgentTyping( + channelId: channelId, + entries: inlineTyping, + knownAgents: knownAgents, + ), ], ), ), diff --git a/mobile/lib/features/channels/typing_avatar_cluster.dart b/mobile/lib/features/channels/typing_avatar_cluster.dart new file mode 100644 index 0000000000..44c279c903 --- /dev/null +++ b/mobile/lib/features/channels/typing_avatar_cluster.dart @@ -0,0 +1,129 @@ +import 'package:flutter/material.dart'; +import '../../shared/theme/theme.dart'; + +/// Fixed message-avatar footprint with at most four animated occupants. +class TypingAvatarCluster extends StatelessWidget { + const TypingAvatarCluster({ + super.key, + required this.count, + required this.avatars, + }); + final int count; + final Map avatars; + + @override + Widget build(BuildContext context) { + final duration = MediaQuery.disableAnimationsOf(context) + ? Duration.zero + : Duration(milliseconds: count == 3 || count == 4 ? 420 : 360); + final size = switch (count) { + 1 => messageAvatarSize, + 2 => 28.0, + 3 => 26.0, + _ => 24.0, + }; + final edge = messageAvatarSize - size; + const badgeSize = 26.0; + final positions = switch (count) { + 1 => [Offset.zero], + 2 => [Offset.zero, Offset(edge, edge)], + 3 => [Offset(edge / 2, 0), Offset(0, edge), Offset(edge, edge)], + _ => [Offset.zero, Offset(edge, 0), Offset(0, edge), Offset(edge, edge)], + }; + final children = avatars.entries.take(count > 4 ? 3 : 4).toList(); + return SizedBox( + key: const ValueKey('typing-avatar-cluster'), + width: messageAvatarSize, + height: messageAvatarSize, + child: Stack( + clipBehavior: Clip.none, + children: [ + for (var i = 0; i < children.length; i++) + TweenAnimationBuilder( + key: ValueKey(children[i].key), + tween: _AvatarRectTween( + end: Rect.fromLTWH( + positions[i].dx, + positions[i].dy, + size, + size, + ), + ), + duration: duration, + curve: count == 2 ? Curves.easeOutBack : Curves.easeOutCubic, + builder: (context, rect, child) => + Positioned.fromRect(rect: rect, child: child!), + child: _arrival( + duration: count == 1 ? Duration.zero : duration, + offset: Offset( + positions[i].dx < edge / 2 ? -12 : 12, + positions[i].dy < edge / 2 ? -8 : 8, + ), + child: FittedBox(child: children[i].value), + ), + ), + if (count > 4) + Positioned( + left: messageAvatarSize - badgeSize, + top: messageAvatarSize - badgeSize, + width: badgeSize, + height: badgeSize, + child: _arrival( + duration: duration, + offset: const Offset(12, 8), + child: Container( + key: const ValueKey('typing-avatar-overflow'), + decoration: BoxDecoration( + color: context.colors.secondaryContainer, + shape: BoxShape.circle, + ), + padding: const EdgeInsets.all(2), + child: Center( + child: FittedBox( + fit: BoxFit.scaleDown, + child: Text( + '+${count - 3}', + style: context.textTheme.labelSmall?.copyWith( + fontWeight: FontWeight.w700, + fontSize: 12, + color: context.colors.onSecondaryContainer, + ), + ), + ), + ), + ), + ), + ), + ], + ), + ); + } + + Widget _arrival({ + required Duration duration, + required Offset offset, + required Widget child, + }) { + return TweenAnimationBuilder( + tween: Tween(begin: 0, end: 1), + duration: duration, + curve: count == 2 ? Curves.easeOutBack : Curves.easeOutCubic, + builder: (context, value, child) => Opacity( + opacity: value.clamp(0.0, 1.0), + child: Transform.translate( + offset: offset * (1 - value), + child: Transform.scale(scale: 0.95 + 0.05 * value, child: child), + ), + ), + child: child, + ); + } +} + +/// Interpolate directly between layouts, keeping the first frame stationary. +class _AvatarRectTween extends Tween { + _AvatarRectTween({required Rect end}) : super(begin: end, end: end); + + @override + Rect lerp(double t) => Rect.lerp(begin, end, t)!; +} diff --git a/mobile/lib/features/channels/typing_motion_preview.dart b/mobile/lib/features/channels/typing_motion_preview.dart new file mode 100644 index 0000000000..8272bf905e --- /dev/null +++ b/mobile/lib/features/channels/typing_motion_preview.dart @@ -0,0 +1,119 @@ +import 'dart:async'; +import 'package:flutter/material.dart'; +import 'package:flutter_hooks/flutter_hooks.dart'; +import 'package:hooks_riverpod/hooks_riverpod.dart'; +import '../../shared/profile/user_cache_provider.dart'; +import '../../shared/mentions/agent_identity_provider.dart'; +import '../../shared/theme/theme.dart'; +import '../../shared/widgets/avatar_image.dart'; +import 'channel_typing_provider.dart'; +import 'inline_agent_typing.dart'; + +/// Install-only motion playground. Never publishes typing events. +class TypingMotionPreview extends HookConsumerWidget { + const TypingMotionPreview({super.key, required this.channelId}); + final String channelId; + static const enabled = bool.fromEnvironment('BUZZ_TYPING_PREVIEW'); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final count = useState(1); + final playing = useState(true); + final reduced = MediaQuery.disableAnimationsOf(context); + final profiles = ref.watch(userCacheProvider); + final bots = ref.watch(agentMentionPubkeysProvider(channelId)); + final agentKeys = { + for (final profile in profiles.values) + if (profile.isAgent || bots.contains(profile.pubkey)) profile.pubkey, + ...bots, + }.take(8).toList(); + const demoKeys = ['typing-preview-agent-1', 'typing-preview-agent-2']; + agentKeys.insertAll(0, demoKeys); + final people = profiles.keys + .where( + (key) => !bots.contains(key) && !(profiles[key]?.isAgent ?? false), + ) + .take(8) + .toList(); + // Deliberately alternate shapes, rather than letting cache insertion order + // fill every demo slot with people before an agent can appear. + final keys = []; + for ( + var i = 0; + keys.length < 8 && (i < people.length || i < agentKeys.length); + i++ + ) { + if (i < people.length) keys.add(people[i]); + if (keys.length < 8 && i < agentKeys.length) keys.add(agentKeys[i]); + } + useEffect(() { + if (!playing.value || reduced || keys.isEmpty) return null; + final timer = Timer.periodic(const Duration(seconds: 2), (_) { + count.value = count.value >= keys.length ? 1 : count.value + 1; + }); + return timer.cancel; + }, [playing.value, reduced, keys.length]); + final shown = count.value.clamp(1, keys.isEmpty ? 1 : keys.length); + return Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Flexible( + child: Text( + 'Typing preview · $shown · demo agents', + style: context.textTheme.labelSmall, + ), + ), + IconButton( + tooltip: playing.value ? 'Pause preview' : 'Play preview', + onPressed: () => playing.value = !playing.value, + icon: Icon(playing.value ? Icons.pause : Icons.play_arrow), + ), + IconButton( + tooltip: 'Fewer people', + onPressed: () { + playing.value = false; + count.value = shown > 1 ? shown - 1 : 1; + }, + icon: const Icon(Icons.remove), + ), + IconButton( + tooltip: 'More people', + onPressed: () { + playing.value = false; + count.value = shown < keys.length ? shown + 1 : shown; + }, + icon: const Icon(Icons.add), + ), + ], + ), + if (keys.isNotEmpty) + InlineAgentTyping( + channelId: channelId, + previewAvatars: { + for (final key in demoKeys) + key: AvatarImage( + imageUrl: null, + radius: messageAvatarSize / 2, + isAgent: true, + backgroundColor: context.colors.primaryContainer, + fallback: Icon( + Icons.smart_toy_rounded, + color: context.colors.onPrimaryContainer, + size: 24, + ), + ), + }, + entries: [ + for (final key in keys.take(shown)) + TypingEntry(pubkey: key, expiresAtMs: 0), + ], + ) + else + const Text('Waiting for community profiles…'), + ], + ); + } +} diff --git a/mobile/lib/features/channels/working_now_sheet.dart b/mobile/lib/features/channels/working_now_sheet.dart deleted file mode 100644 index 02fec4bb54..0000000000 --- a/mobile/lib/features/channels/working_now_sheet.dart +++ /dev/null @@ -1,237 +0,0 @@ -import 'package:buzz/shared/theme/buzz_icons.dart'; -import 'package:flutter/material.dart'; -import 'package:flutter_hooks/flutter_hooks.dart'; -import 'package:hooks_riverpod/hooks_riverpod.dart'; - -import '../../shared/profile/user_cache_provider.dart'; -import '../../shared/theme/theme.dart'; -import '../../shared/widgets/modal_presentation.dart'; -import 'agent_activity/agent_activity_sheet.dart'; -import 'channel_identity_names_provider.dart'; -import 'conversation_activity_state.dart'; -import 'small_avatar.dart'; -import 'timeline_message.dart'; - -/// Opens desktop's "Working now" list for [scope]. -Future showWorkingNowSheet( - BuildContext context, { - required ActivityScope scope, - List messages = const [], - void Function(String rootId)? onOpenThread, -}) => showBuzzModalBottomSheet( - context: context, - title: 'Working now', - showDragHandle: true, - builder: (_) => WorkingNowSheet( - scope: scope, - messages: messages, - onOpenThread: onOpenThread, - ), -); - -/// Opens what [pubkey] has been doing in [channelId]. -Future showAgentActivity( - BuildContext context, { - required String channelId, - required String pubkey, -}) => showBuzzModalBottomSheet( - context: context, - isScrollControlled: true, - showDragHandle: true, - builder: (_) => AgentActivitySheet(channelId: channelId, agentPubkey: pubkey), -); - -/// The user's agents working in [scope], where each works, and where each -/// row leads: its thread, or its activity. -class WorkingNowSheet extends HookConsumerWidget { - const WorkingNowSheet({ - super.key, - required this.scope, - this.messages = const [], - this.onOpenThread, - }); - - final ActivityScope scope; - - /// Loaded messages, to name the threads agents work in. - final List messages; - - /// Opens a thread by its root, loaded or not. Null where threads do not - /// open, as in Me. - final void Function(String rootId)? onOpenThread; - - @override - Widget build(BuildContext context, WidgetRef ref) { - final activity = watchConversationActivity(ref, scope); - final agents = activity?.agents ?? const {}; - final idle = activity != null && agents.isEmpty; - // Desktop's list closes with its indicator when the last agent stops. - useEffect(() { - if (!idle) return null; - WidgetsBinding.instance.addPostFrameCallback((_) { - if (context.mounted && (ModalRoute.of(context)?.isCurrent ?? false)) { - Navigator.of(context).pop(); - } - }); - return null; - }, [idle]); - final userCache = ref.watch(userCacheProvider); - final labels = watchChannelIdentityLabels( - ref, - scope.channelId, - agents.keys, - ); - final navigator = Navigator.of(context); - - // Leave the sheet before the next page or sheet opens, once: a second - // tap in the same frame must not pop the page under it. - void then(VoidCallback action) { - if (!(ModalRoute.of(context)?.isCurrent ?? false)) return; - navigator.pop(); - WidgetsBinding.instance.addPostFrameCallback((_) { - if (navigator.mounted) action(); - }); - } - - void viewActivity(String pubkey) => then( - () => showAgentActivity( - navigator.context, - channelId: scope.channelId, - pubkey: pubkey, - ), - ); - - TimelineMessage? message(String id) => messages - .where((message) => message.id.toLowerCase() == id.toLowerCase()) - .firstOrNull; - - // Desktop's scope label: up to two places, then a count. - String? place(List roots) { - if (scope.threadRootId != null) return null; - return [ - for (final root in roots.take(2)) - if (root == null) - 'Channel conversation' - else - 'Thread · ${_preview(message(root)) ?? root.substring(0, 8)}', - if (roots.length > 2) '+${roots.length - 2} more', - ].join(' / '); - } - - return SafeArea( - top: false, - // The sheet's height is capped, so a long list scrolls. - child: SingleChildScrollView( - child: Column( - mainAxisSize: MainAxisSize.min, - children: [ - for (final MapEntry(key: pubkey, value: roots) in agents.entries) - Builder( - builder: (context) { - final name = labels[pubkey] ?? pubkey.substring(0, 10); - final known = roots.whereType().toList(); - final root = - scope.threadRootId ?? - (known.length == 1 ? known.single : null); - // Opening the conversation already shown would do nothing, - // so, as on desktop, the row opens the agent's activity. - final opens = onOpenThread; - final here = root == scope.threadRootId || opens == null; - final open = here - ? () => viewActivity(pubkey) - : () => then(() => opens(root!)); - final where = place(roots); - return Row( - key: ValueKey('working-now-$pubkey'), - children: [ - Expanded( - child: Semantics( - button: true, - label: here - ? 'View $name activity' - : 'Open thread for $name', - onTap: open, - excludeSemantics: true, - child: InkWell( - onTap: open, - child: Padding( - padding: const EdgeInsets.symmetric( - horizontal: Grid.gutter, - vertical: Grid.xxs, - ), - child: Row( - children: [ - SmallAvatar( - pubkey: pubkey, - userCache: userCache, - size: Grid.lg, - agent: true, - ), - const SizedBox(width: Grid.twelve), - Expanded( - child: Column( - crossAxisAlignment: - CrossAxisAlignment.start, - children: [ - Text( - name, - maxLines: 1, - overflow: TextOverflow.ellipsis, - style: context.textTheme.titleSmall, - ), - Text( - 'Working', - style: context.textTheme.bodySmall - ?.copyWith( - color: context - .colors - .onSurfaceVariant, - ), - ), - if (where != null) - Text( - where, - maxLines: 1, - overflow: TextOverflow.ellipsis, - style: context.textTheme.bodySmall - ?.copyWith( - color: context - .colors - .onSurfaceVariant, - ), - ), - ], - ), - ), - ], - ), - ), - ), - ), - ), - if (!here) - Padding( - padding: const EdgeInsets.only(right: Grid.twelve), - child: IconButton( - icon: const Icon(BuzzIcons.activity, size: 18), - tooltip: 'View $name activity', - onPressed: () => viewActivity(pubkey), - ), - ), - ], - ); - }, - ), - ], - ), - ), - ); - } -} - -/// Desktop's thread name: the root's first 60 characters on one line. -String? _preview(TimelineMessage? message) { - final text = message?.content.replaceAll(RegExp(r'\s+'), ' ').trim(); - if (text == null || text.isEmpty) return null; - return text.length > 60 ? text.substring(0, 60) : text; -} diff --git a/mobile/test/features/channels/agent_reply_handoff_test.dart b/mobile/test/features/channels/agent_reply_handoff_test.dart new file mode 100644 index 0000000000..803b919323 --- /dev/null +++ b/mobile/test/features/channels/agent_reply_handoff_test.dart @@ -0,0 +1,56 @@ +import 'package:buzz/features/channels/agent_reply_handoff.dart'; +import 'package:buzz/features/channels/agent_typing_bubble.dart'; +import 'package:buzz/shared/theme/theme.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + for (final reduced in [false, true]) { + testWidgets('reply grows from bubble with reduced motion $reduced', ( + tester, + ) async { + await tester.pumpWidget( + MaterialApp( + theme: AppTheme.light(), + home: MediaQuery( + data: MediaQueryData(disableAnimations: reduced), + child: const Scaffold( + body: Align( + alignment: Alignment.topLeft, + child: AgentReplyHandoff( + animate: true, + child: SizedBox( + key: ValueKey('reply'), + width: 220, + height: 120, + child: Text('Agent reply'), + ), + ), + ), + ), + ), + ), + ); + expect( + find.byType(AgentTypingBubble), + reduced ? findsNothing : findsOneWidget, + ); + if (!reduced) { + final bubble = tester.getSize(find.byType(AnimatedSize)); + expect(bubble.width, lessThan(220)); + await tester.pump(); + await tester.pump(const Duration(milliseconds: 70)); + final during = tester.getSize(find.byType(AnimatedSize)); + expect(during.width, greaterThan(bubble.width)); + expect(during.width, lessThan(220)); + } + await tester.pumpAndSettle(); + expect(find.byType(AgentTypingBubble), findsNothing); + expect(find.text('Agent reply'), findsOneWidget); + expect( + tester.getSize(find.byKey(const ValueKey('reply'))), + const Size(220, 120), + ); + }); + } +} diff --git a/mobile/test/features/channels/channel_detail_page_test.dart b/mobile/test/features/channels/channel_detail_page_test.dart index b11ba1f488..2d4a2636d5 100644 --- a/mobile/test/features/channels/channel_detail_page_test.dart +++ b/mobile/test/features/channels/channel_detail_page_test.dart @@ -1,5 +1,6 @@ import 'package:buzz/features/channels/compose_bar.dart'; import 'package:buzz/features/channels/send_message_provider.dart'; +import 'package:buzz/features/channels/agent_reply_handoff.dart'; import 'package:buzz/features/channels/me/me_messages_provider.dart'; import 'package:buzz/shared/me/me_assistants.dart'; import 'package:buzz/shared/me/me_sync.dart'; @@ -59,9 +60,7 @@ import 'package:buzz/features/channels/message_content.dart'; import 'package:buzz/features/channels/mobile_huddle_controller.dart'; import 'package:buzz/features/channels/reaction_row.dart'; import 'package:buzz/features/channels/message_mention_pill.dart'; -import 'package:buzz/features/channels/agent_activity/agent_activity_sheet.dart'; import 'package:buzz/features/channels/thread_detail_page.dart'; -import 'package:buzz/features/channels/working_now_sheet.dart'; import 'package:buzz/features/channels/thread_replies_provider.dart'; import 'package:buzz/features/channels/timeline_message.dart'; import 'package:buzz/features/channels/channels_provider.dart'; @@ -11091,30 +11090,85 @@ void main() { ); group('Typing indicator', () { - // Desktop's conversation activity above the composer, by its spoken - // status: the user's agents working, then everyone else typing. - Finder activityStatus(String label) => - find.bySemanticsLabel(RegExp('^${RegExp.escape(label)}\$')); + testWidgets('Me reply consumes typing and grows its pending bubble', ( + tester, + ) async { + final now = DateTime.now().millisecondsSinceEpoch ~/ 1000; + final prompt = _textMsg( + id: threadIdRoot, + pubkey: 'self', + content: 'Hello', + createdAt: now - 3, + ); + final typing = _FakeTypingNotifier(const []); + await tester.pumpWidget( + _buildTestable( + personalChat: true, + messages: [prompt], + typingNotifier: typing, + users: const { + 'alice': UserProfile(pubkey: 'alice', displayName: 'Alice'), + }, + ), + ); + await tester.pumpAndSettle(); + typing.setEntries([ + TypingEntry( + pubkey: 'alice', + threadHeadId: threadIdRoot, + expiresAtMs: (now - 1) * 1000 + 8000, + ), + ]); + await tester.pump(); + await tester.pump(const Duration(milliseconds: 250)); + expect(find.byKey(const ValueKey('agent-typing-bubble')), findsOneWidget); + final container = ProviderScope.containerOf( + tester.element(find.byType(ChannelDetailPage)), + ); + final messages = + container.read(meMessagesProvider(_channelId).notifier) + as _FakeMessagesNotifier; + messages.setMessages([ + prompt, + _textMsg( + id: 'reply', + pubkey: 'alice', + content: 'Here is the reply', + createdAt: now, + extraTags: [ + ['e', threadIdRoot, '', 'reply'], + ], + ), + ]); + await tester.pump(); + expect( + find.byKey(const ValueKey('inline-agent-typing-alice')), + findsNothing, + ); + final handoff = tester.widget( + find.byKey(const ValueKey('reply-handoff-reply')), + ); + expect(handoff.animate, isTrue); + await tester.pumpAndSettle(); + expect(find.byKey(const ValueKey('agent-typing-bubble')), findsNothing); + expect(findRichText('Here is the reply'), findsOneWidget); + }); - // As on desktop, thread typing shows above the channel's composer only - // for the user's own agents, in channels and DMs alike. Me counts all of - // its thread typing as its own. for (final scenario in [ - (name: 'Me, a person', personal: true, dm: false, owned: false), - (name: 'channel, a person', personal: false, dm: false, owned: false), - (name: 'channel, your agent', personal: false, dm: false, owned: true), - (name: 'DM, another agent', personal: false, dm: true, owned: false), - (name: 'DM, your agent', personal: false, dm: true, owned: true), + (personal: true, dm: false, agent: true, group: false, visible: true), + (personal: false, dm: false, agent: true, group: false, visible: false), + (personal: false, dm: true, agent: true, group: false, visible: true), + (personal: false, dm: true, agent: false, group: false, visible: false), + (personal: false, dm: true, agent: true, group: true, visible: true), ]) { - testWidgets('thread typing follows desktop scope: ${scenario.name}', ( + testWidgets('reply-scoped typing follows conversation scope: $scenario', ( tester, ) async { final typing = _FakeTypingNotifier(const []); await tester.pumpWidget( _buildTestable( personalChat: scenario.personal, - knownAgentPubkeys: const {'alice'}, - inventoryAgents: scenario.owned ? const {'alice'} : const {}, + knownAgentPubkeys: scenario.agent ? {'alice'} : {}, channel: scenario.dm ? Channel( id: _channelId, @@ -11124,8 +11178,12 @@ void main() { description: '', createdBy: 'self', createdAt: DateTime(2025), - memberCount: 2, - participantPubkeys: const ['self', 'alice'], + memberCount: scenario.group ? 3 : 2, + participantPubkeys: [ + 'self', + 'alice', + if (scenario.group) 'bob', + ], isMember: true, ) : null, @@ -11140,16 +11198,6 @@ void main() { ), ); await tester.pumpAndSettle(); - if (scenario.owned) { - // Until the inventory loads, an agent never shows as a person. - typing.setEntries(const [ - TypingEntry(pubkey: 'alice', expiresAtMs: 9999999999999), - ]); - await tester.pump(); - expect(find.text('Alice is typing'), findsNothing); - await tester.pumpAndSettle(); - expect(activityStatus('Alice is working'), findsOneWidget); - } typing.setEntries(const [ TypingEntry( pubkey: 'alice', @@ -11158,21 +11206,15 @@ void main() { ), ]); await tester.pumpAndSettle(); - final status = scenario.owned - ? 'Alice is working' - : scenario.personal - ? 'Alice is typing' - : null; expect( - status == null - ? find.byKey(const ValueKey('conversation-activity')) - : activityStatus(status), - status == null ? findsNothing : findsOneWidget, + find.byKey(const ValueKey('inline-agent-typing-alice')), + scenario.visible ? findsOneWidget : findsNothing, ); + expect(find.text('Alice is typing…'), findsNothing); typing.setEntries(const []); await tester.pumpAndSettle(); expect( - find.byKey(const ValueKey('conversation-activity')), + find.byKey(const ValueKey('inline-agent-typing-alice')), findsNothing, ); }); @@ -11228,9 +11270,10 @@ void main() { widget.painter.runtimeType.toString() == '_ReplyConnector', ), ); - final activity = find.byKey(const ValueKey('conversation-activity')); - final agentsButton = find.byKey( - const ValueKey('conversation-activity-agents'), + // The channel's typing row, by its line of the spoken status. The + // newest timeline item merges its day label into the same node. + Finder typingRow(String label) => find.bySemanticsLabel( + RegExp('^${RegExp.escape(label)}\$', multiLine: true), ); expect(row, findsNothing); @@ -11246,10 +11289,9 @@ void main() { ]); await tester.pumpAndSettle(); expect(row, findsNothing); - expect(activity, findsNothing); + expect(find.byKey(const ValueKey('agent-typing-bubble')), findsNothing); // As on desktop, at most two working avatars show, then a count. - // Alice types in the channel itself. typing.setEntries([ for (final pubkey in ['morty', 'summer', 'jerry', 'rick']) TypingEntry( @@ -11257,68 +11299,14 @@ void main() { threadHeadId: threadIdRoot, expiresAtMs: (now + 8) * 1000, ), - TypingEntry(pubkey: 'alice', expiresAtMs: (now + 8) * 1000), ]); await tester.pumpAndSettle(); expect( find.bySemanticsLabel('View thread: 3 agents are working'), findsOneWidget, ); - // As on desktop, the composer shows them too, sorted by key, beside - // the people typing in the channel. - expect( - activityStatus('Jerry, Morty, Summer are working. Alice is typing'), - findsOneWidget, - ); - expect( - find.bySemanticsLabel('Activity: 3 agents working'), - findsOneWidget, - ); - expect( - find.descendant(of: activity, matching: find.text('Alice is typing')), - findsOneWidget, - ); - - // Desktop's "Working now" list names each agent's thread and opens it. - await tester.tap(agentsButton); - await tester.pumpAndSettle(); - expect(find.text('Working now'), findsOneWidget); - expect(find.text('Thread · morty say hi'), findsNWidgets(3)); - expect(find.bySemanticsLabel('Open thread for Jerry'), findsOneWidget); - expect(find.byTooltip('View Jerry activity'), findsOneWidget); - await tester.tap(find.bySemanticsLabel('Open thread for Morty')); - await tester.pumpAndSettle(); - expect(find.text('Working now'), findsNothing); - expect( - tester - .widget(find.byType(ThreadDetailPage)) - .threadHead - .id, - threadIdRoot, - ); - // In the thread, everyone typing there shows, and Alice does not. - expect( - activityStatus('Jerry, Morty, Summer are working. Rick is typing'), - findsOneWidget, - ); - // The thread is already open, so its rows open each agent's activity. - await tester.tap(agentsButton); - await tester.pumpAndSettle(); - expect(find.bySemanticsLabel('View Morty activity'), findsOneWidget); - expect(find.bySemanticsLabel('Open thread for Morty'), findsNothing); - expect(find.textContaining('Thread ·'), findsNothing); - await tester.tap(find.bySemanticsLabel('View Morty activity')); - await tester.pumpAndSettle(); - expect( - tester - .widget(find.byType(AgentActivitySheet)) - .agentPubkey, - 'morty', - ); - Navigator.of(tester.element(find.byType(AgentActivitySheet))).pop(); - await tester.pumpAndSettle(); - await tester.pageBack(); - await tester.pumpAndSettle(); + // As on desktop, the channel's typing row shows them too. + expect(typingRow('Morty and 2 others are typing'), findsOneWidget); expect(rowAvatars(), ['morty', 'summer']); // Working avatars use the agent shape, though no profile names an owner. for (final avatar @@ -11362,14 +11350,15 @@ void main() { ), findsOneWidget, ); - expect(activityStatus('Morty is working'), findsOneWidget); - // With no one typing, the status still has a place on screen, so - // screen readers can reach and announce it. + expect(typingRow('Morty is typing'), findsOneWidget); expect( - tester.getSemantics(activityStatus('Morty is working')).rect.height, - greaterThan(0), + tester + .widget( + find.byKey(const ValueKey('inline-agent-typing-morty')), + ) + .isAgent, + isTrue, ); - expect(find.bySemanticsLabel('Activity: Morty working'), findsOneWidget); expect( find.bySemanticsLabel('View thread: Morty is working'), findsOneWidget, @@ -11385,6 +11374,25 @@ void main() { .id, threadIdRoot, ); + // The opened thread shares the inline group, even before its first reply. + expect( + find.byKey(const ValueKey('typing-avatar-cluster')), + findsOneWidget, + ); + expect( + find.byKey(const ValueKey('channel-typing-indicator')), + findsNothing, + ); + expect(find.byKey(const ValueKey('conversation-activity')), findsNothing); + expect(find.text('Morty is typing…'), findsNothing); + expect( + tester + .widget( + find.byKey(const ValueKey('inline-agent-typing-morty')), + ) + .isAgent, + isTrue, + ); await tester.pageBack(); await tester.pumpAndSettle(); @@ -11411,20 +11419,14 @@ void main() { find.bySemanticsLabel('View thread: 1 reply. Morty working'), findsOneWidget, ); - // Like the row, the composer follows Morty's typing, not his reply. - expect(activityStatus('Morty is working'), findsOneWidget); + // The reply ends Morty's pending bubble in the channel. + expect(typingRow('Morty is typing'), findsNothing); // Morty shows once, in the working group, not also as a participant. expect(rowAvatars(), ['morty']); expect(connector, findsOneWidget); - // The list closes with the indicator when the last agent stops. - await tester.tap(agentsButton); - await tester.pumpAndSettle(); - expect(find.text('Working now'), findsOneWidget); typing.setEntries(const []); await tester.pumpAndSettle(); - expect(find.text('Working now'), findsNothing); - expect(activity, findsNothing); expect(row, findsOneWidget); expect(find.byKey(const ValueKey('thread-working-dots')), findsNothing); expect(find.bySemanticsLabel('View thread: 1 reply'), findsOneWidget); @@ -11447,221 +11449,189 @@ void main() { find.bySemanticsLabel('View thread: 1 reply. Summer working'), findsOneWidget, ); - expect(activityStatus('Summer is working'), findsOneWidget); - }); + expect(typingRow('Summer is typing'), findsOneWidget); - testWidgets('many agents in many places: every row acts', (tester) async { - final now = DateTime.now().millisecondsSinceEpoch ~/ 1000; - const unloaded = 'feedfacecafebeef'; - final agents = [for (var i = 1; i <= 6; i++) 'a$i']; - await tester.pumpWidget( - _buildTestable( - messages: [ - _textMsg( - id: threadIdRoot, - pubkey: 'self', - content: 'check the\nbuild', - createdAt: now - 3, - ), - ], - typing: [ - for (final pubkey in agents) - if (pubkey != 'a2' && pubkey != 'a3') - TypingEntry( - pubkey: pubkey, - threadHeadId: threadIdRoot, - expiresAtMs: (now + 60) * 1000, - ), - // A thread whose root is not in the timeline. - TypingEntry( - pubkey: 'a2', - threadHeadId: unloaded, - expiresAtMs: (now + 60) * 1000, - ), - // Three places. - for (final root in [null, threadIdRoot, unloaded]) - TypingEntry( - pubkey: 'a3', - threadHeadId: root, - expiresAtMs: (now + 60) * 1000, - ), - ], - disableAnimations: true, - inventoryAgents: agents.toSet(), - users: { - for (final pubkey in agents) - pubkey: UserProfile( - pubkey: pubkey, - displayName: 'Agent ${pubkey.substring(1)}', - ), - }, - ), - ); - await tester.pumpAndSettle(); - // As on desktop, four avatars show, then a count. - final agentsButton = find.byKey( - const ValueKey('conversation-activity-agents'), - ); - expect( - find.descendant(of: agentsButton, matching: find.byType(SmallAvatar)), - findsNWidgets(4), - ); - expect( - find.descendant(of: agentsButton, matching: find.text('+2')), - findsOneWidget, - ); - expect( - find.bySemanticsLabel('Activity: 6 agents working'), - findsOneWidget, - ); - - Future openList() async { - await tester.tap(agentsButton); - await tester.pumpAndSettle(); - expect(find.text('Working now'), findsOneWidget); - } - - await openList(); - expect( - find.text('Channel conversation / Thread · check the build / +1 more'), - findsOneWidget, - ); - expect(find.text('Thread · feedface'), findsOneWidget); - // Several places open the agent's activity, as on desktop. - expect(find.bySemanticsLabel('View Agent 3 activity'), findsOneWidget); - expect(find.byTooltip('View Agent 3 activity'), findsNothing); - - // The capped sheet scrolls to its last agent, whose row opens its - // thread. - final last = find.bySemanticsLabel('Open thread for Agent 6'); - await tester.scrollUntilVisible( - last, - 48, - scrollable: find.descendant( - of: find.byType(WorkingNowSheet), - matching: find.byType(Scrollable), - ), - ); - await tester.tap(last); - await tester.pumpAndSettle(); - expect(find.text('Working now'), findsNothing); - expect( - tester - .widget(find.byType(ThreadDetailPage)) - .threadHead - .id, - threadIdRoot, - ); - await tester.pageBack(); - await tester.pumpAndSettle(); - - // A thread outside the timeline opens by its root, as a desktop link. - await openList(); - await tester.tap(find.bySemanticsLabel('Open thread for Agent 2')); - await tester.pumpAndSettle(); - expect( - find.byWidgetPredicate( - (widget) => - widget is ChannelDetailPage && - widget.initialThreadRootId == unloaded, - ), - findsOneWidget, - ); - await tester.pageBack(); - await tester.pumpAndSettle(); - - // Each row's activity button opens that agent's activity. - await openList(); - await tester.tap(find.byTooltip('View Agent 1 activity')); - await tester.pumpAndSettle(); - expect(find.text('Working now'), findsNothing); - expect( - tester - .widget(find.byType(AgentActivitySheet)) - .agentPubkey, - 'a1', - ); - }); - - testWidgets("Me opens a lone agent's activity directly", (tester) async { - await tester.pumpWidget( - _buildTestable( - personalChat: true, - messages: [_textMsg(id: 'prompt', pubkey: 'self', content: 'Hi')], - typing: [ - TypingEntry( - pubkey: 'morty', - threadHeadId: 'prompt', - expiresAtMs: DateTime.now().millisecondsSinceEpoch + 60000, - ), - ], - disableAnimations: true, - inventoryAgents: const {'morty'}, - users: const { - 'morty': UserProfile(pubkey: 'morty', displayName: 'Morty'), - }, - ), - ); - await tester.pumpAndSettle(); - await tester.tap( - find.byKey(const ValueKey('conversation-activity-agents')), - ); - await tester.pumpAndSettle(); - expect(find.text('Working now'), findsNothing); - expect( - tester - .widget(find.byType(AgentActivitySheet)) - .agentPubkey, - 'morty', - ); - }); - - for (final (count, text) in [ - (1, 'Alice is typing'), - (2, 'Alice, Bob are typing'), - (3, 'Alice, Bob and 1 others are typing'), - ]) { - testWidgets('people typing show beside the composer: $count', ( - tester, - ) async { - await tester.pumpWidget( - _buildTestable( - messages: [], - typing: [ - for (final pubkey in ['alice', 'bob', 'carol'].take(count)) - TypingEntry( - pubkey: pubkey, - expiresAtMs: DateTime.now().millisecondsSinceEpoch + 8000, - ), - ], - users: const { - 'alice': UserProfile(pubkey: 'alice', displayName: 'Alice'), - 'bob': UserProfile(pubkey: 'bob', displayName: 'Bob'), - 'carol': UserProfile(pubkey: 'carol', displayName: 'Carol'), - }, - disableAnimations: true, + // With replies present, mixed human/agent typing shares one group after + // the last reply, and Back returns to the working reply link. + typing.setEntries([ + for (final pubkey in ['summer', 'alice']) + TypingEntry( + pubkey: pubkey, + threadHeadId: threadIdRoot, + expiresAtMs: (now + 120) * 1000, ), - ); - await tester.pumpAndSettle(); + ]); + await tester.pumpAndSettle(); + await tester.tap(row); + await tester.pumpAndSettle(); + expect( + find.byKey(const ValueKey('typing-avatar-cluster')), + findsOneWidget, + ); + expect( + find.byKey(const ValueKey('inline-agent-typing-alice')), + findsOneWidget, + ); + expect( + find.byKey(const ValueKey('inline-agent-typing-summer')), + findsOneWidget, + ); + expect( + tester + .getTopLeft(find.byKey(const ValueKey('typing-avatar-cluster'))) + .dy, + greaterThan( + tester + .getBottomLeft( + find.descendant( + of: find.byKey(const ValueKey('thread-message-group-reply')), + matching: findRichText('hi'), + ), + ) + .dy, + ), + ); + typing.setEntries(const []); + await tester.pumpAndSettle(); + expect(find.byKey(const ValueKey('typing-avatar-cluster')), findsNothing); + await tester.pageBack(); + await tester.pumpAndSettle(); + expect(find.bySemanticsLabel('View thread: 1 reply'), findsOneWidget); + }); - // Desktop's strings. - expect(activityStatus(text), findsOneWidget); - expect(find.text(text), findsOneWidget); - // Readable on the pill's fill in every theme. - expect( - tester.widget(find.text(text)).style?.color, - Theme.of(tester.element(find.text(text))).colorScheme.onSurface, - ); - expect( - find.byKey(const ValueKey('agent-typing-dot-2')), - findsOneWidget, - ); - expect( - find.byKey(const ValueKey('conversation-activity-agents')), - findsNothing, - ); - }); - } + testWidgets('shows single typer', (tester) async { + await tester.pumpWidget( + _buildTestable( + messages: [], + typing: [ + TypingEntry( + pubkey: 'alice', + expiresAtMs: DateTime.now().millisecondsSinceEpoch + 8000, + ), + ], + users: { + 'alice': const UserProfile(pubkey: 'alice', displayName: 'Alice'), + }, + ), + ); + await tester.pump(); + await tester.pump(const Duration(milliseconds: 200)); + + expect( + find.byKey(const ValueKey('inline-agent-typing-alice')), + findsOneWidget, + ); + expect(find.byKey(const ValueKey('agent-typing-dot-2')), findsOneWidget); + expect( + find.byKey(const ValueKey('channel-typing-indicator')), + findsNothing, + ); + }); + + testWidgets('shows two typers', (tester) async { + await tester.pumpWidget( + _buildTestable( + messages: [], + typing: [ + TypingEntry( + pubkey: 'alice', + expiresAtMs: DateTime.now().millisecondsSinceEpoch + 8000, + ), + TypingEntry( + pubkey: 'bob', + expiresAtMs: DateTime.now().millisecondsSinceEpoch + 8000, + ), + ], + users: { + 'alice': const UserProfile(pubkey: 'alice', displayName: 'Alice'), + 'bob': const UserProfile(pubkey: 'bob', displayName: 'Bob'), + }, + ), + ); + await tester.pump(); + await tester.pump(const Duration(milliseconds: 200)); + + expect(find.byKey(const ValueKey('agent-typing-dot-2')), findsOneWidget); + expect( + find.byKey(const ValueKey('inline-agent-typing-alice')), + findsOneWidget, + ); + expect( + find.byKey(const ValueKey('inline-agent-typing-bob')), + findsOneWidget, + ); + }); + + testWidgets('shares one bubble among three typers', (tester) async { + await tester.pumpWidget( + _buildTestable( + messages: [], + typing: [ + TypingEntry( + pubkey: 'alice', + expiresAtMs: DateTime.now().millisecondsSinceEpoch + 8000, + ), + TypingEntry( + pubkey: 'bob', + expiresAtMs: DateTime.now().millisecondsSinceEpoch + 8000, + ), + TypingEntry( + pubkey: 'carol', + expiresAtMs: DateTime.now().millisecondsSinceEpoch + 8000, + ), + ], + users: { + 'alice': const UserProfile(pubkey: 'alice', displayName: 'Alice'), + 'bob': const UserProfile(pubkey: 'bob', displayName: 'Bob'), + 'carol': const UserProfile(pubkey: 'carol', displayName: 'Carol'), + }, + ), + ); + await tester.pump(); + await tester.pump(const Duration(milliseconds: 200)); + + expect(find.byKey(const ValueKey('agent-typing-dot-2')), findsOneWidget); + expect( + find.byKey(const ValueKey('inline-agent-typing-alice')), + findsOneWidget, + ); + expect( + find.byKey(const ValueKey('inline-agent-typing-bob')), + findsOneWidget, + ); + expect( + find.byKey(const ValueKey('inline-agent-typing-carol')), + findsOneWidget, + ); + }); + + testWidgets('keeps inline dots static when motion is reduced', ( + tester, + ) async { + await tester.pumpWidget( + _buildTestable( + messages: [], + typing: [ + TypingEntry( + pubkey: 'alice', + expiresAtMs: DateTime.now().millisecondsSinceEpoch + 8000, + ), + ], + users: { + 'alice': const UserProfile(pubkey: 'alice', displayName: 'Alice'), + }, + disableAnimations: true, + ), + ); + await tester.pump(); + await tester.pump(); + + expect(find.byKey(const ValueKey('agent-typing-dot-2')), findsOneWidget); + expect( + find.byKey(const ValueKey('channel-typing-shimmer')), + findsNothing, + ); + }); }); group('Compose bar', () { @@ -15641,13 +15611,13 @@ void main() { if (initialHeadTop != null) { expect(tester.getTopLeft(head).dy, closeTo(initialHeadTop, 0.5)); } - if (!layout.isMember || layout.isArchived) { - // As on desktop, a thread without a composer has no activity. - expect( - find.byKey(const ValueKey('conversation-activity')), - findsNothing, - ); - } + expect(tester.getSize(list).height, closeTo(initialHeight, 0.5)); + expect( + find.byKey(const ValueKey('typing-avatar-cluster')), + layout.isMember && !layout.isArchived + ? findsOneWidget + : findsNothing, + ); typingNotifier.setEntries(const []); await tester.pumpAndSettle(); expectTailWithinList(); diff --git a/mobile/test/features/channels/inline_agent_typing_test.dart b/mobile/test/features/channels/inline_agent_typing_test.dart new file mode 100644 index 0000000000..05131c2bd9 --- /dev/null +++ b/mobile/test/features/channels/inline_agent_typing_test.dart @@ -0,0 +1,372 @@ +import 'package:buzz/features/channels/channel_identity_names_provider.dart'; +import 'package:buzz/features/channels/agent_typing_bubble.dart'; +import 'package:buzz/shared/mentions/agent_identity_provider.dart'; +import 'package:buzz/features/channels/channel_typing_provider.dart'; +import 'package:buzz/features/channels/inline_agent_typing.dart'; +import 'package:buzz/features/channels/typing_motion_preview.dart'; +import 'package:buzz/shared/identity_names/identity_names.dart'; +import 'package:buzz/shared/profile/user_cache_provider.dart'; +import 'package:buzz/shared/profile/user_profile.dart'; +import 'package:buzz/shared/theme/theme.dart'; +import 'package:buzz/shared/widgets/avatar_image.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:hooks_riverpod/hooks_riverpod.dart'; + +const agent = + 'aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa'; +const profile = UserProfile(pubkey: agent, displayName: 'Test agent'); + +class Profiles extends UserCacheNotifier { + @override + Map build() => {agent: profile}; +} + +class DemoProfiles extends UserCacheNotifier { + @override + Map build() => { + for (var i = 0; i < 8; i++) + 'person-$i': UserProfile(pubkey: 'person-$i', displayName: 'Person $i'), + agent: profile, + }; +} + +Widget surface({ + bool preview = false, + bool reduced = false, + bool typing = true, + bool bot = true, + List? entries, + double textScale = 1, +}) => ProviderScope( + overrides: [ + agentMentionPubkeysProvider( + 'chat', + ).overrideWithValue(bot ? {agent} : {}), + userCacheProvider.overrideWith(preview ? DemoProfiles.new : Profiles.new), + channelIdentityNamesProvider('chat').overrideWithValue( + const IdentityNameSources(profiles: {agent: profile}).scope([agent]), + ), + ], + child: MaterialApp( + theme: AppTheme.light(), + home: MediaQuery( + data: MediaQueryData( + disableAnimations: reduced, + textScaler: TextScaler.linear(textScale), + ), + child: Scaffold( + body: preview + ? const TypingMotionPreview(channelId: 'chat') + : InlineAgentTyping( + channelId: 'chat', + entries: + entries ?? + [ + if (typing) + const TypingEntry(pubkey: agent, expiresAtMs: 8000), + ], + ), + ), + ), + ), +); +void main() { + testWidgets('one to two keeps the gentle straight repositioning', ( + tester, + ) async { + final entries = [ + for (final key in [agent, 'second']) + TypingEntry(pubkey: key, expiresAtMs: 8000), + ]; + final first = find.byKey(const ValueKey('inline-agent-typing-$agent')); + await tester.pumpWidget(surface(entries: entries.take(1).toList())); + final start = tester.getCenter(first); + await tester.pumpWidget(surface(entries: entries)); + await tester.pump(const Duration(milliseconds: 100)); + final during = tester.getCenter(first); + await tester.pump(const Duration(milliseconds: 600)); + final end = tester.getCenter(first); + final travel = end - start; + final offset = during - start; + expect( + (travel.dx * offset.dy - travel.dy * offset.dx).abs() / travel.distance, + lessThan(0.01), + ); + }); + testWidgets('demo still shows a squircle when agent discovery is empty', ( + tester, + ) async { + await tester.pumpWidget(surface(preview: true, bot: false)); + await tester.tap(find.byTooltip('More people')); + await tester.pump(); + await tester.pump(const Duration(milliseconds: 700)); + final avatars = tester + .widgetList(find.byType(AvatarImage)) + .toList(); + expect(avatars, hasLength(2)); + expect(avatars[0].isAgent, isFalse); + expect(avatars[1].isAgent, isTrue); + expect(find.textContaining('demo agents'), findsOneWidget); + await tester.pumpWidget(const SizedBox.shrink()); + }); + testWidgets('first avatar is immediately still and visible', (tester) async { + await tester.pumpWidget(surface()); + final avatar = find.byType(AvatarImage); + final start = tester.getRect(avatar); + await tester.pump(const Duration(milliseconds: 100)); + expect(tester.getRect(avatar), start); + final opacity = tester.widget( + find.ancestor(of: avatar, matching: find.byType(Opacity)).first, + ); + expect(opacity.opacity, 1); + }); + testWidgets( + 'third arrival moves existing avatars directly without overshoot', + (tester) async { + final entries = [ + for (final key in [agent, 'second', 'third']) + TypingEntry(pubkey: key, expiresAtMs: 8000), + ]; + final first = find.byKey(const ValueKey('inline-agent-typing-$agent')); + await tester.pumpWidget(surface(entries: entries.take(2).toList())); + await tester.pump(const Duration(milliseconds: 700)); + final start = tester.getCenter(first); + await tester.pumpWidget(surface(entries: entries)); + await tester.pump(const Duration(milliseconds: 100)); + final during = tester.getCenter(first); + final samples = [during]; + for (var frame = 0; frame < 8; frame++) { + await tester.pump(const Duration(milliseconds: 40)); + samples.add(tester.getCenter(first)); + } + await tester.pump(const Duration(milliseconds: 280)); + final end = tester.getCenter(first); + final direction = end - start; + var previousProgress = 0.0; + for (final sample in samples) { + final delta = sample - start; + final progress = + (delta.dx * direction.dx + delta.dy * direction.dy) / + direction.distanceSquared; + expect(progress, inInclusiveRange(previousProgress - 0.001, 1.001)); + previousProgress = progress; + } + final travel = end - start; + final offset = during - start; + final perpendicularDistance = + (travel.dx * offset.dy - travel.dy * offset.dx).abs() / + travel.distance; + expect(perpendicularDistance, lessThan(0.01)); + expect(find.byType(AgentTypingBubble), findsOneWidget); + }, + ); + testWidgets( + 'demo includes an agent alongside a person even after eight cached people', + (tester) async { + await tester.pumpWidget(surface(preview: true)); + await tester.tap(find.byTooltip('More people')); + await tester.pump(); + await tester.pump(const Duration(milliseconds: 700)); + final avatars = tester + .widgetList(find.byType(AvatarImage)) + .toList(); + expect(avatars, hasLength(2)); + expect(avatars[0].isAgent, isFalse); + expect(avatars[1].isAgent, isTrue); + expect(find.byIcon(Icons.smart_toy_rounded), findsOneWidget); + await tester.pumpWidget(const SizedBox.shrink()); + }, + ); + testWidgets('overflow stays circular even when replacing an agent', ( + tester, + ) async { + final entries = [ + for (final key in ['first', 'second', 'third', agent, 'fifth']) + TypingEntry(pubkey: key, expiresAtMs: 8000), + ]; + await tester.pumpWidget(surface(entries: entries.take(4).toList())); + await tester.pump(const Duration(milliseconds: 700)); + final avatarRect = tester.getRect( + find.byKey(const ValueKey('inline-agent-typing-$agent')), + ); + await tester.pumpWidget(surface(entries: entries)); + await tester.pump(const Duration(milliseconds: 700)); + final badge = find.byKey(const ValueKey('typing-avatar-overflow')); + final badgeRect = tester.getRect(badge); + expect(badgeRect.width, greaterThan(avatarRect.width)); + expect(badgeRect.size, const Size(26, 26)); + expect(badgeRect.bottomRight, avatarRect.bottomRight); + final decoration = + tester.widget(badge).decoration! as BoxDecoration; + expect(decoration.shape, BoxShape.circle); + expect(decoration.borderRadius, isNull); + expect(decoration.border, isNull); + expect(find.text('+2'), findsOneWidget); + }); + testWidgets('avatar layouts stay in one footprint through joins and leaves', ( + tester, + ) async { + final entries = List.generate( + 5, + (i) => TypingEntry( + pubkey: i.toRadixString(16).padLeft(64, '0'), + expiresAtMs: 8000, + ), + ); + for (final count in [1, 2, 3, 4, 5, 4, 3, 2, 1]) { + await tester.pumpWidget(surface(entries: entries.take(count).toList())); + await tester.pump(const Duration(milliseconds: 700)); + final cluster = tester.getRect( + find.byKey(const ValueKey('typing-avatar-cluster')), + ); + expect(cluster.size, const Size(messageAvatarSize, messageAvatarSize)); + final avatars = find.byType(AvatarImage); + expect(avatars, findsNWidgets(count > 4 ? 3 : count)); + expect(find.byType(AgentTypingBubble), findsOneWidget); + final rects = [ + for (var i = 0; i < (count > 4 ? 3 : count); i++) + tester.getRect(avatars.at(i)), + ]; + for (final rect in rects) { + expect(cluster.contains(rect.center), isTrue); + } + if (count == 2) { + expect(rects[0].left, lessThan(rects[1].left)); + expect(rects[0].top, lessThan(rects[1].top)); + } + if (count == 3) { + expect(rects[0].center.dx, cluster.center.dx); + expect(rects[1].top, rects[2].top); + expect(rects[0].top, lessThan(rects[1].top)); + } + if (count == 4) { + expect(rects[0].top, rects[1].top); + expect(rects[2].top, rects[3].top); + expect(rects[0].left, rects[2].left); + expect(rects[1].left, rects[3].left); + } + if (count == 5) expect(find.text('+2'), findsOneWidget); + expect(tester.takeException(), isNull); + } + }); + testWidgets( + 'message rail, author and dots replace status text without a timestamp', + (tester) async { + await tester.pumpWidget(surface()); + expect(find.text('Test agent'), findsOneWidget); + expect(find.byType(AvatarImage), findsOneWidget); + expect( + tester.widget(find.byType(AvatarImage)).isAgent, + isTrue, + ); + expect(find.textContaining('typing'), findsNothing); + expect(find.byType(Text), findsNWidgets(2)); // Name and avatar initial. + expect(find.byKey(const ValueKey('agent-typing-dot-2')), findsOneWidget); + expect(find.byKey(const ValueKey('agent-typing-dot-0')), findsOneWidget); + final dot = find.byKey(const ValueKey('agent-typing-dot-0')); + final initial = tester + .widget(dot) + .transform + .getTranslation() + .y; + await tester.pump(const Duration(milliseconds: 240)); + expect( + tester.widget(dot).transform.getTranslation().y, + lessThan(initial), + ); + expect( + tester.getTopLeft(find.text('Test agent')).dx, + messageAvatarSize + messageAvatarContentGap + Grid.xs, + ); + await tester.pumpWidget(surface(typing: false)); + expect(find.byType(AvatarImage), findsNothing); + expect(find.text('Working…'), findsNothing); + }, + ); + testWidgets('human typing keeps a round avatar', (tester) async { + final semantics = tester.ensureSemantics(); + await tester.pumpWidget(surface(bot: false)); + expect(find.bySemanticsLabel('Test agent is typing'), findsOneWidget); + expect( + tester.widget(find.byType(AvatarImage)).isAgent, + isFalse, + ); + expect(find.byKey(const ValueKey('agent-typing-dot-2')), findsOneWidget); + semantics.dispose(); + }); + testWidgets('many typers keep rows and active animation owners bounded', ( + tester, + ) async { + final semantics = tester.ensureSemantics(); + final entries = List.generate( + 1024, + (index) => TypingEntry( + pubkey: index.toRadixString(16).padLeft(64, '0'), + expiresAtMs: 8000, + ), + ); + // The first three stay visible while the aggregate grows. Duplicate scopes + // for the same identity must not inflate the remainder count. + for (final count in [3, 4, 1024]) { + final visibleEntries = entries.take(count).toList(); + await tester.pumpWidget( + surface( + entries: [ + ...visibleEntries, + TypingEntry( + pubkey: visibleEntries.last.pubkey.toUpperCase(), + threadHeadId: 'thread', + expiresAtMs: 8000, + ), + ], + textScale: 2, + ), + ); + await tester.pump(const Duration(milliseconds: 700)); + expect(find.byType(AvatarImage), findsNWidgets(count == 4 ? 4 : 3)); + expect(find.byType(AgentTypingBubble), findsOneWidget); + // Each repeating ticker schedules one transient callback for the next + // frame: this measures active work, not just visible dot widgets. + expect(tester.binding.transientCallbackCount, 1); + for (final entry in entries.take(3)) { + expect( + find.byKey(ValueKey('inline-agent-typing-${entry.pubkey}')), + findsOneWidget, + ); + } + if (count > 4) { + final remainder = count - 3; + expect(find.text('+$remainder'), findsOneWidget); + expect( + find.bySemanticsLabel(RegExp('.*and ${count - 1} others are typing')), + findsOneWidget, + ); + } + expect(tester.takeException(), isNull); + } + await tester.pumpWidget(surface(entries: entries.take(1).toList())); + await tester.pump(const Duration(milliseconds: 700)); + expect(find.byType(AvatarImage), findsOneWidget); + expect(find.textContaining('and '), findsNothing); + expect(tester.binding.transientCallbackCount, 1); + await tester.pumpWidget(surface(entries: entries, reduced: true)); + await tester.pumpAndSettle(); + expect(find.byType(AgentTypingBubble), findsOneWidget); + expect(tester.binding.transientCallbackCount, 0); + await tester.pumpWidget(surface(typing: false, reduced: true)); + await tester.pumpAndSettle(); + expect(find.byType(AvatarImage), findsNothing); + expect(tester.binding.transientCallbackCount, 0); + semantics.dispose(); + }); + testWidgets('Reduce Motion leaves static dots and no active animation', ( + tester, + ) async { + await tester.pumpWidget(surface(reduced: true)); + await tester.pumpAndSettle(); + expect(find.byKey(const ValueKey('agent-typing-dot-2')), findsOneWidget); + expect(find.byType(ShaderMask), findsNothing); + expect(tester.binding.hasScheduledFrame, isFalse); + }); +}