Files
buzz/mobile/lib/features/agents/agent_model_picker.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

144 lines
4.9 KiB
Dart

import 'package:flutter/material.dart';
import 'package:flutter_hooks/flutter_hooks.dart';
import '../../shared/agents/remote_agents.dart';
import '../../shared/theme/theme.dart';
import '../../shared/widgets/sheet_action_section.dart';
import '../../shared/widgets/buzz_setting_menu.dart';
/// Uses the server catalog for both menus, including model-specific efforts.
class AgentModelPicker extends StatelessWidget {
const AgentModelPicker({
required this.controller,
required this.catalog,
required this.onRetry,
required this.onChanged,
this.modelId,
this.effort,
super.key,
});
final RemoteAgentController controller;
final AsyncSnapshot<List<RemoteAgentModel>> catalog;
final VoidCallback onRetry;
final String? modelId;
final String? effort;
final void Function(String?, String?) onChanged;
@override
Widget build(BuildContext context) {
// While loading, show the same rows disabled so nothing shifts.
final loading = catalog.connectionState != ConnectionState.done;
if (!loading && (catalog.hasError || catalog.data!.isEmpty)) {
return Column(
children: [
Text(
catalog.hasError
? 'Could not load available models.'
: 'No models are available.',
),
TextButton(onPressed: onRetry, child: const Text('Retry models')),
],
);
}
final models = loading ? const <RemoteAgentModel>[] : catalog.data!;
final selected = models.where((model) => model.id == modelId).firstOrNull;
final validEffort = selected?.efforts.contains(effort) == true
? effort
: null;
final enabled = !loading && !controller.busy;
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
SheetActionSection(
dividerIndent: Grid.xs,
children: [
BuzzSettingMenu<String>(
controlKey: const Key('agent-model'),
enabled: enabled,
label: 'Model',
grouped: true,
trailingIcon: Icons.unfold_more,
onSelected: (value) => onChanged(value, null),
options: [
for (final model in models)
BuzzSettingMenuOption(label: model.name, value: model.id),
],
value: selected?.id,
valueLabel: loading ? 'Loading…' : selected?.name ?? 'Choose',
),
BuzzSettingMenu<String>(
controlKey: const Key('agent-effort'),
enabled: enabled && selected != null,
label: 'Thinking',
grouped: true,
trailingIcon: Icons.unfold_more,
onSelected: (value) => onChanged(selected!.id, value),
options: [
for (final value in selected?.efforts ?? <String>[])
BuzzSettingMenuOption(label: value, value: value),
],
value: validEffort,
valueLabel: loading ? 'Loading…' : validEffort ?? 'Choose',
),
],
),
if (modelId != null && selected == null ||
effort != null && validEffort == null)
const Text(
'Choose an available model and supported reasoning effort.',
),
],
);
}
}
/// Shares catalog loading with the submit action so stale selections cannot save.
(AsyncSnapshot<List<RemoteAgentModel>>, VoidCallback) useAgentModelCatalog(
RemoteAgentController controller,
) {
final retry = useState(0);
final future = useMemoized(controller.modelCatalog, [
controller,
retry.value,
]);
return (useFuture(future, preserveState: false), () => retry.value++);
}
/// True only after the loaded catalog confirms this exact pair.
bool validAgentModelSelection(
AsyncSnapshot<List<RemoteAgentModel>> catalog,
String? model,
String? effort,
) =>
catalog.connectionState == ConnectionState.done &&
!catalog.hasError &&
catalog.data?.any(
(entry) => entry.id == model && entry.efforts.contains(effort),
) ==
true;
/// The model and effort a new agent starts with, so the easy path is a
/// capable but affordable choice rather than a blank (or the priciest) one.
/// A draft's own model wins when the catalog offers it; otherwise the first
/// Sonnet model, at medium effort when supported. Anything the catalog
/// doesn't offer is left for the user to choose.
(String?, String?) startingAgentModel(
List<RemoteAgentModel> catalog, {
String? draftModel,
String? draftEffort,
}) {
final drafted = catalog.where((entry) => entry.id == draftModel).firstOrNull;
if (drafted != null) {
return (
drafted.id,
drafted.efforts.contains(draftEffort) ? draftEffort : null,
);
}
final fallback = catalog
.where((entry) => entry.id.toLowerCase().contains('sonnet'))
.firstOrNull;
if (fallback == null) return (null, null);
return (fallback.id, fallback.efforts.contains('medium') ? 'medium' : null);
}