fix(mobile): show agent DM typing and balance reply bubble padding (#8731)

Messages with a reply footer now keep even vertical text padding unless
they have actual overlapping reactions. Agent typing indicators are now
visible in both one-to-one and group DMs when the response is
thread-scoped, regardless of Bestie selection.

The typing fix originated in #8665 and now applies to every agent DM
using the existing agent classification. It retains main's
`visibleTypingProvider` filtering. Ordinary channels and human
thread-typing signals retain their existing scope; voice recording is
not included.

Validation: required pre-push hooks passed, including mobile formatting,
analysis, tests, native crypto package tests, and file-size checks.
Flutter analysis and the full mobile suite pass (3,995 passed, 28
skipped). Widget coverage compares bubble heights with and without a
reply footer and checks typing across Solo, ordinary channels, agent
DMs, human DMs, and group DMs with agents.

Marked ready for review at the owner’s request. Device verification
remains pending. To try: open a DM with any agent, including one not
selected as Bestie, while it handles a thread-scoped response and check
for its avatar and three dots; repeat in a group DM with an agent;
compare equal text in messages with and without a reply footer, with no
reactions, and confirm equal bubble heights.

The broad `just ci` check was stopped during the unrelated desktop Rust
dependency build after the mobile pre-push checks passed; no
full-repository CI pass is claimed.



### Before / after

Flutter widget-harness snapshots with identical synthetic data; not
native device captures. Before: the reply-footer bubble has excess
bottom padding and the thread-scoped Bestie typing signal is hidden.
After: both text bubbles have equal height, and Bestie’s avatar and
three dots are visible.

| Before | After |
| --- | --- |
| ![Before: uneven bubble padding and hidden Bestie
typing](https://github.com/user-attachments/assets/b7b9279d-4b6d-4263-8b4c-91451ee57fd0)
| ![After: balanced bubbles and visible Bestie
typing](https://github.com/user-attachments/assets/c97be4fd-5fc0-47e5-8b78-5b0b9c60254a)
|

---------

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-30 15:09:07 +00:00
committed by GitHub
parent cc04cdca1a
commit aae0081a90
4 changed files with 79 additions and 17 deletions

View File

@@ -362,17 +362,6 @@ class _ResolvedChannelDetailPage extends HookConsumerWidget {
initialForcedUnreadMessageIdsRef.value;
// Ownership comes from the signing identity, not reloadable profile metadata.
final currentPubkey = ref.watch(currentPubkeyProvider);
// Me flattens replies into this timeline, so include their typing signals.
// Ordinary channels keep thread activity inside the thread view.
final typingEntries = ref
.watch(
visibleTypingProvider(
channel.id,
selfPubkey: currentPubkey,
include: (e) => personalChat || e.threadHeadId == null,
),
)
.entries;
final baseChannel = listedChannel ?? channel;
final resolvedChannel =
detailsAsync.whenData(baseChannel.mergeDetails).value ?? baseChannel;
@@ -453,6 +442,21 @@ class _ResolvedChannelDetailPage extends HookConsumerWidget {
channelBotPubkeys:
channelBotPubkeysState.asData?.value ?? const <String>{},
);
// Agent work is visible in every DM, even when its response is threaded.
// Ordinary channels keep thread activity inside the thread view.
final typingEntries = ref
.watch(
visibleTypingProvider(
channel.id,
selfPubkey: currentPubkey,
include: (e) =>
personalChat ||
e.threadHeadId == null ||
(resolvedChannel.isDm &&
agentPubkeys.contains(e.pubkey.toLowerCase())),
),
)
.entries;
useEffect(() {
if (sessionStatus != SessionStatus.connected ||
identitySubscriptionPubkeys.isEmpty) {

View File

@@ -349,8 +349,7 @@ class _MessageBubble extends HookConsumerWidget {
content: message.content,
bubble: true,
hasReactions:
message.reactions.isNotEmpty ||
replyFooter != null,
message.reactions.isNotEmpty,
bubbleEnd: showAvatar,
bubbleStart: showAuthor,
ownMessage: ownMessage,

View File

@@ -11120,14 +11120,39 @@ void main() {
expect(findRichText('Here is the reply'), findsOneWidget);
});
for (final personal in [true, false]) {
testWidgets('reply-scoped typing is visible only in flat Me: $personal', (
for (final scenario in [
(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('reply-scoped typing follows conversation scope: $scenario', (
tester,
) async {
final typing = _FakeTypingNotifier(const []);
await tester.pumpWidget(
_buildTestable(
personalChat: personal,
personalChat: scenario.personal,
knownAgentPubkeys: scenario.agent ? {'alice'} : <String>{},
channel: scenario.dm
? Channel(
id: _channelId,
name: 'DM',
channelType: 'dm',
visibility: 'private',
description: '',
createdBy: 'self',
createdAt: DateTime(2025),
memberCount: scenario.group ? 3 : 2,
participantPubkeys: [
'self',
'alice',
if (scenario.group) 'bob',
],
isMember: true,
)
: null,
messages: [
_textMsg(id: 'prompt', pubkey: 'self', content: 'Hello'),
],
@@ -11149,7 +11174,7 @@ void main() {
await tester.pumpAndSettle();
expect(
find.byKey(const ValueKey('inline-agent-typing-alice')),
personal ? findsOneWidget : findsNothing,
scenario.visible ? findsOneWidget : findsNothing,
);
expect(find.text('Alice is typing…'), findsNothing);
typing.setEntries(const []);

View File

@@ -1,6 +1,40 @@
part of '../channel_detail_page_test.dart';
void bubbleLayoutTests() {
testWidgets('reply footer does not add reaction padding inside the bubble', (
tester,
) async {
await tester.pumpWidget(
_buildTestable(
messages: [
_textMsg(id: 'plain', pubkey: 'alice', content: 'Same text'),
_textMsg(id: threadIdRoot, pubkey: 'alice', content: 'Same text'),
_textMsg(
id: threadIdR1,
pubkey: 'bob',
content: 'Reply',
extraTags: [
['e', threadIdRoot, '', 'reply'],
],
),
],
),
);
await tester.pumpAndSettle();
Finder bubble(String id) => find
.descendant(
of: find.byKey(ValueKey('message-row-$id')),
matching: find.byType(LocalSendBubbleSurface),
)
.first;
expect(find.text('1 reply'), findsOneWidget);
expect(
tester.getSize(bubble(threadIdRoot)).height,
closeTo(tester.getSize(bubble('plain')).height, 0.01),
);
expect(tester.takeException(), isNull);
});
for (final author in ['self', 'alice']) {
testWidgets('date divider owns equal space around $author messages', (
tester,