diff --git a/mobile/lib/features/channels/agent_activity/agent_activity_sheet.dart b/mobile/lib/features/channels/agent_activity/agent_activity_sheet.dart index 26a53f89038..9eeb61e2991 100644 --- a/mobile/lib/features/channels/agent_activity/agent_activity_sheet.dart +++ b/mobile/lib/features/channels/agent_activity/agent_activity_sheet.dart @@ -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(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; diff --git a/mobile/lib/features/channels/channel_detail_page/message_list.dart b/mobile/lib/features/channels/channel_detail_page/message_list.dart index b05126f314a..b34c255ecea 100644 --- a/mobile/lib/features/channels/channel_detail_page/message_list.dart +++ b/mobile/lib/features/channels/channel_detail_page/message_list.dart @@ -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]); diff --git a/mobile/lib/features/channels/thread_detail_page.dart b/mobile/lib/features/channels/thread_detail_page.dart index fcd89b767a6..5fae681c437 100644 --- a/mobile/lib/features/channels/thread_detail_page.dart +++ b/mobile/lib/features/channels/thread_detail_page.dart @@ -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, diff --git a/mobile/test/features/channels/channel_detail_page_test.dart b/mobile/test/features/channels/channel_detail_page_test.dart index 365befcff07..cf77f6b5952 100644 --- a/mobile/test/features/channels/channel_detail_page_test.dart +++ b/mobile/test/features/channels/channel_detail_page_test.dart @@ -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 {