Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 3 additions & 1 deletion mobile/lib/features/channels/me/me_assistant_list.dart
Original file line number Diff line number Diff line change
@@ -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';
Expand All @@ -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,
Expand Down Expand Up @@ -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,
Expand Down
55 changes: 30 additions & 25 deletions mobile/lib/features/channels/me/me_chat_assistant.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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<AgentDirectoryEntry> agents = enabled
? ref.watch(meAssistantsProvider).value ?? []
: [];
final members = enabled
? ref.watch(channelMembersProvider(channelId)).value ?? []
: [];
Expand All @@ -93,30 +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 name =
agents
.where((agent) => agent.pubkey == selected)
.firstOrNull
?.displayName ??
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,
ref.read(userCacheProvider)[pubkey]?.displayName,
];
return names
.whereType<String>()
.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 {
Expand All @@ -125,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'
Expand Down
12 changes: 10 additions & 2 deletions mobile/lib/features/channels/me/me_chat_draft.dart
Original file line number Diff line number Diff line change
Expand Up @@ -27,10 +27,18 @@ class _MeChatDraft extends HookConsumerWidget {
final agent = choices
.where((agent) => agent.pubkey == selected)
.firstOrNull;
final name =
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');
// 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 =
Expand Down Expand Up @@ -249,7 +257,7 @@ class _MeChatDraft extends HookConsumerWidget {
pubkey: selected,
role: 'bot',
joinedAt: DateTime(2026),
displayName: name,
displayName: plainName,
),
],
onSendingChanged: (value) {
Expand Down
1 change: 1 addition & 0 deletions mobile/lib/features/channels/me/me_page.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down
12 changes: 7 additions & 5 deletions mobile/lib/features/channels/me/me_working_provider.dart
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import 'package:hooks_riverpod/hooks_riverpod.dart';

import '../../../shared/me/me_assistant_names.dart';
import '../../../shared/me/me_assistants.dart';
import '../channel_typing_provider.dart';

Expand All @@ -12,11 +13,12 @@ final meWorkingAgentsProvider = Provider.autoDispose
if (agents.isEmpty) return const [];
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()))
agent.displayName?.trim().isNotEmpty == true
? agent.displayName!.trim()
: 'Agent',
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)];
});
Original file line number Diff line number Diff line change
Expand Up @@ -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<String> candidates, {
Set<String> agentPubkeys = const {},
Map<String, String> fallbackNames = const {},
Iterable<String>? shown,
IdentityNameSources? sources,
}) {
final sources = ref.watch(identityNameSourcesProvider);
final names = sources.scope(
final IdentityNameSources facts =
sources ?? ref.watch<IdentityNameSources>(identityNameSourcesProvider);
final names = facts.scope(
candidates,
agentPubkeys: agentPubkeys,
fallbackNames: fallbackNames,
Expand All @@ -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) {
Expand Down
86 changes: 86 additions & 0 deletions mobile/lib/shared/me/me_assistant_names.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,86 @@
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';
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
/// every other naming fact is untouched.
IdentityNameSources meAssistantNameSources(
IdentityNameSources base,
List<AgentDirectoryEntry> 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<AgentDirectoryEntry> agents,
) {
final keys = [for (final agent in agents) agent.pubkey];
return meAssistantNameSources(
sources,
agents,
).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<IdentityNames>(
(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<AgentDirectoryEntry> agents, {
Iterable<String?> saved = const [],
}) {
final keys = [for (final agent in agents) agent.pubkey];
return watchIdentityNames(
ref,
keys,
agentPubkeys: {...keys, ...saved.nonNulls},
sources: meAssistantNameSources(
ref.watch(identityNameSourcesProvider),
agents,
),
);
}
Loading
Loading