Files
buzz/mobile/lib/features/agents/agents_page.dart
Logan Johnson dd2d3cf4ea fix(mobile): hide archived people and agents from choices; give them their own members section (#9320)
## Summary
Mobile kept offering archived people and agents as choices: Add members
(including adding an agent to a Solo chat), New DM, the `@` mention
chooser, Search › People and the Agents page. An archived agent that
shares a live agent's name also forced a qualifier onto the live one,
because names were told apart across both. The members sheet listed
archived members among live ones with nothing to mark them.

Now, matching desktop:
- Archived people and agents are left out of those choices. History
(messages, activity, profiles) still shows them.
- The members sheet moves archived members out of People and Agents into
their own "Archived · N" section after both: archived people, then
archived agents. Each row reads "Archived · Member" (or Agent) in place
of the role, status or "Working…" line. A screen reader reads it with
the name. The members preview on the channel info page (the first five
members above "See all") follows the same order and label.
- Nothing is hidden or marked while the community's archive list is
unknown or cannot be read, and you are never hidden from yourself.

### Related issue
None found. The desktop members dialog gets the same Archived section in
https://github.com/block/buzz-app/pull/3203.

### Testing
Widget and provider tests cover each surface, the unknown-archive case,
the viewer rule and a community switch. Each test fails without its
change.

Android emulator, local relay, fictional names. Each archived person or
agent has a live namesake. History rows still show archived identities,
as intended.

**`@` mention chooser, typing "@Sc" (Scout `qsfn` is archived)**

| Before | After |
| --- | --- |
| <img
src="https://github.com/user-attachments/assets/6693729c-66c1-4433-8a7b-4f0e948057ee"
width="320"> | <img
src="https://github.com/user-attachments/assets/eb4f80be-780e-4dbe-8b2b-e6a5b6c3ca97"
width="320"> |

**`@` mention chooser, typing "@Ca" (Casey Lee `9mhy` is archived)**

| Before | After |
| --- | --- |
| <img
src="https://github.com/user-attachments/assets/b6d409cd-7af1-4da7-b5b9-deece5e21de5"
width="320"> | <img
src="https://github.com/user-attachments/assets/60e21387-791b-4233-b8e6-1a29f94e6810"
width="320"> |

**Add members, searching "Morgan" (Morgan Reyes `ssc2` is archived)**

| Before | After |
| --- | --- |
| <img
src="https://github.com/user-attachments/assets/1222a923-5e43-4263-843d-57564bdc7af3"
width="320"> | <img
src="https://github.com/user-attachments/assets/dbe58278-788d-4a89-83bf-f0b4bc119c5e"
width="320"> |

**Add members, searching "Atlas" (Atlas `85gl` is archived)**

| Before | After |
| --- | --- |
| <img
src="https://github.com/user-attachments/assets/55c67a21-4cf5-47c5-b6ac-1ba80d68a3ec"
width="320"> | <img
src="https://github.com/user-attachments/assets/54b19ab3-fa2f-4933-8b67-1056d6e3b3a4"
width="320"> |

**New DM, no query yet**

| Before | After |
| --- | --- |
| <img
src="https://github.com/user-attachments/assets/6a26fd49-4a06-47bd-ba37-37e5898ab344"
width="320"> | <img
src="https://github.com/user-attachments/assets/3f33eb2b-f27a-4528-91c0-ac76d3fdf44b"
width="320"> |

**New DM, searching "Morgan"**

| Before | After |
| --- | --- |
| <img
src="https://github.com/user-attachments/assets/22d2d617-3074-4910-8c39-e0778e47a879"
width="320"> | <img
src="https://github.com/user-attachments/assets/fa00b160-e350-49af-94d0-beb15c357bef"
width="320"> |

**Search › People, "Morgan"**

| Before | After |
| --- | --- |
| <img
src="https://github.com/user-attachments/assets/83a28f2b-9b22-4d0c-8728-8a2dfb10e634"
width="320"> | <img
src="https://github.com/user-attachments/assets/573aed3f-75ab-478f-8c11-bedab5c6ce4f"
width="320"> |

**Agents page**

| Before | After |
| --- | --- |
| <img
src="https://github.com/user-attachments/assets/d7980f95-825c-4d1a-ad67-95f906a52e9a"
width="320"> | <img
src="https://github.com/user-attachments/assets/6252c38a-b2ec-48dc-ab4e-824ea29bbd85"
width="320"> |

**Channel info, members preview**

| Before | After |
| --- | --- |
| <img
src="https://github.com/user-attachments/assets/8744cdfd-c073-4ade-bd53-9d5e137cee60"
width="320"> | <img
src="https://github.com/user-attachments/assets/5240b1be-13d5-4f22-85a9-8375f1d8ac18"
width="320"> |

**Members sheet, top**

| Before | After |
| --- | --- |
| <img
src="https://github.com/user-attachments/assets/c9241478-99b1-452e-ad41-8c38fe080182"
width="320"> | <img
src="https://github.com/user-attachments/assets/3c6f9247-60c6-47df-94b0-9175f25805bb"
width="320"> |

**Members sheet, scrolled to the end**

| Before | After |
| --- | --- |
| <img
src="https://github.com/user-attachments/assets/7da7fcc8-28ea-4c7a-904e-91ca5a068d23"
width="320"> | <img
src="https://github.com/user-attachments/assets/024f676e-8150-457d-b7d0-67fa1a680f8d"
width="320"> |

## Details
- The archive reader (the community's NIP-IA archive list, signed by the
relay's NIP-11 `self` key) moves from `shared/me` to
`shared/identity_archives`, since it is no longer Solo-only.
`hiddenArchivedIdentitiesProvider` is the single rule the surfaces read.
- Pulling to refresh on the Agents page also rereads the archive list.
Other views reuse the last list, as on desktop; it is also reread on
reconnect and when the agent inventory changes.
- A refresh keeps the last known list. After a community or account
switch, the previous community's list does not apply: nobody is hidden
until the new list arrives.
- The shared chooser drops archived identities before names are
compared, which covers Add members, New DM and mentions.
- Search › People asks the relay for 24 people and shows the first 8
that are not archived, so archived matches do not push live people out.
- The channel info preview shows the label on one line, so with a long
name the role after "Archived" can be cut off. A screen reader still
reads it in full.
- Not changed: typing a full `@Name` by hand (without the chooser) still
resolves against archived channel members too, so an archived namesake
can still make a typed name ambiguous. Picking from the chooser is
unaffected. Possible follow-up.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

---------

Signed-off-by: Logan Johnson <loganj@squareup.com>
Co-authored-by: Larry <627498bd4bd1f281a16431e3c6cce3b5c25b6692798c78672298aefbf2f8f8b5@buzz.block.builderlab.xyz>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-10 11:10:49 -08:00

751 lines
28 KiB
Dart
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import 'dart:async';
import 'package:flutter/material.dart';
import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import '../../shared/agents/owned_agent_directory.dart';
import '../../shared/widgets/bee_refresh_indicator.dart';
import '../../shared/widgets/buzz_toast.dart';
import '../../shared/agents/remote_agents.dart';
import '../../shared/deeplink/deep_link.dart';
import '../../shared/widgets/feedback_snackbar.dart';
import '../../shared/community/community_provider.dart';
import '../../shared/me/default_agent.dart';
import '../../shared/hosted/hosted_flow.dart';
import '../../shared/hosted/hosted_flow_provider.dart';
import '../../shared/identity_archives/identity_archives.dart';
import '../../shared/identity_names/identity_names_provider.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';
import '../../shared/widgets/modal_presentation.dart';
import '../../shared/widgets/agent_navigation_icon.dart';
import '../../shared/me/me_sync.dart';
import '../channels/bestie_welcome_storage.dart';
import '../channels/channel.dart';
import '../channels/channel_detail_page.dart';
import '../channels/channel_management_provider.dart';
import '../profile/user_profile_sheet.dart';
import 'agent_creation_draft.dart';
import 'create_agent_page.dart';
import 'agent_card.dart';
import 'remote_agent_model_control.dart';
import '../../shared/widgets/frosted_app_bar.dart';
import '../../shared/widgets/frosted_scaffold.dart';
import '../../shared/theme/buzz_icons.dart';
import 'agent_card_grid.dart';
import 'delete_agent_action.dart';
import 'agent_identity_sections.dart';
import 'bestie_agent_action.dart';
Route<Channel>? _openAgentsRoute;
/// The open Agents stack's nested navigator, so a link can tell whether
/// anything is open above its list.
GlobalKey<NavigatorState>? _openAgentsNavigator;
/// Opens the active community's full-page Agents navigation stack.
///
/// A [draft] from a `buzz://agent/new` link opens [CreateAgentPage] pre-filled
/// once the account, registry and community gates allow it.
Future<void> showAgents(
BuildContext context, {
AgentCreationDeepLink? draft,
bool createOnOpen = false,
}) async {
final container = ProviderScope.containerOf(context);
final controller = container.read(remoteAgentControllerProvider);
if (!context.mounted) return;
final navigator = Navigator.of(context);
// A link replaces an Agents stack that is already open instead of stacking
// a second one on top of it, but only while it shows just its list: a form,
// profile or dialog above it may hold work the user hasn't finished.
final open = _openAgentsRoute;
if (createOnOpen &&
open != null &&
open.isActive &&
open.navigator == navigator) {
return;
}
if (draft != null &&
open != null &&
open.isActive &&
open.navigator == navigator) {
if (!open.isCurrent ||
(_openAgentsNavigator?.currentState?.canPop() ?? false)) {
ScaffoldMessenger.maybeOf(context)?.showFeedbackSnackBar(
const SnackBar(
content: Text(
'Finish or close what’s open in Agents, then reopen the link.',
),
),
kind: ToastKind.error,
);
return;
}
navigator.removeRoute(open);
}
unawaited(controller.run(controller.refresh));
final route = MaterialPageRoute<Channel>(
builder: (_) => _AgentsNavigation(
controller: controller,
draft: draft,
createOnOpen: createOnOpen,
),
);
_openAgentsRoute = route;
final channel = await navigator.push(route);
if (identical(_openAgentsRoute, route)) {
_openAgentsRoute = null;
_openAgentsNavigator = null;
}
// The caller's context can be gone by now: iOS Home drops the Bestie
// shortcut while another route covers it. The navigator outlives it.
if (channel != null && navigator.mounted) {
await navigator.push<void>(
MaterialPageRoute(
// Native glass and video layers must track the live route.
allowSnapshotting: false,
builder: (_) => ChannelDetailPage(channel: channel),
),
);
}
}
class _AgentsNavigation extends HookConsumerWidget {
const _AgentsNavigation({
required this.controller,
this.draft,
this.createOnOpen = false,
});
final bool createOnOpen;
final RemoteAgentController controller;
final AgentCreationDeepLink? draft;
@override
Widget build(BuildContext context, WidgetRef ref) {
final communityId = ref.watch(activeCommunityProvider).asData?.value?.id;
final heroController = useMemoized(HeroController.new);
final navigator = useMemoized(() => GlobalKey<NavigatorState>(), [
communityId,
]);
if (identical(ModalRoute.of(context), _openAgentsRoute)) {
_openAgentsNavigator = navigator;
}
return NavigatorPopHandler<Channel>(
onPopWithResult: (result) {
if (navigator.currentState?.canPop() ?? false) {
navigator.currentState!.pop(result);
}
},
child: Navigator(
key: navigator,
observers: [heroController],
onGenerateRoute: (_) => MaterialPageRoute<void>(
builder: (listContext) => Material(
color: context.colors.surface,
child: AgentsPage(
controller: controller,
draft: draft,
createOnOpen: createOnOpen,
onClose: () => Navigator.of(context).pop(),
onCreated: (pubkey) async {
// Only a first agent becomes Bestie; continue into its chat.
final scope = ref.read(meSyncProvider);
bool current() =>
context.mounted && ref.read(meSyncProvider) == scope;
final Channel channel;
try {
if (await ref.read(defaultAgentProvider.future) != pubkey ||
!current()) {
return;
}
channel = await ref
.read(channelActionsProvider)
.openDm(pubkeys: [pubkey]);
} catch (error) {
if (context.mounted) {
ScaffoldMessenger.maybeOf(context)?.showFeedbackSnackBar(
SnackBar(
content: Text(
'Could not open the agent conversation. $error',
),
),
kind: ToastKind.error,
);
}
return;
}
if (!current()) return;
// The welcome is local decoration; never let it block the chat.
try {
await saveBestieWelcome(
ref.read(savedPrefsProvider),
scope,
channel.id,
pubkey,
);
ref.invalidate(bestieWelcomeProvider(channel.id));
} catch (error) {
debugPrint('agents: Bestie welcome not saved: $error');
}
// Close only the Agents stack, never a route opened above it.
if (!context.mounted || !current()) return;
if (ModalRoute.of(context)?.isCurrent ?? false) {
Navigator.of(context).pop(channel);
}
},
onSelect: (pubkey) async {
final channel = await showBuzzModalBottomSheet<Channel>(
context: listContext,
isScrollControlled: true,
showDragHandle: false,
showCloseButton: false,
padBottom: false,
builder: (_) => UserProfileSheet(
pubkey: pubkey,
agentPresentation: true,
agentSubtitleIcon:
controller.agents.any(
(agent) => agent['agent_pubkey'] == pubkey,
)
? const AgentNavigationIcon(size: 16)
: null,
agentSubtitle:
controller.agents.any(
(agent) => agent['agent_pubkey'] == pubkey,
)
? 'Builderbot agent'
: agentLocationUnknown(
ref.read(activeCommunityProvider).asData?.value,
controller,
)
? null
: 'Manage on desktop',
primaryActionsSpacing: Grid.md,
contextualActions: (_) =>
BestieAgentAction(pubkey: pubkey, compact: true),
agentIdentity: (_) => AgentIdentitySections(
pubkey: pubkey,
controller: controller,
),
agentSettings: (_) => DeleteAgentAction(
asSettingsRow: true,
controller: controller,
pubkey: pubkey,
),
),
);
if (channel != null && context.mounted) {
Navigator.of(context).pop(channel);
}
},
),
),
),
),
);
}
}
/// The current user's verified agents in the active community.
class AgentsPage extends HookConsumerWidget {
const AgentsPage({
required this.controller,
required this.onClose,
required this.onSelect,
this.onCreated,
this.draft,
this.createOnOpen = false,
super.key,
});
/// Link-supplied draft to open in [CreateAgentPage] when permitted.
final AgentCreationDeepLink? draft;
/// Open creation only after confirming there are no agents or saved setup.
final bool createOnOpen;
final VoidCallback onClose;
final ValueChanged<String> onSelect;
/// With [createOnOpen], called with the agent a creation here finished.
/// The list stays busy until it completes.
final Future<void> Function(String pubkey)? onCreated;
final RemoteAgentController controller;
@override
Widget build(BuildContext context, WidgetRef ref) {
useListenable(controller);
useListenable(controller.account.subjectChanges);
final flow = ref.watch(hostedFlowProvider);
useListenable(flow);
final signingIn =
flow.reconnecting &&
(flow.stage == HostedFlowStage.signIn ||
flow.stage == HostedFlowStage.working);
// Account sign-in belongs to the shared hosted flow; reload once it lands.
useEffect(() {
if (flow.reconnecting && flow.stage == HostedFlowStage.complete) {
unawaited(controller.run(controller.refresh));
}
return null;
}, [flow.stage]);
final community = ref.watch(activeCommunityProvider).asData?.value;
final bestie = ref.watch(defaultAgentProvider).asData?.value;
final ownedDirectory = ref.watch(ownedAgentDirectoryProvider);
// The account's remote agents with a verified owner profile here are
// listed too, so one stays listed while a deletion that already removed
// its registration is retried. Every other agent comes from its listed
// registration.
final candidates = {
if (controller.owner == community?.pubkey)
for (final agent in controller.agents) agent['agent_pubkey'] as String,
};
final candidateKey = (candidates.toList()..sort()).join(',');
final attempt = useState(0);
final registryFuture = useMemoized(() async {
final fetch = ref.read(relaySessionProvider.notifier).fetchHistory;
return ownedCommunityAgents(candidates, community?.pubkey, fetch);
}, [community?.relayUrl, community?.pubkey, candidateKey, attempt.value]);
final registered = useFuture(registryFuture, preserveState: false);
final scope = '${community?.relayUrl}|${community?.pubkey}|$candidateKey';
final lastOwned = useRef<(String, Set<String>)?>(null);
if (registered.data case final owned?) lastOwned.value = (scope, owned);
final owned =
registered.data ??
(lastOwned.value?.$1 == scope ? lastOwned.value?.$2 : null);
// Archived agents are not offered, as on desktop, where archiving an
// agent takes it out of the library.
final archived = ref.watch(hiddenArchivedIdentitiesProvider);
final keys =
{
...?owned,
for (final entry
in ownedDirectory.asData?.value ?? <AgentDirectoryEntry>[])
entry.pubkey,
}
.where(
(key) =>
!controller.deletedAgents.contains(key) &&
!archived.contains(key),
)
.toList();
final names = watchIdentityNames(
ref,
keys,
agentPubkeys: keys.toSet(),
fallbackNames: {
for (final entry
in ownedDirectory.asData?.value ?? <AgentDirectoryEntry>[])
if (entry.displayName != null) entry.pubkey: entry.displayName!,
for (final agent in controller.agents)
agent['agent_pubkey'] as String: agent['agent_name'] as String,
},
);
// Buzz App orders names without case and uses the identity as a stable tie.
keys.sort((a, b) {
final byName = names
.labelFor(a)
.toLowerCase()
.compareTo(names.labelFor(b).toLowerCase());
return byName != 0 ? byName : a.compareTo(b);
});
final profiles = ref.watch(userCacheProvider);
final creating = useState(false);
final needsSignIn = agentLocationUnknown(community, controller);
final signInNeeded = builderlabSignInNeeded(community, controller);
final failed =
registered.hasError ||
ownedDirectory.hasError ||
// Remote listing failures matter only where remote agents are shown.
(supportsRemoteAgents(community) && controller.listingFailed);
final loading =
!creating.value &&
(controller.busy ||
ownedDirectory.isLoading ||
registered.connectionState != ConnectionState.done);
Future<void> refresh() async {
await controller.run(controller.refresh);
if (context.mounted) {
attempt.value++;
ref.invalidate(ownedAgentDirectoryProvider);
// A refresh also rereads archives: an agent restored elsewhere
// reappears, and one archived elsewhere leaves.
ref.invalidate(identityArchivesProvider);
}
}
Future<void> openCreate([AgentCreationDraft? initialDraft]) async {
if (community == null) return;
creating.value = true;
try {
final done = await Navigator.of(context).push<bool>(
MaterialPageRoute(
builder: (_) => CreateAgentPage(
controller: controller,
community: community,
initialDraft: initialDraft,
onCreated: () {
ref.invalidate(agentDirectoryProvider);
ref.invalidate(ownedAgentDirectoryProvider);
},
),
),
);
final agent = controller.created;
if (createOnOpen && done == true && agent != null && context.mounted) {
await onCreated?.call(agent);
}
} finally {
if (context.mounted) creating.value = false;
}
}
final VoidCallback? addAgent =
community == null || controller.busy || creating.value || signingIn
? null
: () async {
if (controller.account.account == null) {
await flow.reconnectAccount();
return;
}
await openCreate();
};
// Open a creation request once, after sign-in and inventory loading.
// The Bestie shortcut leaves existing agents and saved setups in the list.
final draftStep = useRef(
draft == null && !createOnOpen ? _DraftStep.done : _DraftStep.wait,
);
final draftRefreshFor = useRef<String?>(null);
// Looked up during build: effects cannot read inherited widgets, and
// snack bars cannot show while a frame is building.
final messenger = ScaffoldMessenger.maybeOf(context);
void drop(String message) {
draftStep.value = _DraftStep.done;
WidgetsBinding.instance.addPostFrameCallback((_) {
messenger?.showFeedbackSnackBar(
SnackBar(
content: Text(
draft == null
? message
.replaceAll('Reopen the link', 'Reopen Agents')
.replaceAll('reopen the link', 'reopen Agents')
: message,
),
),
kind: ToastKind.error,
);
});
}
useEffect(
() {
final link = draft;
if (draftStep.value == _DraftStep.done) return null;
if (link == null) {
if (community == null || ownedDirectory.isLoading) return null;
if (!supportsRemoteAgents(community) ||
ownedDirectory.hasError ||
(ownedDirectory.value?.isNotEmpty ?? false)) {
draftStep.value = _DraftStep.done;
return null;
}
}
// Sign-in counts as attempted only once it is seen running, so a
// rerun before reconnectAccount reaches its sign-in stage still waits.
if (signingIn && draftStep.value == _DraftStep.signInRequested) {
draftStep.value = _DraftStep.signingIn;
}
if (community == null || controller.busy || signingIn) return null;
if (!supportsRemoteAgents(community)) {
drop('Remote agents aren’t available in this community.');
return null;
}
if (controller.account.account == null) {
if (draftStep.value == _DraftStep.wait) {
draftStep.value = _DraftStep.signInRequested;
unawaited(
flow.reconnectAccount().catchError((Object error) {
debugPrint('agents: link sign-in failed: $error');
if (context.mounted) {
drop('Couldn’t start Builderlab sign-in. Reopen the link.');
}
}),
);
} else if (draftStep.value == _DraftStep.signingIn) {
drop(
'Sign in to Builderlab to create this agent. Reopen the link.',
);
}
return null;
}
// The setup check below needs this account's registry, which a
// sign-in started by the link doesn't reload by itself.
final subject = controller.account.account!.subject;
if (!controller.loadedForCurrentAccount) {
if (draftRefreshFor.value != subject) {
draftRefreshFor.value = subject;
unawaited(controller.run(controller.refresh));
} else {
drop('Couldn’t load your agents. Reopen the link to retry.');
}
return null;
}
if (controller.listingFailed) {
drop('Couldn’t load your agents. Reopen the link to retry.');
return null;
}
if (controller.pending != null) {
if (link == null) {
draftStep.value = _DraftStep.done;
} else {
drop(
'Finish your unfinished agent setup first, then reopen the link.',
);
}
return null;
}
if (link == null) {
if (registered.connectionState != ConnectionState.done) return null;
if (registered.hasError || keys.isNotEmpty) {
draftStep.value = _DraftStep.done;
return null;
}
draftStep.value = _DraftStep.done;
WidgetsBinding.instance.addPostFrameCallback((_) {
if (context.mounted) unawaited(openCreate());
});
return null;
}
draftStep.value = _DraftStep.done;
// The checks above hold for this account; the fetch can take minutes.
final client = ref.read(agentCardClientProvider)();
unawaited(() async {
// Hold Create (+) off while the card loads, like an open form does.
// Deferred past this build: hooks state can't change mid-build.
await Future<void>.value();
if (!context.mounted) return client.close();
creating.value = true;
final AgentCreationDraft resolved;
try {
resolved = await AgentCreationDraft.fetch(link, client);
} catch (error) {
if (error is! AgentCardException) {
debugPrint('agents: agent card failed: $error');
}
if (context.mounted) {
creating.value = false;
drop(
error is AgentCardException
? error.message
: 'Couldn’t open this agent card.',
);
}
return;
} finally {
client.close();
}
if (!context.mounted) return;
if (controller.account.account?.subject != subject) {
creating.value = false;
drop('Your Builderlab account changed. Reopen the link.');
} else if (controller.pending != null) {
creating.value = false;
drop(
'Finish your unfinished agent setup first, then reopen the link.',
);
} else {
await openCreate(resolved);
}
}());
return null;
},
[
controller.busy,
signingIn,
flow.stage,
community?.id,
controller.account.account?.subject,
controller.loadedForCurrentAccount,
ownedDirectory,
registered.connectionState,
candidateKey,
],
);
final headerStyle = context.textTheme.titleMedium?.copyWith(
fontSize: 22,
fontWeight: FontWeight.w600,
color: navigationPrimaryForeground(context),
);
final topHeight = frostedAppBarHeight(
context,
nativeLargeTitle: true,
titleStyle: headerStyle,
bottomHeight: Grid.xxs,
);
return FrostedScaffold(
backgroundColor: context.colors.surface,
appBar: FrostedAppBar(
nativeActions: [
if (supportsRemoteAgents(community))
IosNavigationAction(
label: 'Add agent',
symbol: 'plus',
onPressed: addAgent,
),
],
nativeTitle: 'Agents',
nativeLargeTitle: true,
automaticallyImplyLeading: false,
nativeLeading: IosNavigationAction(
label: 'Close agents',
symbol: 'xmark',
onPressed: onClose,
),
leading: IconButton(
tooltip: 'Close agents',
icon: const Icon(BuzzIcons.x),
onPressed: onClose,
),
horizontalInset: Grid.gutter,
title: Text('Agents', style: headerStyle),
titleStyle: headerStyle,
bottomHeight: Grid.xxs,
bottom: const SizedBox.expand(),
actions: [
if (supportsRemoteAgents(community))
IconButton(
tooltip: 'Add agent',
onPressed: addAgent,
icon: const Icon(Icons.add),
),
],
),
body: SafeArea(
top: false,
child: Material(
color: Colors.transparent,
child: BeeRefreshIndicator(
edgeOffset: topHeight,
onRefresh: refresh,
child: CustomScrollView(
physics: const AlwaysScrollableScrollPhysics(),
slivers: [
SliverToBoxAdapter(child: SizedBox(height: topHeight)),
if (signInNeeded)
SliverToBoxAdapter(
child: ReconnectCapsule(
inline: true,
message: signingIn
? 'Finish sign-in in browser'
: 'Reconnect to Builderlab',
actionLabel: signingIn ? 'Cancel' : 'Sign in',
destructiveAction: signingIn,
onSignIn: () => unawaited(
signingIn ? flow.leave() : flow.reconnectAccount(),
),
),
),
SliverToBoxAdapter(
child: Column(
children: [
if (createOnOpen && (loading || signingIn))
const LinearProgressIndicator(),
if (createOnOpen &&
supportsRemoteAgents(community) &&
controller.failure.isNotEmpty)
Padding(
padding: const EdgeInsets.all(Grid.sm),
child: Text(controller.failure),
),
if (failed && keys.isEmpty)
ListTile(
title: const Text('Could not load agents'),
trailing: TextButton(
onPressed: refresh,
child: const Text('Retry'),
),
),
if (!loading && !failed && !needsSignIn && keys.isEmpty)
const Padding(
padding: EdgeInsets.all(Grid.sm),
child: Text(
'You haven’t added any agents yet.',
textAlign: TextAlign.center,
),
),
],
),
),
AgentCardGrid(
itemCount: keys.length,
itemBuilder: (context, index) {
final key = keys[index];
final remote = controller.agents
.where((row) => row['agent_pubkey'] == key)
.firstOrNull;
final editable =
community != null &&
supportsRemoteAgents(community) &&
controller.owner == community.pubkey &&
owned?.contains(key) == true &&
controller.activeAgents.contains(key) &&
remote != null;
return AgentCard(
key: ValueKey(key),
name: names.labelFor(key),
isBestie: key == bestie,
imageUrl:
profiles[key]?.avatarUrl ??
ownedDirectory.asData?.value
.where((entry) => entry.pubkey == key)
.firstOrNull
?.avatarUrl,
location: remote != null ? 'Remote' : null,
onOpen: () => onSelect(key),
footer: editable
? RemoteAgentModelControl(
key: ValueKey('model-$key'),
controller: controller,
agent: remote,
community: community,
isCurrent: () =>
ref
.read(activeCommunityProvider)
.asData
?.value
?.id ==
community.id,
)
: null,
subtitle: editable
? null
: remote != null
? 'Builderbot agent'
: needsSignIn
? null
: 'Manage on desktop',
);
},
),
],
),
),
),
),
);
}
}
/// Progress of opening a link's draft: waiting on gates, sign-in requested,
/// sign-in seen running, or done.
enum _DraftStep { wait, signInRequested, signingIn, done }