fix(mobile): restore inline typing groups and keep working thread links (#8865)

Restores mobile’s shared inline typing bubble and animated avatar
cluster after #8765 replaced them with composer activity. Humans and
agents share the group, including the existing overflow badge and
reduced-motion behavior; fresh replies regain the bubble-to-message
handoff.

Keeps the working-agent reply link from #8754, including its current DM
support: a root message can show working agents and open its thread
before the first reply. Thread timelines now use the same inline typing
group after their last message, so reverting the redesign does not bring
back the old full-width typing bar. Archived and non-member
conversations retain their existing no-typing-indicator behavior.

This intentionally follows the requested mobile presentation rather than
buzz-app’s composer activity layout. VISION_MOBILE.md allows
platform-specific presentation. The composer’s “Working now” list is
removed with the redesign; the root-message thread links remain.

Validation:
- At `5df48ad4b`: `just mobile-install`, `just mobile-check` and `just
mobile-test` passed (4,036 Flutter tests passed, 30 skipped;
unconfigured-push checks and 27 crypto tests also passed). Repository
file-size checks passed.
- Required pre-push hooks passed, including a second full mobile
check/test run and the repository file-size gate. Hosted CI is pending.
- Extended the existing thread journey through opening an empty working
thread, mixed human/agent typing after a reply, clearing typing, and
Back. Existing coverage exercises avatar grouping, overflow, reduced
motion, reply handoff, and followed/detached thread scrolling.
- Agent self-review completed; no additional blockers found after
correcting inventory-only agent avatar shapes and waiting for timeline
layout before following new thread typing.
- Simulator component preview uses synthetic identities in a local,
uncommitted entrypoint. It is not evidence of a live relay exchange.

Human verification pending (draft):
1. In a channel or DM, have an agent work on a threaded reply. Confirm
the root’s working-agent reply link opens that thread before a reply
arrives.
2. Have multiple people/agents type. Confirm one inline avatar group and
dot bubble, including inside the opened thread, with no wide composer
bar.
3. Receive a fresh reply in the main conversation/Solo and check the
typing-to-message handoff. In a long thread, check typing while
following the latest message and while scrolled up, then navigate Back.

Component preview on iPhone 18 Pro (iOS 27.0 simulator). These are
production widgets in an uncommitted preview shell with synthetic
identities; surrounding screen chrome is not a full-app screenshot.

![Inline group together with the retained working thread
link](https://github.com/user-attachments/assets/28f57a27-9319-4d27-a965-24d0dca3b620)

![Five typers use the circular overflow
badge](https://github.com/user-attachments/assets/5912862e-ac5a-48f7-a79c-39c2e2231d24)

---------

Signed-off-by: kenny lopez <klopez4212@gmail.com>
Co-authored-by: Terp <5856f6900e8e0a4e0ea1f4f8d2ee33e4753d2119bc122f94ecafc660b46c0511@buzz.block.builderlab.xyz>
This commit is contained in:
klopez4212
2026-10-31 16:09:30 +00:00
committed by GitHub
parent 0a46f329c0
commit 120cbae8e5
18 changed files with 1441 additions and 1059 deletions

View File

@@ -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<String, int> messageIds, List<TypingEntry> typing}) useAgentReplyHandoffs(
String channelId,
List<TypingEntry> typing,
List<TimelineMessage> 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<String, int> entries, String id) =>
(entries[id] ?? 0) > DateTime.now().millisecondsSinceEpoch;
class _ReplyHandoffs {
Set<String>? seen;
final pending = <(String, String?), TypingEntry>{};
final completed = <(String, String?), int>{};
final handoffs = <String, int>{};
({Map<String, int> messageIds, List<TypingEntry> typing}) update(
List<TypingEntry> typing,
List<TimelineMessage> 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(),
);
}
}

View File

@@ -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()),
);
}
}

View File

@@ -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 <String>{},
);
// 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,

View File

