Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -39,23 +39,34 @@ class AgentActivitySheet extends HookConsumerWidget {
);
final botName = profile?.label ?? shortPubkey(agentPubkey);

// Auto-scroll to bottom on new items.
// Follow live activity only while the user is already reading its tail.
// New bot items are passive updates, so correcting with an animation at
// max extent makes iOS repeatedly rubber-band at the bottom.
final sheetControllerRef = useRef<ScrollController?>(null);
final previousLength = useRef(0);
final autoScrollQueued = useRef(false);

useEffect(() {
final sc = sheetControllerRef.value;
if (transcript.length > previousLength.value &&
sc != null &&
sc.hasClients) {
sc.hasClients &&
!autoScrollQueued.value) {
final position = sc.position;
final wasAtTail =
position.maxScrollExtent - position.pixels <= 1;
if (!wasAtTail) {
previousLength.value = transcript.length;
return null;
}
final previousPixels = position.pixels;
autoScrollQueued.value = true;
WidgetsBinding.instance.addPostFrameCallback((_) {
if (sc.hasClients) {
sc.animateTo(
sc.position.maxScrollExtent,
duration: const Duration(milliseconds: 150),
curve: Curves.easeOut,
);
}
autoScrollQueued.value = false;
if (!sc.hasClients || sc.position.pixels + 1 < previousPixels) return;
final maxScrollExtent = sc.position.maxScrollExtent;
if (maxScrollExtent - sc.position.pixels <= 1) return;
sc.jumpTo(maxScrollExtent);
});
}
previousLength.value = transcript.length;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -350,7 +350,16 @@ class _MessageList extends HookConsumerWidget {
return null;
}
WidgetsBinding.instance.addPostFrameCallback((_) {
if (context.mounted) scrollToLatest();
// Incoming messages are passive layout updates, not user navigation.
// In a reversed list an already-pinned tail normally stays put; if it
// needs correction, realign with an instant jump rather than restarting
// a 220ms bottom-edge animation for every agent event on iOS.
if (!context.mounted ||
!followsLatest.value ||
hasUserScrolled.value) {
return;
}
realignLatestAfterLayoutChange();
});
return null;
}, [latestEntryId]);
Expand Down
5 changes: 5 additions & 0 deletions mobile/lib/features/channels/thread_detail_page.dart
Original file line number Diff line number Diff line change
Expand Up @@ -213,6 +213,11 @@ class ThreadDetailPage extends HookConsumerWidget {
userOptedOutOfTailFollow.value = false;
followsThreadTail.value = true;
}
// Rapid agent replies can arrive while the newly laid-out tail is
// already visible. There is nothing to navigate to in that case;
// restarting a bottom-edge animation for every reply produces iOS
// rubber-banding.
if (threadTailIsVisible()) return;
if (animate) {
itemScrollController.scrollTo(
index: lastIndex,
Expand Down
56 changes: 56 additions & 0 deletions mobile/test/features/channels/channel_detail_page_test.dart
Original file line number Diff line number Diff line change
Expand Up @@ -2491,6 +2491,62 @@ void main() {
);
});

testWidgets('does not animate an already pinned tail for rapid live updates', (
tester,
) async {
final initialMessages = [
for (var i = 0; i < 20; i++)
_textMsg(
id: 'msg$i',
pubkey: 'alice',
content: 'Message $i',
createdAt: 1000 + i,
),
];
final messagesNotifier = _FakeMessagesNotifier(initialMessages);

await tester.pumpWidget(
_buildTestable(
messages: const [],
messagesNotifier: messagesNotifier,
users: const {
'alice': UserProfile(pubkey: 'alice', displayName: 'Alice'),
},
),
);
await tester.pumpAndSettle();

final composerDock = find.byKey(const ValueKey('channel-composer-dock'));
for (var i = 0; i < 5; i++) {
final latestId = 'agent-update-$i';
messagesNotifier.setMessages([
...initialMessages,
for (var previous = 0; previous <= i; previous++)
_textMsg(
id: 'agent-update-$previous',
pubkey: 'agent',
content: 'Agent update $previous',
createdAt: 2000 + previous,
),
]);
await tester.pump();
await tester.pump();

final latestMessage = find.byKey(
ValueKey('channel-message-group-$latestId'),
);
expect(
tester.getBottomLeft(latestMessage).dy,
closeTo(tester.getTopLeft(composerDock).dy, 1),
);
expect(
find.byKey(const ValueKey('channel-jump-to-latest')),
findsNothing,
);
}
await tester.pumpAndSettle();
});

testWidgets(
'keeps follow mode off while a tall newest message stays visible',
(tester) async {
Expand Down