From 3e81b4afcbefb0ce1091030668106bd769ff4d7c Mon Sep 17 00:00:00 2001 From: Logan Johnson Date: Tue, 10 Nov 2026 10:33:17 -0500 Subject: [PATCH 1/3] fix(mobile): tell same-name agents apart in Solo The Solo agent picker, the new-chat draft, the chat header and its agent switcher, and the "working" label showed each agent's raw name, so two agents with one name looked the same. They now use the shared identity-names labels, scoped over the agents Solo offers. Archived agents are already left out of that list, so an archived namesake adds no qualifier. Solo keeps showing the owner's inventory name ahead of the agent's own profile name. An agent with no name now shows the short npub every other picker shows. Co-authored-by: Larry <627498bd4bd1f281a16431e3c6cce3b5c25b6692798c78672298aefbf2f8f8b5@buzz.block.builderlab.xyz> Co-Authored-By: Claude Opus 5.5 Signed-off-by: Logan Johnson --- .../channels/me/me_assistant_list.dart | 4 +- .../channels/me/me_chat_assistant.dart | 22 ++-- .../features/channels/me/me_chat_draft.dart | 7 +- mobile/lib/features/channels/me/me_page.dart | 1 + .../channels/me/me_working_provider.dart | 10 +- .../identity_names_provider.dart | 9 +- mobile/lib/shared/me/me_assistant_names.dart | 73 +++++++++++++ .../channels/me/me_chat_assistant_test.dart | 102 +++++++++++++++++- .../features/channels/me/me_page_test.dart | 75 +++++++++++++ 9 files changed, 283 insertions(+), 20 deletions(-) create mode 100644 mobile/lib/shared/me/me_assistant_names.dart diff --git a/mobile/lib/features/channels/me/me_assistant_list.dart b/mobile/lib/features/channels/me/me_assistant_list.dart index 2f6dc6f5860..c7c0c0b4a10 100644 --- a/mobile/lib/features/channels/me/me_assistant_list.dart +++ b/mobile/lib/features/channels/me/me_assistant_list.dart @@ -1,6 +1,7 @@ import 'package:flutter/material.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; +import '../../../shared/me/me_assistant_names.dart'; import '../../../shared/mentions/agent_identity_provider.dart'; import '../../../shared/profile/user_cache_provider.dart'; import '../../../shared/theme/theme.dart'; @@ -25,6 +26,7 @@ class MeAssistantList extends ConsumerWidget { @override Widget build(BuildContext context, WidgetRef ref) { final profiles = ref.watch(userCacheProvider); + final names = watchMeAssistantNames(ref, choices); return ConstrainedBox( constraints: BoxConstraints( maxHeight: MediaQuery.sizeOf(context).height * 0.6, @@ -68,7 +70,7 @@ class MeAssistantList extends ConsumerWidget { : 'A', ), ), - title: Text(agent.displayName ?? 'Agent'), + title: Text(names.labelFor(agent.pubkey)), trailing: agent.pubkey == selected ? const Icon(BuzzIcons.check) : null, diff --git a/mobile/lib/features/channels/me/me_chat_assistant.dart b/mobile/lib/features/channels/me/me_chat_assistant.dart index 8237bedaf09..a6db80d9611 100644 --- a/mobile/lib/features/channels/me/me_chat_assistant.dart +++ b/mobile/lib/features/channels/me/me_chat_assistant.dart @@ -3,9 +3,11 @@ import 'package:flutter/material.dart'; import 'package:flutter_hooks/flutter_hooks.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; +import '../../../shared/me/me_assistant_names.dart'; import '../../../shared/me/me_assistants.dart'; import '../../../shared/me/me_agent_change.dart'; import '../../../shared/me/me_sync.dart'; +import '../../../shared/mentions/agent_identity_provider.dart'; import '../../../shared/profile/user_cache_provider.dart'; import '../../../shared/relay/relay.dart'; import '../../../shared/theme/theme.dart'; @@ -73,7 +75,9 @@ useMeChatAssistant( final prefs = sync?.prefs; final selection = useState((key, initial ?? prefs?.getString(key))); final busy = useState(false); - final agents = enabled ? ref.watch(meAssistantsProvider).value ?? [] : []; + final List agents = enabled + ? ref.watch(meAssistantsProvider).value ?? [] + : []; final members = enabled ? ref.watch(channelMembersProvider(channelId)).value ?? [] : []; @@ -94,21 +98,19 @@ useMeChatAssistant( ref.read(userCacheProvider.notifier).get(selected) : null; // The owner's inventory name outranks the agent's self-published profile. - final name = - agents - .where((agent) => agent.pubkey == selected) - .firstOrNull - ?.displayName ?? - profile?.displayName; + final names = watchMeAssistantNames(ref, agents); + final name = agents.any((agent) => agent.pubkey == selected) + ? names.labelFor(selected!) + : profile?.displayName; final avatarInitial = name?.trim().isNotEmpty == true ? name!.trim()[0].toUpperCase() : profile?.initial ?? '?'; String agentName(String pubkey) { - final names = [ - agents.where((a) => a.pubkey == pubkey).firstOrNull?.displayName, + final candidates = [ + agents.any((a) => a.pubkey == pubkey) ? names.labelFor(pubkey) : null, ref.read(userCacheProvider)[pubkey]?.displayName, ]; - return names + return candidates .whereType() .where((name) => name.trim().isNotEmpty) .firstOrNull ?? diff --git a/mobile/lib/features/channels/me/me_chat_draft.dart b/mobile/lib/features/channels/me/me_chat_draft.dart index f5bfe8c3dab..e509e7b9b52 100644 --- a/mobile/lib/features/channels/me/me_chat_draft.dart +++ b/mobile/lib/features/channels/me/me_chat_draft.dart @@ -27,10 +27,13 @@ class _MeChatDraft extends HookConsumerWidget { final agent = choices .where((agent) => agent.pubkey == selected) .firstOrNull; - final name = + final names = watchMeAssistantNames(ref, choices); + // The composer keeps the plain name; only what the owner reads is labeled. + final plainName = agent?.displayName ?? profile?.label ?? (selected == null ? 'Choose an agent' : 'Agent'); + final name = agent == null ? plainName : names.labelFor(agent.pubkey); final avatarInitial = name.isEmpty ? 'A' : name.characters.first; final scopeChanged = originalScope != (sync.community, sync.pubkey); final frozen = @@ -249,7 +252,7 @@ class _MeChatDraft extends HookConsumerWidget { pubkey: selected, role: 'bot', joinedAt: DateTime(2026), - displayName: name, + displayName: plainName, ), ], onSendingChanged: (value) { diff --git a/mobile/lib/features/channels/me/me_page.dart b/mobile/lib/features/channels/me/me_page.dart index e878a894fc8..d48f217a3df 100644 --- a/mobile/lib/features/channels/me/me_page.dart +++ b/mobile/lib/features/channels/me/me_page.dart @@ -22,6 +22,7 @@ import '../../../shared/me/me_chat_creation.dart'; import '../../../shared/me/me_sync.dart'; import '../../../shared/relay/relay.dart'; import '../../../shared/me/me_recovery.dart'; +import '../../../shared/me/me_assistant_names.dart'; import '../../../shared/me/me_assistants.dart'; import '../../../shared/theme/theme.dart'; import '../../../shared/theme/buzz_icons.dart'; diff --git a/mobile/lib/features/channels/me/me_working_provider.dart b/mobile/lib/features/channels/me/me_working_provider.dart index a602769ae2a..3a4c282a9b3 100644 --- a/mobile/lib/features/channels/me/me_working_provider.dart +++ b/mobile/lib/features/channels/me/me_working_provider.dart @@ -1,5 +1,7 @@ import 'package:hooks_riverpod/hooks_riverpod.dart'; +import '../../../shared/identity_names/identity_names_provider.dart'; +import '../../../shared/me/me_assistant_names.dart'; import '../../../shared/me/me_assistants.dart'; import '../channel_typing_provider.dart'; @@ -10,13 +12,15 @@ final meWorkingAgentsProvider = Provider.autoDispose .family, String>((ref, channelId) { final agents = ref.watch(meAssistantsProvider).value ?? []; if (agents.isEmpty) return const []; + final names = meAssistantNames( + ref.watch(identityNameSourcesProvider), + agents, + ); final typing = ref.watch(channelTypingProvider(channelId)); final active = typing.map((entry) => entry.pubkey.toLowerCase()).toSet(); return [ for (final agent in agents) if (active.contains(agent.pubkey.toLowerCase())) - agent.displayName?.trim().isNotEmpty == true - ? agent.displayName!.trim() - : 'Agent', + names.labelFor(agent.pubkey), ]; }); diff --git a/mobile/lib/shared/identity_names/identity_names_provider.dart b/mobile/lib/shared/identity_names/identity_names_provider.dart index 5bf7c5eccac..bb1aec9ee92 100644 --- a/mobile/lib/shared/identity_names/identity_names_provider.dart +++ b/mobile/lib/shared/identity_names/identity_names_provider.dart @@ -42,15 +42,18 @@ void loadIdentityNameOwners(Ref ref, IdentityNames names) { /// [candidates]). Pass [shown] when the context also holds identities that are /// only compared, such as a channel roster whose names another owner loads, /// so opening the view does not queue a profile read for every one of them. +/// [sources] replaces the client-wide facts for views that adjust them. IdentityNames watchIdentityNames( WidgetRef ref, Iterable candidates, { Set agentPubkeys = const {}, Map fallbackNames = const {}, Iterable? shown, + IdentityNameSources? sources, }) { - final sources = ref.watch(identityNameSourcesProvider); - final names = sources.scope( + final IdentityNameSources facts = + sources ?? ref.watch(identityNameSourcesProvider); + final names = facts.scope( candidates, agentPubkeys: agentPubkeys, fallbackNames: fallbackNames, @@ -66,7 +69,7 @@ IdentityNames watchIdentityNames( }; final missing = { for (final key in loadFor) - if (!sources.profiles.containsKey(key)) key, + if (!facts.profiles.containsKey(key)) key, ...names.missingOwnerProfiles(loadFor), }; if (missing.isNotEmpty) { diff --git a/mobile/lib/shared/me/me_assistant_names.dart b/mobile/lib/shared/me/me_assistant_names.dart new file mode 100644 index 00000000000..32c6289663b --- /dev/null +++ b/mobile/lib/shared/me/me_assistant_names.dart @@ -0,0 +1,73 @@ +import 'package:hooks_riverpod/hooks_riverpod.dart'; + +import '../identity_names/identity_names.dart'; +import '../identity_names/identity_names_provider.dart'; +import '../mentions/agent_identity_provider.dart'; +import '../profile/user_profile.dart'; + +/// [base] with the owner's inventory name ahead of each agent's self-published +/// profile name, as Solo has always shown it. Verified names still win, and +/// every other naming fact is untouched. +IdentityNameSources meAssistantNameSources( + IdentityNameSources base, + List agents, +) { + final inventory = { + for (final agent in agents) + if (agent.displayName?.trim().isNotEmpty == true) + agent.pubkey.toLowerCase(): agent.displayName!, + }; + return IdentityNameSources( + // An uncached profile stays absent so the caller still loads it. + profiles: { + for (final MapEntry(:key, :value) in base.profiles.entries) + key: inventory.containsKey(key) + ? UserProfile( + pubkey: value.pubkey, + displayName: inventory[key], + avatarUrl: value.avatarUrl, + about: value.about, + nip05Handle: value.nip05Handle, + ownerPubkey: value.ownerPubkey, + ) + : value, + }, + verifiedNames: base.verifiedNames, + agentPubkeys: base.agentPubkeys, + agentDisplayNames: {...base.agentDisplayNames, ...inventory}, + agentOwners: base.agentOwners, + viewer: base.viewer, + ); +} + +/// Labels for the agents Solo offers. The comparison context is exactly +/// [agents] (the live inventory, archived agents already excluded), so a +/// hidden archived namesake never adds a qualifier, and the picker, draft and +/// header all read the same label for one agent. +IdentityNames meAssistantNames( + IdentityNameSources sources, + List agents, +) { + final keys = [for (final agent in agents) agent.pubkey]; + return meAssistantNameSources( + sources, + agents, + ).scope(keys, agentPubkeys: keys.toSet()); +} + +/// [meAssistantNames] for a widget, loading the profiles it still lacks. +IdentityNames watchMeAssistantNames( + WidgetRef ref, + List agents, +) { + final keys = [for (final agent in agents) agent.pubkey]; + return watchIdentityNames( + ref, + keys, + agentPubkeys: keys.toSet(), + sources: meAssistantNameSources( + ref.watch(identityNameSourcesProvider), + agents, + ), + ); +} diff --git a/mobile/test/features/channels/me/me_chat_assistant_test.dart b/mobile/test/features/channels/me/me_chat_assistant_test.dart index 8cb71244e72..2714a19bf6c 100644 --- a/mobile/test/features/channels/me/me_chat_assistant_test.dart +++ b/mobile/test/features/channels/me/me_chat_assistant_test.dart @@ -12,6 +12,8 @@ import 'package:buzz/shared/profile/user_cache_provider.dart'; import 'package:buzz/shared/profile/user_profile.dart'; import 'package:buzz/shared/relay/relay.dart'; import 'package:buzz/shared/theme/theme.dart'; +import 'package:buzz/shared/utils/string_utils.dart'; +import 'package:buzz/shared/widgets/app_list.dart'; import 'package:buzz/shared/widgets/avatar_image.dart'; import 'package:flutter/material.dart'; import 'package:flutter_test/flutter_test.dart'; @@ -28,6 +30,13 @@ class _Profiles extends UserCacheNotifier { }; } +class _CachedProfiles extends UserCacheNotifier { + _CachedProfiles(this.profiles); + final Map profiles; + @override + Map build() => profiles; +} + class _Harness extends HookConsumerWidget { final ValueChanged> onSend; const _Harness(this.onSend); @@ -61,7 +70,8 @@ class _Harness extends HookConsumerWidget { void main() { for (final keyFallback in [false, true]) { final target = keyFallback ? '0123456789abcdef' * 4 : 'b'; - final targetName = keyFallback ? '01234567…cdef' : 'Beta'; + // An unnamed agent reads as its compact npub, like every other picker. + final targetName = keyFallback ? shortPubkey(target) : 'Beta'; for (final action in ['Resume', 'Later', 'Discard choice']) { testWidgets( 'header restores pending agent choice: $action fallback=$keyFallback', @@ -426,4 +436,94 @@ void main() { expect(ownershipReads, 2); }, ); + testWidgets('same-name agents read distinctly in the picker and header', ( + tester, + ) async { + SharedPreferences.setMockInitialValues({}); + final prefs = await SharedPreferences.getInstance(); + final sync = MeSync( + RelaySessionNotifier(), + RelayConfig( + baseUrl: 'https://relay.test', + nsec: nostr.Nip19.encode( + prefix: nostr.Nip19Prefix.nsec, + data: '0' * 63 + '1', + ), + ), + prefs: prefs, + ); + final first = 'a1' * 32; + final second = 'b2' * 32; + await prefs.setString( + 'me-assistant:${sync.community}:${sync.pubkey}:chat', + first, + ); + var live = [first, second]; + await tester.pumpWidget( + ProviderScope( + overrides: [ + meSyncProvider.overrideWithValue(sync), + meAssistantsProvider.overrideWith( + (ref) async => [ + for (final key in live) + AgentDirectoryEntry(pubkey: key, displayName: 'Atlas'), + ], + ), + channelMembersProvider('chat').overrideWith( + (ref) async => [ + for (final key in [first, second]) + ChannelMember( + pubkey: key, + role: 'bot', + joinedAt: DateTime(2026), + ), + ], + ), + meAssistantOwnershipProvider.overrideWith((ref, key) async => true), + // An archived agent's profile stays in the client's cache. + userCacheProvider.overrideWith( + () => _CachedProfiles({ + first: UserProfile(pubkey: first, displayName: 'Atlas'), + }), + ), + ], + child: MaterialApp(theme: AppTheme.light(), home: _Harness((_) {})), + ), + ); + await tester.pumpAndSettle(); + String headerLabel() => tester + .widget(find.byKey(const ValueKey('me-assistant-button'))) + .tooltip!; + List pickerLabels() => [ + for (final row in tester.widgetList( + find.byType(AppListRowRaw), + )) + (row.title as Text).data!, + ]; + + final firstHeader = headerLabel(); + expect(firstHeader, startsWith('Change agent: Atlas')); + expect(firstHeader, isNot('Change agent: Atlas')); + await tester.tap(find.byKey(const ValueKey('me-assistant-button'))); + await tester.pumpAndSettle(); + final labels = pickerLabels(); + expect(labels, hasLength(2)); + expect(labels.toSet(), hasLength(2), reason: 'Namesakes must differ'); + expect('Change agent: ${labels.first}', firstHeader); + await tester.tap(find.text(labels.last)); + await tester.pumpAndSettle(); + expect(headerLabel(), 'Change agent: ${labels.last}'); + + // The first agent is archived: no live namesake remains, so the cached + // profiles of hidden agents add no qualifier. + live = [second]; + ProviderScope.containerOf( + tester.element(find.byType(_Harness)), + ).invalidate(meAssistantsProvider); + await tester.pumpAndSettle(); + expect(headerLabel(), 'Change agent: Atlas'); + await tester.tap(find.byKey(const ValueKey('me-assistant-button'))); + await tester.pumpAndSettle(); + expect(pickerLabels(), ['Atlas']); + }); } diff --git a/mobile/test/features/channels/me/me_page_test.dart b/mobile/test/features/channels/me/me_page_test.dart index 7bef3f3c7c8..38fdb66ea97 100644 --- a/mobile/test/features/channels/me/me_page_test.dart +++ b/mobile/test/features/channels/me/me_page_test.dart @@ -22,6 +22,7 @@ import 'package:buzz/features/channels/me/me_page.dart'; import 'package:buzz/shared/me/me_groups.dart'; import 'package:buzz/shared/me/me_sync.dart'; import 'package:buzz/shared/theme/theme.dart'; +import 'package:buzz/shared/widgets/app_list.dart'; class _UnavailableRelay extends RelaySessionNotifier { @override @@ -426,6 +427,80 @@ void main() { }, ); + testWidgets('new chat tells same-name agents apart in the picker and draft', ( + tester, + ) async { + SharedPreferences.setMockInitialValues({}); + final prefs = await SharedPreferences.getInstance(); + final action = ValueNotifier(null); + addTearDown(action.dispose); + final sync = MeSync( + _UnavailableRelay(), + RelayConfig( + baseUrl: 'https://relay.test', + nsec: nostr.Keys.generate().nsec, + ), + prefs: prefs, + ); + var live = ['a1' * 32, 'b2' * 32]; + await tester.pumpWidget( + ProviderScope( + overrides: [ + savedPrefsProvider.overrideWithValue(prefs), + defaultAgentProvider.overrideWith((ref) => Stream.value(null)), + meSyncProvider.overrideWithValue(sync), + meGroupsProvider.overrideWith( + (ref) => Stream.value(const MeGroups()), + ), + channelsProvider.overrideWith(FakeChannels.new), + meAssistantsProvider.overrideWith( + (ref) async => [ + for (final key in live) + AgentDirectoryEntry(pubkey: key, displayName: 'Atlas'), + ], + ), + agentDirectoryProvider.overrideWith((ref) async => []), + agentOwnersProvider.overrideWith((ref) async => {}), + customEmojiListProvider.overrideWithValue([]), + ], + child: MaterialApp( + theme: AppTheme.light(), + home: MePage(newChatAction: action), + ), + ), + ); + await tester.pumpAndSettle(); + action.value!(); + await tester.pumpAndSettle(); + List pickerLabels() => [ + for (final row in tester.widgetList( + find.byType(AppListRowRaw), + )) + (row.title as Text).data!, + ]; + + await tester.tap(find.byTooltip('Choose an agent')); + await tester.pumpAndSettle(); + final labels = pickerLabels(); + expect(labels.toSet(), hasLength(2), reason: 'Namesakes must differ'); + await tester.tap(find.text(labels.last)); + await tester.pumpAndSettle(); + expect(find.byTooltip('Change agent: ${labels.last}'), findsOneWidget); + expect(find.text('Message ${labels.last}'), findsOneWidget); + + // Once the namesake is archived the plain name is enough. + live = [live.last]; + ProviderScope.containerOf( + tester.element(find.byType(ComposeBar)), + ).invalidate(meAssistantsProvider); + await tester.pumpAndSettle(); + expect(find.byTooltip('Change agent: Atlas'), findsOneWidget); + expect(find.text('Message Atlas'), findsOneWidget); + await tester.tap(find.byTooltip('Change agent: Atlas')); + await tester.pumpAndSettle(); + expect(pickerLabels(), ['Atlas']); + }); + for (final resume in [true, false]) { testWidgets( 'new chat resumes a named creation in the draft: resume=$resume', From fb9c8fc57b5cb10eb3cf5319f50cfb2d432fa159 Mon Sep 17 00:00:00 2001 From: Logan Johnson Date: Tue, 10 Nov 2026 10:49:10 -0500 Subject: [PATCH 2/3] test(mobile): cover Solo's inventory-name preference Solo shows the owner's inventory name ahead of an agent's cached profile name, and a verified name ahead of both. The widget tests used one name for both sources, so nothing caught losing that preference. Co-authored-by: Larry <627498bd4bd1f281a16431e3c6cce3b5c25b6692798c78672298aefbf2f8f8b5@buzz.block.builderlab.xyz> Co-Authored-By: Claude Opus 5.5 Signed-off-by: Logan Johnson --- .../shared/me/me_assistant_names_test.dart | 37 +++++++++++++++++++ 1 file changed, 37 insertions(+) create mode 100644 mobile/test/shared/me/me_assistant_names_test.dart diff --git a/mobile/test/shared/me/me_assistant_names_test.dart b/mobile/test/shared/me/me_assistant_names_test.dart new file mode 100644 index 00000000000..c1374f81804 --- /dev/null +++ b/mobile/test/shared/me/me_assistant_names_test.dart @@ -0,0 +1,37 @@ +import 'package:buzz/shared/identity_names/identity_names.dart'; +import 'package:buzz/shared/me/me_assistant_names.dart'; +import 'package:buzz/shared/mentions/agent_identity_provider.dart'; +import 'package:buzz/shared/profile/user_profile.dart'; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + final atlas = 'a' * 64; + final uncached = 'b' * 64; + final agents = [ + AgentDirectoryEntry(pubkey: atlas, displayName: 'Atlas'), + AgentDirectoryEntry(pubkey: uncached, displayName: 'Beacon'), + ]; + + test('Solo prefers the inventory name to a cached profile name', () { + final cached = IdentityNameSources( + profiles: {atlas: UserProfile(pubkey: atlas, displayName: 'Old name')}, + ); + final names = meAssistantNames(cached, agents); + expect(names.labelFor(atlas), 'Atlas'); + expect(names.labelFor(uncached), 'Beacon'); + // An uncached profile stays absent, so the caller still loads it. + expect(meAssistantNameSources(cached, agents).profiles, contains(atlas)); + expect( + meAssistantNameSources(cached, agents).profiles, + isNot(contains(uncached)), + ); + }); + + test('a verified name still wins over the inventory name', () { + final verified = IdentityNameSources( + profiles: {atlas: UserProfile(pubkey: atlas, displayName: 'Old name')}, + verifiedNames: {atlas: 'Atlas Prime'}, + ); + expect(meAssistantNames(verified, agents).labelFor(atlas), 'Atlas Prime'); + }); +} From 6bd089443d02378b441be2e1f59ddb8bb1b6e5ce Mon Sep 17 00:00:00 2001 From: Logan Johnson Date: Tue, 10 Nov 2026 14:01:22 -0500 Subject: [PATCH 3/3] fix(mobile): name saved archived agents apart; skip names for idle Solo rows A saved agent switch or a resumed new chat can still point to an agent that is now archived. Its recovery notice, Resume/Discard dialog and draft header now label it as an agent against the live agents, so it never reads exactly like a live namesake. Solo rows no longer build a name context while no agent is working in them. Working rows read one shared context instead of each copying the profile cache. Co-authored-by: Larry <627498bd4bd1f281a16431e3c6cce3b5c25b6692798c78672298aefbf2f8f8b5@buzz.block.builderlab.xyz> Co-Authored-By: Claude Opus 5.5 Signed-off-by: Logan Johnson --- .../channels/me/me_chat_assistant.dart | 47 ++++++------ .../features/channels/me/me_chat_draft.dart | 9 ++- .../channels/me/me_working_provider.dart | 14 ++-- mobile/lib/shared/me/me_assistant_names.dart | 19 ++++- .../channels/me/me_chat_assistant_test.dart | 21 ++++++ .../features/channels/me/me_page_test.dart | 72 +++++++++++++++++++ .../channels/me/me_working_provider_test.dart | 66 +++++++++++++++++ 7 files changed, 213 insertions(+), 35 deletions(-) create mode 100644 mobile/test/features/channels/me/me_working_provider_test.dart diff --git a/mobile/lib/features/channels/me/me_chat_assistant.dart b/mobile/lib/features/channels/me/me_chat_assistant.dart index a6db80d9611..53683a36abe 100644 --- a/mobile/lib/features/channels/me/me_chat_assistant.dart +++ b/mobile/lib/features/channels/me/me_chat_assistant.dart @@ -97,28 +97,6 @@ useMeChatAssistant( ? ref.watch(userCacheProvider)[selected] ?? ref.read(userCacheProvider.notifier).get(selected) : null; - // The owner's inventory name outranks the agent's self-published profile. - final names = watchMeAssistantNames(ref, agents); - final name = agents.any((agent) => agent.pubkey == selected) - ? names.labelFor(selected!) - : profile?.displayName; - final avatarInitial = name?.trim().isNotEmpty == true - ? name!.trim()[0].toUpperCase() - : profile?.initial ?? '?'; - String agentName(String pubkey) { - final candidates = [ - agents.any((a) => a.pubkey == pubkey) ? names.labelFor(pubkey) : null, - ref.read(userCacheProvider)[pubkey]?.displayName, - ]; - return candidates - .whereType() - .where((name) => name.trim().isNotEmpty) - .firstOrNull ?? - (pubkey.length > 12 - ? '${pubkey.substring(0, 8)}…${pubkey.substring(pubkey.length - 4)}' - : pubkey); - } - MeAgentChange? pending; if (sync != null) { try { @@ -127,6 +105,31 @@ useMeChatAssistant( // The action surfaces an unreadable journal without crashing the chat. } } + // The owner's inventory name outranks the agent's self-published profile. + final names = watchMeAssistantNames( + ref, + agents, + saved: [selected, pending?.agent], + ); + // A known agent outside the inventory, such as an archived one, is labeled + // against the live agents so it never reads as a live namesake. + bool named(String pubkey) => + agents.any((agent) => agent.pubkey == pubkey) || + ref.read(userCacheProvider)[pubkey]?.displayName?.trim().isNotEmpty == + true; + final name = selected != null && named(selected) + ? names.labelFor(selected) + : profile?.displayName; + final avatarInitial = name?.trim().isNotEmpty == true + ? name!.trim()[0].toUpperCase() + : profile?.initial ?? '?'; + String agentName(String pubkey) { + if (named(pubkey)) return names.labelFor(pubkey); + return pubkey.length > 12 + ? '${pubkey.substring(0, 8)}…${pubkey.substring(pubkey.length - 4)}' + : pubkey; + } + final pendingName = pending == null ? null : agentName(pending.agent); final label = pendingName != null ? 'Pending agent change: $pendingName' diff --git a/mobile/lib/features/channels/me/me_chat_draft.dart b/mobile/lib/features/channels/me/me_chat_draft.dart index e509e7b9b52..cf5242eb279 100644 --- a/mobile/lib/features/channels/me/me_chat_draft.dart +++ b/mobile/lib/features/channels/me/me_chat_draft.dart @@ -27,13 +27,18 @@ class _MeChatDraft extends HookConsumerWidget { final agent = choices .where((agent) => agent.pubkey == selected) .firstOrNull; - final names = watchMeAssistantNames(ref, choices); + final names = watchMeAssistantNames(ref, choices, saved: [selected]); // The composer keeps the plain name; only what the owner reads is labeled. final plainName = agent?.displayName ?? profile?.label ?? (selected == null ? 'Choose an agent' : 'Agent'); - final name = agent == null ? plainName : names.labelFor(agent.pubkey); + // A saved choice that is no longer offered, such as an archived agent, is + // labeled against the live agents so it never reads as a live namesake. + final name = + agent != null || profile?.displayName?.trim().isNotEmpty == true + ? names.labelFor(selected!) + : plainName; final avatarInitial = name.isEmpty ? 'A' : name.characters.first; final scopeChanged = originalScope != (sync.community, sync.pubkey); final frozen = diff --git a/mobile/lib/features/channels/me/me_working_provider.dart b/mobile/lib/features/channels/me/me_working_provider.dart index 3a4c282a9b3..f22f6ad75f0 100644 --- a/mobile/lib/features/channels/me/me_working_provider.dart +++ b/mobile/lib/features/channels/me/me_working_provider.dart @@ -1,6 +1,5 @@ import 'package:hooks_riverpod/hooks_riverpod.dart'; -import '../../../shared/identity_names/identity_names_provider.dart'; import '../../../shared/me/me_assistant_names.dart'; import '../../../shared/me/me_assistants.dart'; import '../channel_typing_provider.dart'; @@ -12,15 +11,14 @@ final meWorkingAgentsProvider = Provider.autoDispose .family, String>((ref, channelId) { final agents = ref.watch(meAssistantsProvider).value ?? []; if (agents.isEmpty) return const []; - final names = meAssistantNames( - ref.watch(identityNameSourcesProvider), - agents, - ); final typing = ref.watch(channelTypingProvider(channelId)); final active = typing.map((entry) => entry.pubkey.toLowerCase()).toSet(); - return [ + final working = [ for (final agent in agents) - if (active.contains(agent.pubkey.toLowerCase())) - names.labelFor(agent.pubkey), + if (active.contains(agent.pubkey.toLowerCase())) agent.pubkey, ]; + // An idle chat reads no names at all. + if (working.isEmpty) return const []; + final names = ref.watch(meAssistantNamesProvider); + return [for (final pubkey in working) names.labelFor(pubkey)]; }); diff --git a/mobile/lib/shared/me/me_assistant_names.dart b/mobile/lib/shared/me/me_assistant_names.dart index 32c6289663b..0442f3cc84d 100644 --- a/mobile/lib/shared/me/me_assistant_names.dart +++ b/mobile/lib/shared/me/me_assistant_names.dart @@ -4,6 +4,7 @@ import '../identity_names/identity_names.dart'; import '../identity_names/identity_names_provider.dart'; import '../mentions/agent_identity_provider.dart'; import '../profile/user_profile.dart'; +import 'me_assistants.dart'; /// [base] with the owner's inventory name ahead of each agent's self-published /// profile name, as Solo has always shown it. Verified names still win, and @@ -55,16 +56,28 @@ IdentityNames meAssistantNames( ).scope(keys, agentPubkeys: keys.toSet()); } +/// [meAssistantNames] for the live inventory, built once for every reader so +/// each Solo row does not copy the profile cache again. +final meAssistantNamesProvider = Provider.autoDispose( + (ref) => meAssistantNames( + ref.watch(identityNameSourcesProvider), + ref.watch(meAssistantsProvider).value ?? const [], + ), +); + /// [meAssistantNames] for a widget, loading the profiles it still lacks. +/// [saved] are agents a saved choice still names that Solo no longer offers, +/// such as an archived agent: they are labeled as agents against [agents]. IdentityNames watchMeAssistantNames( WidgetRef ref, - List agents, -) { + List agents, { + Iterable saved = const [], +}) { final keys = [for (final agent in agents) agent.pubkey]; return watchIdentityNames( ref, keys, - agentPubkeys: keys.toSet(), + agentPubkeys: {...keys, ...saved.nonNulls}, sources: meAssistantNameSources( ref.watch(identityNameSourcesProvider), agents, diff --git a/mobile/test/features/channels/me/me_chat_assistant_test.dart b/mobile/test/features/channels/me/me_chat_assistant_test.dart index 2714a19bf6c..a9425e1817d 100644 --- a/mobile/test/features/channels/me/me_chat_assistant_test.dart +++ b/mobile/test/features/channels/me/me_chat_assistant_test.dart @@ -525,5 +525,26 @@ void main() { await tester.tap(find.byKey(const ValueKey('me-assistant-button'))); await tester.pumpAndSettle(); expect(pickerLabels(), ['Atlas']); + await tester.tapAt(Offset.zero); + await tester.pumpAndSettle(); + + // A saved switch to the archived agent still names it apart from its live + // namesake, where the owner decides whether to finish it. + await MeAgentChange.create(sync, 'chat', first).save(sync, 'chat'); + ProviderScope.containerOf( + tester.element(find.byType(_Harness)), + ).invalidate(meAssistantsProvider); + await tester.pumpAndSettle(); + final pending = find.textContaining('Finish choosing '); + final archivedName = tester + .widget(pending) + .data! + .substring('Finish choosing '.length); + expect(archivedName, startsWith('Atlas')); + expect(archivedName, isNot('Atlas')); + expect(headerLabel(), 'Pending agent change: $archivedName'); + await tester.tap(pending); + await tester.pumpAndSettle(); + expect(find.textContaining('Your choice of $archivedName'), findsOneWidget); }); } diff --git a/mobile/test/features/channels/me/me_page_test.dart b/mobile/test/features/channels/me/me_page_test.dart index 38fdb66ea97..9a89975375f 100644 --- a/mobile/test/features/channels/me/me_page_test.dart +++ b/mobile/test/features/channels/me/me_page_test.dart @@ -23,6 +23,15 @@ import 'package:buzz/shared/me/me_groups.dart'; import 'package:buzz/shared/me/me_sync.dart'; import 'package:buzz/shared/theme/theme.dart'; import 'package:buzz/shared/widgets/app_list.dart'; +import 'package:buzz/shared/profile/user_cache_provider.dart'; +import 'package:buzz/shared/profile/user_profile.dart'; + +class _CachedProfiles extends UserCacheNotifier { + _CachedProfiles(this.profiles); + final Map profiles; + @override + Map build() => profiles; +} class _UnavailableRelay extends RelaySessionNotifier { @override @@ -501,6 +510,69 @@ void main() { expect(pickerLabels(), ['Atlas']); }); + testWidgets('a resumed chat for an archived agent names it apart', ( + tester, + ) async { + SharedPreferences.setMockInitialValues({}); + final prefs = await SharedPreferences.getInstance(); + final action = ValueNotifier(null); + addTearDown(action.dispose); + final sync = MeSync( + _UnavailableRelay(), + RelayConfig( + baseUrl: 'https://relay.test', + nsec: nostr.Keys.generate().nsec, + ), + prefs: prefs, + ); + final archived = 'a1' * 32; + final live = 'b2' * 32; + await MeChatCreation('same-id', 'Unfinished chat', archived).save(sync); + await tester.pumpWidget( + ProviderScope( + overrides: [ + savedPrefsProvider.overrideWithValue(prefs), + defaultAgentProvider.overrideWith((ref) => Stream.value(null)), + meSyncProvider.overrideWithValue(sync), + meGroupsProvider.overrideWith( + (ref) => Stream.value(const MeGroups()), + ), + channelsProvider.overrideWith(FakeChannels.new), + meAssistantsProvider.overrideWith( + (ref) async => [ + AgentDirectoryEntry(pubkey: live, displayName: 'Atlas'), + ], + ), + // The archived agent's profile stays in the client's cache. + userCacheProvider.overrideWith( + () => _CachedProfiles({ + archived: UserProfile(pubkey: archived, displayName: 'Atlas'), + }), + ), + agentDirectoryProvider.overrideWith((ref) async => []), + agentOwnersProvider.overrideWith((ref) async => {}), + customEmojiListProvider.overrideWithValue([]), + ], + child: MaterialApp( + theme: AppTheme.light(), + home: MePage(newChatAction: action), + ), + ), + ); + await tester.pumpAndSettle(); + action.value!(); + await tester.pumpAndSettle(); + await tester.tap(find.text('Resume')); + await tester.pumpAndSettle(); + final tooltip = tester + .widgetList(find.byType(IconButton)) + .map((button) => button.tooltip) + .whereType() + .singleWhere((label) => label.startsWith('Change agent: ')); + expect(tooltip, startsWith('Change agent: Atlas')); + expect(tooltip, isNot('Change agent: Atlas')); + }); + for (final resume in [true, false]) { testWidgets( 'new chat resumes a named creation in the draft: resume=$resume', diff --git a/mobile/test/features/channels/me/me_working_provider_test.dart b/mobile/test/features/channels/me/me_working_provider_test.dart new file mode 100644 index 00000000000..ee589c9fe13 --- /dev/null +++ b/mobile/test/features/channels/me/me_working_provider_test.dart @@ -0,0 +1,66 @@ +import 'package:buzz/features/channels/channel_typing_provider.dart'; +import 'package:buzz/features/channels/me/me_working_provider.dart'; +import 'package:buzz/shared/identity_names/identity_names.dart'; +import 'package:buzz/shared/identity_names/identity_names_provider.dart'; +import 'package:buzz/shared/me/me_assistant_names.dart'; +import 'package:buzz/shared/me/me_assistants.dart'; +import 'package:buzz/shared/mentions/agent_identity_provider.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:hooks_riverpod/hooks_riverpod.dart'; + +class _Typing extends ChannelTypingNotifier { + _Typing(super.channelId); + @override + List build() => []; + void show(List pubkeys) => state = [ + for (final pubkey in pubkeys) + TypingEntry(pubkey: pubkey, expiresAtMs: 999999), + ]; +} + +void main() { + test('idle Solo rows build no names; working namesakes read apart', () async { + final first = 'a1' * 32; + final second = 'b2' * 32; + final chats = ['one', 'two']; + final typing = {for (final id in chats) id: _Typing(id)}; + var nameReads = 0; + final container = ProviderContainer( + overrides: [ + meAssistantsProvider.overrideWith( + (ref) async => [ + for (final key in [first, second]) + AgentDirectoryEntry(pubkey: key, displayName: 'Atlas'), + ], + ), + identityNameSourcesProvider.overrideWith((ref) { + nameReads++; + return IdentityNameSources.empty; + }), + for (final id in chats) + channelTypingProvider(id).overrideWith(() => typing[id]!), + ], + ); + addTearDown(container.dispose); + await container.read(meAssistantsProvider.future); + final rows = { + for (final id in chats) + id: container.listen(meWorkingAgentsProvider(id), (_, _) {}), + }; + + // Nobody is working: no row copies the profile cache into a name context. + expect([for (final row in rows.values) row.read()], [isEmpty, isEmpty]); + expect(nameReads, 0); + expect(container.exists(meAssistantNamesProvider), isFalse); + + typing['one']!.show([first]); + typing['two']!.show([first, second]); + final one = rows['one']!.read(); + final two = rows['two']!.read(); + expect(one, hasLength(1)); + expect(two, hasLength(2)); + expect(two.toSet(), hasLength(2), reason: 'Namesakes must differ'); + expect(two.first, one.single, reason: 'One agent reads alike everywhere'); + expect(two, everyElement(startsWith('Atlas'))); + }); +}