Fix mobile agent activity boundaries

Signed-off-by: kenny lopez <klopez4212@gmail.com>
This commit is contained in:
kenny lopez
2026-08-17 14:26:54 +01:00
parent 638320c007
commit 63a1829f69
11 changed files with 315 additions and 152 deletions
@@ -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<List<AgentTurnState>>((ref) {
];
});
/// Working turns plus recent explicit outcomes that remain actionable beside
/// the composer for a short, bounded window.
final composerAgentTurnStatesProvider = Provider<List<AgentTurnState>>((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<AgentTurnState> composerAgentTurnStates(
Iterable<AgentTurnState> 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);
@@ -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';
@@ -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';
@@ -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 <String, String>{};
final activeTurns = ref.watch(activeAgentTurnsProvider);
final composerTurns = ref.watch(composerAgentTurnStatesProvider);
final activeByAgent = <String, AgentTurnState>{};
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,
@@ -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<Map<String, UserProfile>> {
final Set<String> _pending = {};
Timer? _batchTimer;
@override
Map<String, UserProfile> 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<String> 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<void> _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<String, UserProfile>.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, Map<String, UserProfile>>(
UserCacheNotifier.new,
);
// Compatibility export for profile-feature callers.
export '../../shared/profile/user_cache_provider.dart';
+2 -48
View File
@@ -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<String, dynamic> 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';
@@ -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<Map<String, UserProfile>> {
final Set<String> _pending = {};
Timer? _batchTimer;
@override
Map<String, UserProfile> 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<String> 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<void> _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<String, UserProfile>.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, Map<String, UserProfile>>(
UserCacheNotifier.new,
);
@@ -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<String, dynamic> 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;
}
@@ -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({
@@ -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 {
@@ -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 <ChannelMember>[]),
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(