diff --git a/mobile/lib/features/channels/agent_activity/composer_agent_activity_indicator.dart b/mobile/lib/features/channels/agent_activity/composer_agent_activity_indicator.dart index 59e17da61..8ab33933f 100644 --- a/mobile/lib/features/channels/agent_activity/composer_agent_activity_indicator.dart +++ b/mobile/lib/features/channels/agent_activity/composer_agent_activity_indicator.dart @@ -495,6 +495,7 @@ class ComposerAgentActivityIndicator extends HookConsumerWidget { observerState: observerState, transcript: transcript, profiles: profiles, + signals: viewableWorking, selectorAgents: selectorAgents, selectedAgent: effectiveSelectedAgent, nameFor: nameFor, @@ -619,6 +620,7 @@ class _InlineActivityPanel extends StatelessWidget { final ObserverState? observerState; final List transcript; final Map profiles; + final List signals; final List selectorAgents; final String selectedAgent; final String Function(String) nameFor; @@ -639,6 +641,7 @@ class _InlineActivityPanel extends StatelessWidget { required this.observerState, required this.transcript, required this.profiles, + required this.signals, required this.selectorAgents, required this.selectedAgent, required this.nameFor, @@ -655,6 +658,7 @@ class _InlineActivityPanel extends StatelessWidget { final headline = _selectedActivityHeadline(selectedTurn, transcript); final compactLabel = _agentActivityLabel( pubkeys: selectorAgents, + signals: signals, selectedTurn: selectedTurn, transcript: transcript, nameFor: nameFor, diff --git a/mobile/lib/features/channels/agent_activity/composer_agent_activity_indicator/agent_activity_controls.dart b/mobile/lib/features/channels/agent_activity/composer_agent_activity_indicator/agent_activity_controls.dart index dfda077f4..68b380ae1 100644 --- a/mobile/lib/features/channels/agent_activity/composer_agent_activity_indicator/agent_activity_controls.dart +++ b/mobile/lib/features/channels/agent_activity/composer_agent_activity_indicator/agent_activity_controls.dart @@ -229,6 +229,7 @@ class _AgentActivityControl extends StatelessWidget { : [?selectedAgent]; final label = _agentActivityLabel( pubkeys: pubkeys, + signals: signals, selectedTurn: selectedTurn, transcript: transcript, nameFor: nameFor, @@ -393,16 +394,47 @@ _ActivityStatus _activityStatus(AgentTurnState? turn, bool isFallbackWorking) { ({String visibleLabel, String semanticLabel}) _agentActivityLabel({ required List pubkeys, + required List signals, required AgentTurnState? selectedTurn, required List transcript, required String Function(String) nameFor, required bool expanded, }) { final action = expanded ? 'Collapse live activity.' : 'Show live activity.'; + if (pubkeys.length > 1 && signals.length == pubkeys.length) { + final working = signals.where((signal) => signal.isWorking).length; + final errors = signals + .where((signal) => signal.phase == AgentTurnPhase.error) + .length; + final finished = signals.length - working - errors; + if (working == signals.length) { + return ( + visibleLabel: '${signals.length} agents are working…', + semanticLabel: '${signals.length} agents are working. $action', + ); + } + final visibleParts = [ + if (working > 0) '$working working', + if (finished > 0) '$finished finished', + if (errors > 0) '$errors ${errors == 1 ? 'error' : 'errors'}', + ]; + final semanticParts = [ + if (working > 0) + '$working ${working == 1 ? 'agent is' : 'agents are'} working', + if (finished > 0) + '$finished ${finished == 1 ? 'agent has' : 'agents have'} finished', + if (errors > 0) + '$errors ${errors == 1 ? 'agent stopped' : 'agents stopped'} with ${errors == 1 ? 'an error' : 'errors'}', + ]; + return ( + visibleLabel: visibleParts.join(' · '), + semanticLabel: '${semanticParts.join(', ')}. $action', + ); + } if (pubkeys.length > 1) { return ( - visibleLabel: '${pubkeys.length} agents are working…', - semanticLabel: '${pubkeys.length} agents are working. $action', + visibleLabel: '${pubkeys.length} agents have activity', + semanticLabel: '${pubkeys.length} agents have activity. $action', ); } final name = pubkeys.isEmpty ? 'Agent' : nameFor(pubkeys.single); 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 c7a7fcaf0..1a27d885a 100644 --- a/mobile/lib/features/channels/agent_activity/working_bots_provider.dart +++ b/mobile/lib/features/channels/agent_activity/working_bots_provider.dart @@ -22,6 +22,9 @@ class WorkingAgentSignal { /// Whether this signal represents live work rather than a retained outcome. final bool isWorking; + + /// Known observer phase; typing-only signals have no lifecycle phase yet. + final AgentTurnPhase? phase; final String? turnId; final DateTime? startedAt; @@ -30,6 +33,7 @@ class WorkingAgentSignal { required this.source, required this.canViewActivity, this.isWorking = true, + this.phase, this.turnId, this.startedAt, }); @@ -105,6 +109,7 @@ final composerActivityStateProvider = Provider.autoDispose source: AgentWorkingSource.observer, canViewActivity: canView(entry.key), isWorking: turn.isWorking, + phase: turn.phase, turnId: turn.turnId, startedAt: turn.startedAt, ); @@ -121,13 +126,15 @@ final composerActivityStateProvider = Provider.autoDispose } if (signals.containsKey(pubkey)) continue; final turn = activeByAgent[pubkey]; + final liveTurn = turn?.isWorking == true ? turn : null; signals[pubkey] = WorkingAgentSignal( pubkey: pubkey, source: AgentWorkingSource.typing, canViewActivity: canView(pubkey), isWorking: true, - turnId: turn?.turnId, - startedAt: turn?.startedAt, + phase: liveTurn?.phase, + turnId: liveTurn?.turnId, + startedAt: liveTurn?.startedAt, ); } diff --git a/mobile/test/features/channels/agent_activity/composer_agent_activity_indicator_test.dart b/mobile/test/features/channels/agent_activity/composer_agent_activity_indicator_test.dart index 510578ed3..e8391689b 100644 --- a/mobile/test/features/channels/agent_activity/composer_agent_activity_indicator_test.dart +++ b/mobile/test/features/channels/agent_activity/composer_agent_activity_indicator_test.dart @@ -206,6 +206,105 @@ void main() { expect(find.text('Thinking'), findsOneWidget); }); + testWidgets('summarizes mixed working and terminal agent states', ( + tester, + ) async { + final container = ProviderContainer( + overrides: [ + composerActivityStateProvider(_scope).overrideWithValue( + const ComposerActivityState( + agents: [ + WorkingAgentSignal( + pubkey: _agentPubkey, + source: AgentWorkingSource.observer, + canViewActivity: true, + phase: AgentTurnPhase.working, + turnId: _turnId, + ), + WorkingAgentSignal( + pubkey: _secondAgentPubkey, + source: AgentWorkingSource.observer, + canViewActivity: true, + isWorking: false, + phase: AgentTurnPhase.error, + turnId: _secondTurnId, + ), + ], + humanTyping: [], + ), + ), + agentTurnStatesProvider.overrideWithValue([ + _turn(AgentTurnPhase.working), + _turnFor( + pubkey: _secondAgentPubkey, + turnId: _secondTurnId, + phase: AgentTurnPhase.error, + ), + ]), + observerTurnSubscriptionProvider( + _turnKey, + ).overrideWithValue(_observerState), + userCacheProvider.overrideWith(_FakeUserCacheNotifier.new), + ], + ); + addTearDown(container.dispose); + + await tester.pumpWidget(_app(container)); + await tester.pump(); + + expect(find.text('1 working · 1 error'), findsOneWidget); + expect(find.text('2 agents are working…'), findsNothing); + }); + + testWidgets('summarizes multiple retained terminal outcomes', (tester) async { + final container = ProviderContainer( + overrides: [ + composerActivityStateProvider(_scope).overrideWithValue( + const ComposerActivityState( + agents: [ + WorkingAgentSignal( + pubkey: _agentPubkey, + source: AgentWorkingSource.observer, + canViewActivity: true, + isWorking: false, + phase: AgentTurnPhase.finished, + turnId: _turnId, + ), + WorkingAgentSignal( + pubkey: _secondAgentPubkey, + source: AgentWorkingSource.observer, + canViewActivity: true, + isWorking: false, + phase: AgentTurnPhase.error, + turnId: _secondTurnId, + ), + ], + humanTyping: [], + ), + ), + agentTurnStatesProvider.overrideWithValue([ + _turn(AgentTurnPhase.finished), + _turnFor( + pubkey: _secondAgentPubkey, + turnId: _secondTurnId, + phase: AgentTurnPhase.error, + ), + ]), + observerTurnSubscriptionProvider( + _turnKey, + ).overrideWithValue(_observerState), + userCacheProvider.overrideWith(_FakeUserCacheNotifier.new), + ], + ); + addTearDown(container.dispose); + + await tester.pumpWidget(_app(container)); + await tester.pump(); + + expect(find.text('1 finished · 1 error'), findsOneWidget); + expect(find.text('2 agents are working…'), findsNothing); + }); + testWidgets('reduced motion makes inline size changes immediate', ( tester, ) async { 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 fd264da3d..7d42c7848 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 @@ -114,6 +114,56 @@ void main() { }, ); + test('thread typing does not inherit a retained terminal turn', () { + final failedTurn = _turn('agent-a', phase: AgentTurnPhase.error); + 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, + ), + ]), + ), + agentMentionPubkeysProvider( + _channelId, + ).overrideWith((ref) => const {'agent-a'}), + agentOwnersProvider.overrideWithValue( + const AsyncData({'agent-a': 'owner'}), + ), + userCacheProvider.overrideWith(_FakeUserCacheNotifier.new), + observerRelayProvider.overrideWith( + () => _FakeObserverRelayNotifier({ + 'agent-a': [_observerFrame('agent-a')], + }), + ), + composerAgentTurnStatesProvider.overrideWithValue([failedTurn]), + ], + ); + addTearDown(container.dispose); + + final signal = container + .read( + composerActivityStateProvider(( + channelId: _channelId, + threadHeadId: 'thread-1', + )), + ) + .agents + .single; + + expect(signal.source, AgentWorkingSource.typing); + expect(signal.isWorking, isTrue); + expect(signal.turnId, isNull); + expect(signal.startedAt, isNull); + }); + test('keeps a recent owned error reachable after typing stops', () { final failedTurn = _turn('agent-a', phase: AgentTurnPhase.error); final container = ProviderContainer(