mirror of
https://github.com/block/buzz.git
synced 2026-11-07 18:01:06 +00:00
## 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 |
| --- | --- | --- |
| 
| 
| 
|
## 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>
144 lines
4.9 KiB
Dart
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);
|
|
}
|