Files
buzz/mobile/lib/features/agents/create_agent_page.dart
morgmart 0dd778f800 mobile: start new agents on Sonnet at medium effort (#9034)
## Summary

The mobile create-agent screen opened with model and thinking effort
blank, so the easiest path was picking from the full catalog, expensive
models included. A new agent now starts on the first Sonnet model the
catalog offers, at medium effort when that model supports it. People can
still change either one.

- An agent link that suggests a model still wins when Builderlab offers
that model.
- A setup resumed after a failure keeps the choice it saved.
- If the catalog has no Sonnet model, both start blank like today. If
the Sonnet model has no medium effort, only effort is left blank.

Builderlab doesn't send a default or pricing, so the app matches on
"sonnet" in the model id rather than pinning a version. That keeps
working when the catalog moves to a newer Sonnet.

## Screenshots

Captured from the widget test harness with a catalog listing Opus first,
then Sonnet. The avatar preview is blank because the test doesn't load
network images.

| Narrow, light | Narrow, dark | Wide, light |
| --- | --- | --- |
| ![Create agent, narrow
light](https://raw.githubusercontent.com/block/buzz/8cb0af35dfc38de81ee942b2c3475009549d6370/default-model--create-agent-narrow-light.png)
| ![Create agent, narrow
dark](https://raw.githubusercontent.com/block/buzz/8cb0af35dfc38de81ee942b2c3475009549d6370/default-model--create-agent-narrow-dark.png)
| ![Create agent, wide
light](https://raw.githubusercontent.com/block/buzz/8cb0af35dfc38de81ee942b2c3475009549d6370/default-model--create-agent-wide-light.png)
|

## Testing

- New unit tests cover the default, missing medium effort, no Sonnet,
draft model wins, and a draft model the catalog lacks.
- A new widget test checks the form opens on Sonnet at medium and only
needs a name before Create.
- Full mobile suite passes locally with the CI dart-defines
(`BUZZ_PUSH_GATEWAY_URL`, `BUZZ_AUTH_DOMAINS`) at 256063f95.

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

Signed-off-by: morgmart <98432065+morgmart@users.noreply.github.com>
Co-authored-by: Carl <c217fe6b9d958f41c3a5e030dccc7f626775a923089cb6491305eade75ea1f1b@buzz.block.builderlab.xyz>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-03 22:14:12 -08:00

479 lines
19 KiB
Dart

import 'agent_avatar_hero.dart';
import 'agent_character_artwork.dart';
import 'dart:math';
import 'agent_model_picker.dart';
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import '../../shared/agents/remote_agents.dart';
import '../../shared/agents/agent_avatar_catalog.dart';
import '../../shared/relay/relay.dart';
import 'agent_avatar_picker.dart';
import 'agent_creation_draft.dart';
import '../../shared/community/community.dart';
import '../../shared/community/community_provider.dart';
import '../../shared/theme/theme.dart';
import '../../shared/widgets/avatar_shape.dart';
import '../../shared/widgets/frosted_app_bar.dart';
import '../../shared/widgets/frosted_scaffold.dart';
import '../../shared/widgets/ios_glass_navigation_action.dart';
import '../../shared/widgets/preset_agent_avatar.dart';
import 'agent_avatar_editor_page.dart';
import '../../shared/widgets/sheet_action_section.dart';
/// Collects agent details, an avatar, and a service-supported model.
class CreateAgentPage extends HookConsumerWidget {
const CreateAgentPage({
required this.controller,
required this.community,
required this.onCreated,
this.initialDraft,
super.key,
});
/// Pre-fills a fresh setup (for example from a link). Ignored when an
/// unfinished setup is pending: that must be resumed as saved.
final AgentCreationDraft? initialDraft;
final RemoteAgentController controller;
final Community community;
final VoidCallback onCreated;
@override
Widget build(BuildContext context, WidgetRef ref) {
useListenable(controller);
ref.listen(remoteAgentsEnabledProvider, (_, enabled) {
final route = ModalRoute.of(context);
if (!enabled && route != null) {
Navigator.of(context).removeRoute(route);
}
});
final (catalog, retryCatalog) = useAgentModelCatalog(controller);
final openingSubject = useMemoized(
() => controller.account.account?.subject,
const [],
);
final draft = controller.pending == null ? initialDraft : null;
final model = useState(controller.pending?['model_id'] as String?);
final effort = useState(controller.pending?['reasoning_effort'] as String?);
// A fresh setup starts from the first catalog that loads (after a failed
// load, the one Retry fetches): the draft's model if offered, otherwise
// the default. A resumed setup keeps its saved choice.
final startApplied = useRef(controller.pending != null);
useEffect(() {
final entries = catalog.data;
if (startApplied.value ||
catalog.connectionState != ConnectionState.done ||
entries == null) {
return null;
}
startApplied.value = true;
final (startModel, startEffort) = startingAgentModel(
entries,
draftModel: draft?.modelId,
draftEffort: draft?.reasoningEffort,
);
model.value = startModel;
effort.value = startEffort;
return null;
}, [catalog.connectionState]);
final name = useTextEditingController(
text: controller.pending?['name'] as String? ?? draft?.name ?? '',
);
final instructions = useTextEditingController(
text:
controller.pending?['instructions'] as String? ??
draft?.instructions ??
'',
);
useListenable(name);
final error = useState<String?>(null);
final avatarHeroTag = useMemoized(Object.new);
final editingAvatar = useState(false);
final avatar = useState(
draft?.avatar ??
AgentAvatarSelection.url(
controller.pending?['picture'] as String? ??
agentAvatarCatalog[Random().nextInt(agentAvatarCatalog.length)]
.url,
),
);
final preparing = useState(false);
final busy = controller.busy || preparing.value;
final active = ref.watch(activeCommunityProvider).asData?.value;
final sameCommunity = active?.id == community.id;
final pending = controller.pending != null;
final instructionsMissing =
controller.pending?['instructionsMissing'] == true;
final canCreate =
!busy &&
sameCommunity &&
name.text.trim().isNotEmpty &&
validAgentModelSelection(catalog, model.value, effort.value);
InputDecoration fieldDecoration(String label, {String? hint}) =>
InputDecoration(
hintText: hint,
filled: false,
fillColor: context.colors.surfaceContainerHighest,
contentPadding: const EdgeInsets.symmetric(
horizontal: Grid.xs,
vertical: Grid.xs,
),
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(Radii.container),
borderSide: BorderSide.none,
),
enabledBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(Radii.container),
borderSide: BorderSide.none,
),
focusedBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(Radii.container),
borderSide: BorderSide.none,
),
);
Future<void> create() async {
error.value = null;
if (controller.account.account?.subject != openingSubject) {
error.value =
'The account changed. Reopen Agents and choose a model again.';
return;
}
if (!sameCommunity) {
error.value = 'The active community changed. Reopen Agents.';
return;
}
preparing.value = true;
try {
final subject = controller.account.account?.subject;
final service = ref.read(mediaUploadServiceProvider);
final picture = pending ? null : await avatar.value.prepare(service);
if (!context.mounted) return;
if (ref.read(activeCommunityProvider).asData?.value?.id !=
community.id ||
controller.account.account?.subject != subject) {
error.value = 'The account or community changed. Reopen Agents.';
return;
}
final success = await controller.create(
community,
name.text,
instructions: instructions.text,
pictureUrl: picture,
modelId: model.value,
reasoningEffort: effort.value,
);
if (!context.mounted) return;
if (success) {
onCreated();
Navigator.pop(context, true);
} else {
error.value = controller.failure;
retryCatalog();
}
} catch (_) {
if (context.mounted) {
error.value = 'Could not upload the avatar. Try again.';
}
} finally {
if (context.mounted) preparing.value = false;
}
}
return PopScope(
canPop: !busy,
child: FrostedScaffold(
appBar: FrostedAppBar(
// Keep the full-screen UIKit navigation layer out of the Hero's
// overlay until the editor's reverse transition has completed.
nativeViewSuppressed: editingAvatar,
nativeTitle: 'Create agent',
title: const Text('Create agent'),
automaticallyImplyLeading: false,
nativeLeading: IosNavigationAction(
label: 'Cancel',
symbol: 'xmark',
onPressed: busy ? null : () => Navigator.pop(context),
),
nativeActions: [
IosNavigationAction(
label: pending ? 'Resume' : 'Create',
symbol: 'checkmark',
onPressed: canCreate ? create : null,
),
],
leading: IconButton(
tooltip: 'Cancel',
onPressed: busy ? null : () => Navigator.pop(context),
icon: const Icon(Icons.close),
),
actions: [
IconButton(
tooltip: pending ? 'Resume' : 'Create',
onPressed: canCreate ? create : null,
icon: const Icon(Icons.check),
),
],
),
body: SafeArea(
top: false,
child: SingleChildScrollView(
padding: EdgeInsets.fromLTRB(
Grid.gutter,
frostedAppBarHeight(context) + Grid.md,
Grid.gutter,
Grid.md,
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Center(
child: Column(
children: [
Semantics(
label: 'Agent avatar',
image: true,
child: AgentAvatarHero(
tag: avatarHeroTag,
selection: avatar.value,
child: TickerMode(
key: const ValueKey('create-agent-avatar-playback'),
enabled: !editingAvatar.value,
child: SizedBox.square(
dimension: 160,
child: avatar.value.bytes != null
? AvatarClip(
isAgent: true,
child: Image.memory(
avatar.value.bytes!,
fit: BoxFit.cover,
),
)
: PresetAgentAvatar(
imageUrl: avatar.value.url!,
posterPlaceholder: characterPoster(
avatar.value.url!,
),
size: 160,
fallback: const Icon(
Icons.smart_toy_outlined,
size: 80,
),
),
),
),
),
),
if (!pending) ...[
const SizedBox(height: Grid.xxs),
_ChooseAvatarButton(
transitioning: editingAvatar.value,
onPressed: busy || editingAvatar.value
? null
: () async {
if (editingAvatar.value) return;
editingAvatar.value = true;
try {
FocusScope.of(context).unfocus();
if (avatar.value.url != null) {
await precacheImage(
NetworkImage(avatar.value.url!),
context,
onError: (_, _) {},
).timeout(
const Duration(milliseconds: 300),
onTimeout: () {},
);
if (!context.mounted) return;
}
// Remove the UIKit control before the Hero
// crosses its bounds. Keep it absent until the
// reverse flight's overlay has been removed.
await WidgetsBinding.instance.endOfFrame;
if (!context.mounted) return;
final route =
PageRouteBuilder<AgentAvatarSelection>(
transitionDuration:
MediaQuery.disableAnimationsOf(
context,
)
? Duration.zero
: const Duration(
milliseconds: 240,
),
reverseTransitionDuration:
MediaQuery.disableAnimationsOf(
context,
)
? Duration.zero
: const Duration(
milliseconds: 240,
),
pageBuilder: (_, _, _) =>
AgentAvatarEditorPage(
initialSelection: avatar.value,
heroTag: avatarHeroTag,
),
transitionsBuilder:
(_, animation, _, child) {
final progress =
CurvedAnimation(
parent: animation,
curve:
Curves.fastOutSlowIn,
reverseCurve: Curves
.fastOutSlowIn
.flipped,
);
return FadeTransition(
opacity: progress,
child: child,
);
},
);
final result = await Navigator.of(
context,
).push<AgentAvatarSelection>(route);
if (context.mounted && result != null) {
avatar.value = result;
}
await route.completed;
} finally {
if (context.mounted) {
editingAvatar.value = false;
}
}
},
),
],
],
),
),
const SizedBox(height: Grid.md),
SheetActionSection(
label: 'Name',
children: [
Semantics(
label: 'Name',
child: TextField(
controller: name,
autofocus: false,
enabled: !busy && !pending,
textCapitalization: TextCapitalization.words,
decoration: fieldDecoration('Name'),
),
),
],
),
const SizedBox(height: Grid.sm),
AgentModelPicker(
controller: controller,
catalog: catalog,
onRetry: retryCatalog,
modelId: model.value,
effort: effort.value,
onChanged: (id, value) {
model.value = id;
effort.value = value;
},
),
const SizedBox(height: Grid.sm),
SheetActionSection(
label: 'Instructions',
children: [
Semantics(
label: 'Instructions',
child: TextField(
controller: instructions,
enabled: !busy && (!pending || instructionsMissing),
minLines: 4,
maxLines: 6,
textCapitalization: TextCapitalization.sentences,
textInputAction: TextInputAction.newline,
decoration: fieldDecoration(
'Instructions',
hint:
'Describe what this agent should do (optional).',
),
),
),
],
),
const SizedBox(height: Grid.sm),
if (busy) ...[
const SizedBox(height: Grid.xs),
const LinearProgressIndicator(),
],
if (error.value != null) ...[
const SizedBox(height: Grid.xs),
Text(
error.value!,
style: TextStyle(color: context.colors.error),
),
],
if (controller.canDiscardPending && !busy)
TextButton(
onPressed: () async {
error.value = null;
if (await controller.discardPending()) {
name.clear();
instructions.clear();
} else if (context.mounted) {
error.value = controller.failure;
}
},
child: const Text('Discard unfinished setup'),
),
],
),
),
),
),
);
}
}
/// Platform capsule action for entering the dedicated avatar editor.
class _ChooseAvatarButton extends StatelessWidget {
const _ChooseAvatarButton({
required this.onPressed,
required this.transitioning,
});
final bool transitioning;
final VoidCallback? onPressed;
@override
Widget build(BuildContext context) {
if (defaultTargetPlatform == TargetPlatform.iOS) {
if (transitioning) {
return SizedBox(
width: 160,
height: 48,
child: Center(
child: TextButton(
onPressed: null,
child: const Text('Choose avatar'),
),
),
);
}
return IosGlassNavigationAction(
label: 'Choose avatar',
width: 160,
onPressed: onPressed,
);
}
return FilledButton.tonal(
style: FilledButton.styleFrom(shape: const StadiumBorder()),
onPressed: onPressed,
child: const Text('Choose avatar'),
);
}
}