From d5b96f9a27652d20ae7af181571c92e931e98a7d Mon Sep 17 00:00:00 2001 From: kenny lopez Date: Mon, 17 Aug 2026 17:20:02 +0100 Subject: [PATCH] Preserve agent activity across thread scopes Signed-off-by: kenny lopez --- .../agent_activity/working_bots_provider.dart | 33 ++++++++----- .../features/channels/thread_detail_page.dart | 4 +- .../working_bots_provider_test.dart | 46 +++++++++++++++++++ .../channels/channel_detail_page_test.dart | 6 +++ 4 files changed, 76 insertions(+), 13 deletions(-) diff --git a/mobile/lib/features/channels/agent_activity/working_bots_provider.dart b/mobile/lib/features/channels/agent_activity/working_bots_provider.dart index f3cdb6c09b..fbafe860d8 100644 --- a/mobile/lib/features/channels/agent_activity/working_bots_provider.dart +++ b/mobile/lib/features/channels/agent_activity/working_bots_provider.dart @@ -155,17 +155,28 @@ final composerActivityStateProvider = Provider.autoDispose /// Agent pubkeys working in a channel, for the members badge and sheet. final workingBotPubkeysProvider = Provider.autoDispose .family, String>((ref, channelId) { - final activity = ref.watch( - composerActivityStateProvider(( - channelId: channelId, - threadHeadId: null, - )), - ); - return Set.unmodifiable( - activity.agents - .where((agent) => agent.isWorking) - .map((agent) => agent.pubkey), - ); + final threadScopes = { + null, + for (final entry in ref.watch(channelTypingProvider(channelId))) + entry.threadHeadId, + for (final turn in ref.watch(composerAgentTurnStatesProvider)) + if (turn.channelId == channelId) turn.threadHeadId, + }; + final working = {}; + for (final threadHeadId in threadScopes) { + final activity = ref.watch( + composerActivityStateProvider(( + channelId: channelId, + threadHeadId: threadHeadId, + )), + ); + working.addAll( + activity.agents + .where((agent) => agent.isWorking) + .map((agent) => agent.pubkey), + ); + } + return Set.unmodifiable(working); }); int _compareComposerTurnRecency(AgentTurnState a, AgentTurnState b) { diff --git a/mobile/lib/features/channels/thread_detail_page.dart b/mobile/lib/features/channels/thread_detail_page.dart index d3e328b268..a3a771f16d 100644 --- a/mobile/lib/features/channels/thread_detail_page.dart +++ b/mobile/lib/features/channels/thread_detail_page.dart @@ -724,7 +724,7 @@ class ThreadDetailPage extends HookConsumerWidget { if (!isMember || isArchived) _ThreadTypingIndicator( channelId: channelId, - threadHeadId: threadHead.id, + threadHeadId: effectiveRootId, animated: false, overlayTopBoundary: frostedAppBarHeight(context), ), @@ -751,7 +751,7 @@ class ThreadDetailPage extends HookConsumerWidget { restoreComposerFocus, ) => _ThreadTypingIndicator( channelId: channelId, - threadHeadId: threadHead.id, + threadHeadId: effectiveRootId, horizontalInset: 0, overlayTopBoundary: frostedAppBarHeight(context), compactWidthFactor: 0.85, diff --git a/mobile/test/features/channels/agent_activity/working_bots_provider_test.dart b/mobile/test/features/channels/agent_activity/working_bots_provider_test.dart index 7d37c95be2..9e3901f9ff 100644 --- a/mobile/test/features/channels/agent_activity/working_bots_provider_test.dart +++ b/mobile/test/features/channels/agent_activity/working_bots_provider_test.dart @@ -259,6 +259,52 @@ void main() { expect(container.read(workingBotPubkeysProvider(_channelId)), isEmpty); }); + test('channel working set includes agents from every thread scope', () { + final container = ProviderContainer( + overrides: [ + currentPubkeyProvider.overrideWith((ref) => 'owner'), + channelMembersProvider( + _channelId, + ).overrideWith((ref) async => const []), + channelTypingProvider(_channelId).overrideWith( + () => _FakeTypingNotifier(const [ + TypingEntry( + pubkey: 'agent-a', + threadHeadId: 'thread-1', + expiresAtMs: 9999999999999, + ), + TypingEntry( + pubkey: 'human', + threadHeadId: 'thread-3', + expiresAtMs: 9999999999999, + ), + ]), + ), + agentMentionPubkeysProvider( + _channelId, + ).overrideWith((ref) => const {'agent-a', 'agent-b'}), + agentOwnersProvider.overrideWithValue( + const AsyncData({'agent-a': 'owner', 'agent-b': 'owner'}), + ), + userCacheProvider.overrideWith(_FakeUserCacheNotifier.new), + observerRelayProvider.overrideWith( + () => _FakeObserverRelayNotifier({ + 'agent-b': [_observerFrame('agent-b')], + }), + ), + composerAgentTurnStatesProvider.overrideWithValue([ + _turn('agent-b', threadHeadId: 'thread-2'), + ]), + ], + ); + addTearDown(container.dispose); + + expect(container.read(workingBotPubkeysProvider(_channelId)), { + 'agent-a', + 'agent-b', + }); + }); + test('prefers live work over retained terminal history', () { final receiptAt = DateTime.utc(2026, 8, 16, 12, 0, 5); final container = ProviderContainer( diff --git a/mobile/test/features/channels/channel_detail_page_test.dart b/mobile/test/features/channels/channel_detail_page_test.dart index df44a0efb5..6d49910775 100644 --- a/mobile/test/features/channels/channel_detail_page_test.dart +++ b/mobile/test/features/channels/channel_detail_page_test.dart @@ -14,6 +14,7 @@ import 'package:lucide_icons_flutter/lucide_icons.dart'; import 'package:scrollable_positioned_list/scrollable_positioned_list.dart'; import 'package:buzz/features/channels/channel.dart'; import 'package:buzz/features/channels/agent_activity/active_agent_turns.dart'; +import 'package:buzz/features/channels/agent_activity/composer_agent_activity_indicator.dart'; import 'package:buzz/features/channels/agent_activity/observer_models.dart'; import 'package:buzz/features/channels/agent_activity/observer_subscription.dart'; import 'package:buzz/features/channels/agent_activity/working_bots_provider.dart'; @@ -4561,6 +4562,11 @@ void main() { expect(threadPage.threadHead.id, 'parent'); expect(threadPage.initialMessageId, 'target'); + final activityIndicator = tester.widget( + find.byType(ComposerAgentActivityIndicator), + ); + expect(activityIndicator.threadHeadId, 'root'); + final highlighted = tester.widget( find.byKey(const ValueKey('thread-message-target')), );