@@ -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<TimelineMessage> 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<void>(
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,
);
}
}

View File

@@ -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<void>(
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<void>(
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,
),
),
),
),
],
),

View File

@@ -3,6 +3,7 @@ part of '../channel_detail_page.dart';
class _MessageList extends HookConsumerWidget {
final ValueNotifier<StickyDateHeaderState> stickyDateHeaderState;
final List<MainTimelineEntry> entries;
final List<TypingEntry> inlineTyping;
/// Agents typing in each thread, by lowercase root message ID.
final Map<String, List<String>> 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,
),
],
),
),

View File

@@ -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<TimelineMessage> 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<String> 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,
),
),
),
],
),
),
),
),
);
}
}

View File

@@ -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<String, List<String?>> agents,
/// Everyone else typing in this conversation itself, in first-seen order.
List<String> typers,
});
const _idle = (agents: <String, List<String?>>{}, typers: <String>[]);
/// 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 <String>{})
pubkey.toLowerCase(),
};
final agents = <String, List<String?>>{};
final typers = <String>[];
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,
],
);
}

View File

@@ -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<TypingEntry> entries;
final Map<String, Widget> previewAvatars;
/// Lowercase pubkeys known to be agents, such as the user's own.
final Set<String> 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(),
],
),
),
],
),
),
),
);
}
}

View File

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

View File

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

View File

@@ -11,6 +11,8 @@ class _ThreadMessageList extends HookWidget {
final ItemPositionsListener itemPositionsListener;
final double bottomInset;
final List<TimelineMessage> replies;
final List<TypingEntry> inlineTyping;
final Set<String> knownAgents;
final AsyncValue<List<NostrEvent>> relayReplyState;
final VoidCallback onRetryReplies;
final Map<String, DateTime> 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,
),
],
),
),

View File

@@ -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<String, Widget> 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<Rect>(
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<double>(
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<Rect> {
_AvatarRectTween({required Rect end}) : super(begin: end, end: end);
@override
Rect lerp(double t) => Rect.lerp(begin, end, t)!;
}

View File

@@ -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 = <String>[];
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…'),
],
);
}
}

View File

@@ -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<void> showWorkingNowSheet(
BuildContext context, {
required ActivityScope scope,
List<TimelineMessage> messages = const [],
void Function(String rootId)? onOpenThread,
}) => showBuzzModalBottomSheet<void>(
context: context,
title: 'Working now',
showDragHandle: true,
builder: (_) => WorkingNowSheet(
scope: scope,
messages: messages,
onOpenThread: onOpenThread,
),
);
/// Opens what [pubkey] has been doing in [channelId].
Future<void> showAgentActivity(
BuildContext context, {
required String channelId,
required String pubkey,
}) => showBuzzModalBottomSheet<void>(
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<TimelineMessage> 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<String?> 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<String>().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;
}

View File

@@ -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),
);
});
}
}

View File

@@ -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<AgentReplyHandoff>(
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'} : <String>{},
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<ThreadDetailPage>(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<AgentActivitySheet>(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<AvatarImage>(
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<AvatarImage>(
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<void> 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<ThreadDetailPage>(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<AgentActivitySheet>(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<AgentActivitySheet>(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<Text>(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();

View File

@@ -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<String, UserProfile> build() => {agent: profile};
}
class DemoProfiles extends UserCacheNotifier {
@override
Map<String, UserProfile> 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<TypingEntry>? entries,
double textScale = 1,
}) => ProviderScope(
overrides: [
agentMentionPubkeysProvider(
'chat',
).overrideWithValue(bot ? {agent} : <String>{}),
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<AvatarImage>(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<Opacity>(
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 = <Offset>[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<AvatarImage>(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<Container>(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<AvatarImage>(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<Transform>(dot)
.transform
.getTranslation()
.y;
await tester.pump(const Duration(milliseconds: 240));
expect(
tester.widget<Transform>(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<AvatarImage>(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);
});
}