diff --git a/mobile/lib/features/channels/agent_activity/active_agent_turns.dart b/mobile/lib/features/channels/agent_activity/active_agent_turns.dart index d13a17dca..0e178efa8 100644 --- a/mobile/lib/features/channels/agent_activity/active_agent_turns.dart +++ b/mobile/lib/features/channels/agent_activity/active_agent_turns.dart @@ -7,6 +7,7 @@ import 'observer_subscription.dart'; const _defaultLivenessTimeout = Duration(seconds: 30); const _activeTurnClockInterval = Duration(seconds: 5); +const _terminalComposerRetention = Duration(seconds: 30); // Matches buzz-acp's MAX_TURN_DURATION_CEILING_SECS. A disabled or unusually // sparse liveness cadence must not expire before any legal turn can finish. const _maximumTurnDuration = Duration(days: 7); @@ -264,6 +265,30 @@ final activeAgentTurnsProvider = Provider>((ref) { ]; }); +/// Working turns plus recent explicit outcomes that remain actionable beside +/// the composer for a short, bounded window. +final composerAgentTurnStatesProvider = Provider>((ref) { + final now = + ref.watch(_activeAgentTurnClockProvider).value ?? DateTime.now().toUtc(); + return composerAgentTurnStates(ref.watch(agentTurnStatesProvider), now: now); +}); + +/// Filters turn states to those that should remain visible by the composer. +@visibleForTesting +List composerAgentTurnStates( + Iterable states, { + required DateTime now, +}) => List.unmodifiable([ + for (final state in states) + if (state.isWorking || + !now.isAfter( + (state.terminalAt ?? state.lastActivityAt).add( + _terminalComposerRetention, + ), + )) + state, +]); + DateTime _frameTimestamp(ObserverFrame frame) => DateTime.tryParse(frame.timestamp)?.toUtc() ?? DateTime.fromMillisecondsSinceEpoch(frame.seq, isUtc: true); 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 26a53f890..e60fde07d 100644 --- a/mobile/lib/features/channels/agent_activity/agent_activity_sheet.dart +++ b/mobile/lib/features/channels/agent_activity/agent_activity_sheet.dart @@ -5,7 +5,7 @@ import 'package:lucide_icons_flutter/lucide_icons.dart'; import '../../../shared/theme/theme.dart'; import '../../../shared/widgets/buzz_loading_indicator.dart'; -import '../../profile/user_cache_provider.dart'; +import '../../../shared/profile/user_cache_provider.dart'; import '../date_formatters.dart'; import 'observer_models.dart'; import 'observer_subscription.dart'; 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 48eb57bf9..59e17da61 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 @@ -11,8 +11,8 @@ import '../../../shared/theme/theme.dart'; import '../../../shared/utils/string_utils.dart'; import '../../../shared/widgets/buzz_loading_indicator.dart'; import '../../../shared/widgets/frosted_app_bar.dart'; -import '../../profile/user_cache_provider.dart'; -import '../../profile/user_profile.dart'; +import '../../../shared/profile/user_cache_provider.dart'; +import '../../../shared/profile/user_profile.dart'; import '../channel_typing_indicator.dart'; import '../small_avatar.dart'; import 'active_agent_turns.dart'; 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 741535b32..7630f2969 100644 --- a/mobile/lib/features/channels/agent_activity/working_bots_provider.dart +++ b/mobile/lib/features/channels/agent_activity/working_bots_provider.dart @@ -2,7 +2,7 @@ import 'package:flutter/foundation.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import '../../../shared/mentions/agent_identity_provider.dart'; -import '../../profile/user_cache_provider.dart'; +import '../../../shared/profile/user_cache_provider.dart'; import '../channel_management_provider.dart'; import '../channel_typing_provider.dart'; import 'active_agent_turns.dart'; @@ -70,9 +70,9 @@ final composerActivityStateProvider = Provider.autoDispose final ownerByAgent = ref.watch(agentOwnersProvider).asData?.value ?? const {}; - final activeTurns = ref.watch(activeAgentTurnsProvider); + final composerTurns = ref.watch(composerAgentTurnStatesProvider); final activeByAgent = {}; - for (final turn in activeTurns) { + for (final turn in composerTurns) { if (turn.channelId != key.channelId) continue; final existing = activeByAgent[turn.agentPubkey]; if (existing == null || @@ -95,6 +95,7 @@ final composerActivityStateProvider = Provider.autoDispose for (final entry in activeByAgent.entries) { if (!channelAgents.contains(entry.key)) continue; final turn = entry.value; + if (!turn.isWorking && !canView(entry.key)) continue; signals[entry.key] = WorkingAgentSignal( pubkey: entry.key, source: AgentWorkingSource.observer, diff --git a/mobile/lib/features/profile/user_cache_provider.dart b/mobile/lib/features/profile/user_cache_provider.dart index e9363b808..6ef805404 100644 --- a/mobile/lib/features/profile/user_cache_provider.dart +++ b/mobile/lib/features/profile/user_cache_provider.dart @@ -1,90 +1,2 @@ -import 'dart:async'; - -import 'package:hooks_riverpod/hooks_riverpod.dart'; - -import '../../shared/crypto/nip_oa.dart'; -import '../../shared/relay/relay.dart'; -import 'user_profile.dart'; - -/// In-memory cache of user profiles, fetched in batches from the relay. -/// -/// Lookups requested via [get] or [preload] are coalesced into a single -/// kind:0 batch query (NIP-01 `authors` filter) every 50ms. -class UserCacheNotifier extends Notifier> { - final Set _pending = {}; - Timer? _batchTimer; - - @override - Map build() { - ref.watch(relayConfigProvider); - ref.onDispose(() { - _batchTimer?.cancel(); - _batchTimer = null; - }); - return {}; - } - - /// Request a profile for [pubkey]. Returns immediately from cache if - /// available, otherwise schedules a batch fetch. - UserProfile? get(String pubkey) { - final cached = state[pubkey.toLowerCase()]; - if (cached != null) return cached; - _scheduleFetch(pubkey.toLowerCase()); - return null; - } - - /// Preload profiles for a list of pubkeys (e.g. channel members). - void preload(List pubkeys) { - final uncached = pubkeys - .map((pk) => pk.toLowerCase()) - .where((pk) => !state.containsKey(pk) && !_pending.contains(pk)) - .toList(); - if (uncached.isEmpty) return; - _pending.addAll(uncached); - _batchTimer ??= Timer(const Duration(milliseconds: 50), _flushPending); - } - - void _scheduleFetch(String pubkey) { - if (state.containsKey(pubkey) || _pending.contains(pubkey)) return; - _pending.add(pubkey); - _batchTimer ??= Timer(const Duration(milliseconds: 50), _flushPending); - } - - Future _flushPending() async { - _batchTimer = null; - if (_pending.isEmpty) return; - - final pubkeys = _pending.toList(); - _pending.clear(); - - try { - final session = ref.read(relaySessionProvider.notifier); - final events = await session.fetchHistory( - NostrFilters.profilesBatch(pubkeys), - ); - - final updated = Map.from(state); - for (final event in events) { - final data = ProfileData.fromEvent(event); - final pk = data.pubkey.toLowerCase(); - updated[pk] = UserProfile( - pubkey: pk, - displayName: data.displayName, - avatarUrl: data.avatarUrl, - about: data.about, - nip05Handle: data.nip05, - ownerPubkey: verifiedOaOwnerPubkey(event.tags, event.pubkey), - ); - } - - state = updated; - } catch (_) { - // Silently fail — we'll just show pubkeys. - } - } -} - -final userCacheProvider = - NotifierProvider>( - UserCacheNotifier.new, - ); +// Compatibility export for profile-feature callers. +export '../../shared/profile/user_cache_provider.dart'; diff --git a/mobile/lib/features/profile/user_profile.dart b/mobile/lib/features/profile/user_profile.dart index de58d955e..7f77f0394 100644 --- a/mobile/lib/features/profile/user_profile.dart +++ b/mobile/lib/features/profile/user_profile.dart @@ -1,48 +1,2 @@ -import 'package:flutter/foundation.dart'; - -@immutable -class UserProfile { - final String pubkey; - final String? displayName; - final String? avatarUrl; - final String? about; - final String? nip05Handle; - - /// NIP-OA verified owner pubkey from the profile's `auth` tag; non-null - /// means this identity is an agent (mirrors desktop's `ownerPubkey`). - final String? ownerPubkey; - - const UserProfile({ - required this.pubkey, - this.displayName, - this.avatarUrl, - this.about, - this.nip05Handle, - this.ownerPubkey, - }); - - factory UserProfile.fromJson(Map json) => UserProfile( - pubkey: json['pubkey'] as String, - displayName: json['display_name'] as String?, - avatarUrl: json['avatar_url'] as String?, - about: json['about'] as String?, - nip05Handle: json['nip05_handle'] as String?, - ); - - /// Short label: display name, or first 8 chars of pubkey. - String get label => - displayName ?? - '${pubkey.length >= 8 ? pubkey.substring(0, 8) : pubkey}...'; - - /// First letter for fallback avatar. - String get initial => - (displayName?.isNotEmpty == true ? displayName! : pubkey)[0] - .toUpperCase(); -} - -/// Optional profile handle shown beside a message author's display name. -String? messageUsernameLabel(UserProfile? profile) { - final handle = profile?.nip05Handle?.trim(); - if (handle != null && handle.isNotEmpty) return handle; - return null; -} +// Compatibility export for profile-feature callers. +export '../../shared/profile/user_profile.dart'; diff --git a/mobile/lib/shared/profile/user_cache_provider.dart b/mobile/lib/shared/profile/user_cache_provider.dart new file mode 100644 index 000000000..51498d149 --- /dev/null +++ b/mobile/lib/shared/profile/user_cache_provider.dart @@ -0,0 +1,91 @@ +import 'dart:async'; + +import 'package:hooks_riverpod/hooks_riverpod.dart'; + +import '../crypto/nip_oa.dart'; +import '../relay/relay.dart'; +import 'user_profile.dart'; + +/// In-memory cache of user profiles, fetched in batches from the relay. +/// +/// Lookups requested via [get] or [preload] are coalesced into a single +/// kind:0 batch query (NIP-01 `authors` filter) every 50ms. +class UserCacheNotifier extends Notifier> { + final Set _pending = {}; + Timer? _batchTimer; + + @override + Map build() { + ref.watch(relayConfigProvider); + ref.onDispose(() { + _batchTimer?.cancel(); + _batchTimer = null; + }); + return {}; + } + + /// Request a profile for [pubkey]. Returns immediately from cache if + /// available, otherwise schedules a batch fetch. + UserProfile? get(String pubkey) { + final cached = state[pubkey.toLowerCase()]; + if (cached != null) return cached; + _scheduleFetch(pubkey.toLowerCase()); + return null; + } + + /// Preload profiles for a list of pubkeys (e.g. channel members). + void preload(List pubkeys) { + final uncached = pubkeys + .map((pk) => pk.toLowerCase()) + .where((pk) => !state.containsKey(pk) && !_pending.contains(pk)) + .toList(); + if (uncached.isEmpty) return; + _pending.addAll(uncached); + _batchTimer ??= Timer(const Duration(milliseconds: 50), _flushPending); + } + + void _scheduleFetch(String pubkey) { + if (state.containsKey(pubkey) || _pending.contains(pubkey)) return; + _pending.add(pubkey); + _batchTimer ??= Timer(const Duration(milliseconds: 50), _flushPending); + } + + Future _flushPending() async { + _batchTimer = null; + if (_pending.isEmpty) return; + + final pubkeys = _pending.toList(); + _pending.clear(); + + try { + final session = ref.read(relaySessionProvider.notifier); + final events = await session.fetchHistory( + NostrFilters.profilesBatch(pubkeys), + ); + + final updated = Map.from(state); + for (final event in events) { + final data = ProfileData.fromEvent(event); + final pk = data.pubkey.toLowerCase(); + updated[pk] = UserProfile( + pubkey: pk, + displayName: data.displayName, + avatarUrl: data.avatarUrl, + about: data.about, + nip05Handle: data.nip05, + ownerPubkey: verifiedOaOwnerPubkey(event.tags, event.pubkey), + ); + } + + state = updated; + } catch (_) { + // Silently fail — we'll just show pubkeys. + } + } +} + +/// Shared relay-backed profile cache for cross-feature identity presentation. +final userCacheProvider = + NotifierProvider>( + UserCacheNotifier.new, + ); diff --git a/mobile/lib/shared/profile/user_profile.dart b/mobile/lib/shared/profile/user_profile.dart new file mode 100644 index 000000000..12d39aa91 --- /dev/null +++ b/mobile/lib/shared/profile/user_profile.dart @@ -0,0 +1,49 @@ +import 'package:flutter/foundation.dart'; + +/// Relay-backed user identity metadata shared across product features. +@immutable +class UserProfile { + final String pubkey; + final String? displayName; + final String? avatarUrl; + final String? about; + final String? nip05Handle; + + /// NIP-OA verified owner pubkey from the profile's `auth` tag; non-null + /// means this identity is an agent (mirrors desktop's `ownerPubkey`). + final String? ownerPubkey; + + const UserProfile({ + required this.pubkey, + this.displayName, + this.avatarUrl, + this.about, + this.nip05Handle, + this.ownerPubkey, + }); + + factory UserProfile.fromJson(Map json) => UserProfile( + pubkey: json['pubkey'] as String, + displayName: json['display_name'] as String?, + avatarUrl: json['avatar_url'] as String?, + about: json['about'] as String?, + nip05Handle: json['nip05_handle'] as String?, + ); + + /// Short label: display name, or first 8 chars of pubkey. + String get label => + displayName ?? + '${pubkey.length >= 8 ? pubkey.substring(0, 8) : pubkey}...'; + + /// First letter for fallback avatar. + String get initial => + (displayName?.isNotEmpty == true ? displayName! : pubkey)[0] + .toUpperCase(); +} + +/// Optional profile handle shown beside a message author's display name. +String? messageUsernameLabel(UserProfile? profile) { + final handle = profile?.nip05Handle?.trim(); + if (handle != null && handle.isNotEmpty) return handle; + return null; +} diff --git a/mobile/test/features/channels/agent_activity/active_agent_turns_test.dart b/mobile/test/features/channels/agent_activity/active_agent_turns_test.dart index 5cf022a65..a76b3ef73 100644 --- a/mobile/test/features/channels/agent_activity/active_agent_turns_test.dart +++ b/mobile/test/features/channels/agent_activity/active_agent_turns_test.dart @@ -203,6 +203,41 @@ void main() { expect(turns.single.phase, AgentTurnPhase.error); expect(turns.single.errorMessage, 'Process exited'); }); + + test( + 'retains terminal outcomes beside the composer for a bounded window', + () { + final terminalAt = DateTime.utc(2026, 8, 16, 12, 0, 2); + final states = [ + AgentTurnState( + agentPubkey: 'agent-a', + channelId: 'channel-1', + turnId: 'turn-a', + startedAt: DateTime.utc(2026, 8, 16, 12), + lastActivityAt: terminalAt, + livenessTimeout: const Duration(seconds: 30), + phase: AgentTurnPhase.error, + terminalAt: terminalAt, + errorMessage: 'Agent timed out', + ), + ]; + + expect( + composerAgentTurnStates( + states, + now: terminalAt.add(const Duration(seconds: 30)), + ), + hasLength(1), + ); + expect( + composerAgentTurnStates( + states, + now: terminalAt.add(const Duration(seconds: 31)), + ), + isEmpty, + ); + }, + ); } ObserverFrame _frame({ 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 c87e52975..510578ed3 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 @@ -9,8 +9,8 @@ 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'; import 'package:buzz/features/channels/channel_typing_provider.dart'; -import 'package:buzz/features/profile/user_cache_provider.dart'; -import 'package:buzz/features/profile/user_profile.dart'; +import 'package:buzz/shared/profile/user_cache_provider.dart'; +import 'package:buzz/shared/profile/user_profile.dart'; import 'package:buzz/shared/theme/theme.dart'; const _channelId = 'channel-1'; @@ -158,6 +158,54 @@ void main() { }, ); + testWidgets('keeps a collapsed terminal error openable', (tester) async { + final container = ProviderContainer( + overrides: [ + composerActivityStateProvider(_scope).overrideWithValue( + const ComposerActivityState( + agents: [ + WorkingAgentSignal( + pubkey: _agentPubkey, + source: AgentWorkingSource.observer, + canViewActivity: true, + turnId: _turnId, + ), + ], + humanTyping: [], + ), + ), + agentTurnStatesProvider.overrideWithValue([ + _turn(AgentTurnPhase.error), + ]), + observerTurnSubscriptionProvider( + _turnKey, + ).overrideWithValue(_observerState), + userCacheProvider.overrideWith(_FakeUserCacheNotifier.new), + ], + ); + addTearDown(container.dispose); + + await tester.pumpWidget(_app(container)); + await tester.pump(); + + final control = find.byKey( + const ValueKey('composer-agent-activity-control'), + ); + expect(control, findsOneWidget); + expect(find.text('Pollen stopped with an error'), findsOneWidget); + + await tester.tap(control); + await tester.pump(); + await tester.pump(const Duration(milliseconds: 240)); + + expect( + find.byKey(const ValueKey('composer-agent-activity-panel')), + findsOneWidget, + ); + expect(find.text('Error'), findsOneWidget); + expect(find.text('Thinking'), findsOneWidget); + }); + 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 eed024eb1..4772c08a0 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 @@ -6,9 +6,9 @@ import 'package:buzz/features/channels/agent_activity/observer_subscription.dart import 'package:buzz/features/channels/agent_activity/working_bots_provider.dart'; import 'package:buzz/features/channels/channel_management_provider.dart'; import 'package:buzz/features/channels/channel_typing_provider.dart'; -import 'package:buzz/features/profile/user_cache_provider.dart'; -import 'package:buzz/features/profile/user_profile.dart'; import 'package:buzz/shared/mentions/agent_identity_provider.dart'; +import 'package:buzz/shared/profile/user_cache_provider.dart'; +import 'package:buzz/shared/profile/user_profile.dart'; const _channelId = 'channel-1'; @@ -40,7 +40,7 @@ void main() { 'agent-a': [_observerFrame('agent-a')], }), ), - activeAgentTurnsProvider.overrideWithValue([observerTurn]), + composerAgentTurnStatesProvider.overrideWithValue([observerTurn]), ], ); addTearDown(container.dispose); @@ -96,7 +96,7 @@ void main() { 'agent-a': [_observerFrame('agent-a')], }), ), - activeAgentTurnsProvider.overrideWithValue([_turn('agent-a')]), + composerAgentTurnStatesProvider.overrideWithValue([_turn('agent-a')]), ], ); addTearDown(container.dispose); @@ -113,16 +113,64 @@ void main() { expect(state.humanTyping.single.pubkey, 'human'); }, ); + + test('keeps a recent owned error reachable after typing stops', () { + 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 [])), + 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 state = container.read( + composerActivityStateProvider(( + channelId: _channelId, + threadHeadId: null, + )), + ); + + expect(state.agents, hasLength(1)); + expect(state.agents.single.pubkey, 'agent-a'); + expect(state.agents.single.turnId, failedTurn.turnId); + expect(state.agents.single.canViewActivity, isTrue); + expect(state.humanTyping, isEmpty); + }); } -AgentTurnState _turn(String pubkey) => AgentTurnState( +AgentTurnState _turn( + String pubkey, { + AgentTurnPhase phase = AgentTurnPhase.working, +}) => AgentTurnState( agentPubkey: pubkey, channelId: _channelId, turnId: 'turn-$pubkey', startedAt: DateTime.utc(2026, 8, 16, 12), lastActivityAt: DateTime.utc(2026, 8, 16, 12), livenessTimeout: const Duration(seconds: 30), - phase: AgentTurnPhase.working, + phase: phase, + terminalAt: phase == AgentTurnPhase.working + ? null + : DateTime.utc(2026, 8, 16, 12, 0, 5), ); ObserverFrame _observerFrame(String pubkey) => ObserverFrame(