mirror of
https://github.com/block/buzz.git
synced 2026-11-09 02:41:57 +00:00
Follow-up to [#8626](https://github.com/block/buzz/pull/8626). Mobile remote-agent creation now matches desktop. - **Model and reasoning effort:** Create agent has Model and Reasoning effort menus. Their options come from the remote service's catalog, and nothing is preselected (same as desktop). The server checks the selection against the catalog again before registering, and saves it on the agent. - **Builderlab gate:** creating a remote agent requires a hosted community and Builderlab sign-in. The Agents directory, community bots and the Bestie chooser stay available without it. Signing out closes an open Create agent sheet; the Agents sheet stays open. - **Enrollment:** `attest-agent` enrolls the agent in the requested community, as on desktop, so the separate `add-buzz-subscription` call is gone. - **Quieter progress:** spinners replace the progress bars and the "Working…" label. Status and success text ("is ready", "Remote agent deleted.", setup steps) is gone, and only failures show text. Refreshing an already-loaded agent list no longer blanks it. - **Shared setting menu:** `BuzzSettingMenu` is a bordered setting row that opens a Material 3 menu spanning the row's width. Opening it dismisses the keyboard. The channel-TTL setting in Create channel uses it too. Agent avatars and QR/link-based creation follow in stacked PRs. Editing existing agents comes later. | Agents | Create | Model menu | | --- | --- | --- | | <img src="https://raw.githubusercontent.com/block/buzz/d0bee87c45d30b9bdd2163a75966ccd5d64f322a/pr-8650--1-agents.png" width="240"> | <img src="https://raw.githubusercontent.com/block/buzz/d0bee87c45d30b9bdd2163a75966ccd5d64f322a/pr-8650--2-create.png" width="240"> | <img src="https://raw.githubusercontent.com/block/buzz/d0bee87c45d30b9bdd2163a75966ccd5d64f322a/pr-8650--3-model-menu.png" width="240"> | ### Validation - Checked on the staging iOS simulator with a Builderlab account: the Agents list, the Create sheet, and the model and effort menus (including with the keyboard open). - Added tests: catalog-gated model selection (including switching models clears an unsupported effort), the Builderlab sign-in gate, controller creation and resume with a model, a failed listing reported instead of an empty registry, and create sheet busy and failure states (including the catalog reload after a rejected create). - The hosted-only agent tests are skipped without Builderlab defines, which `mobile-test` doesn't pass. They pass locally with the staging defines (20/20). - Not covered: Android. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Signed-off-by: Tom Brow <tomb@block.xyz> Co-authored-by: george <973be878edd6d4f79692a3827f20a6f7fe0ed0362f269ff6b6ba8867033d7d8e@sprout-oss.stage.blox.sqprod.co>
239 lines
8.6 KiB
Dart
239 lines
8.6 KiB
Dart
import 'package:flutter/material.dart';
|
|
import 'agent_model_picker.dart';
|
|
import 'package:flutter_hooks/flutter_hooks.dart';
|
|
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
|
|
|
import '../../shared/agents/remote_agents.dart';
|
|
import '../../shared/community/community.dart';
|
|
import '../../shared/widgets/buzz_loading_indicator.dart';
|
|
import '../../shared/community/community_provider.dart';
|
|
import '../../shared/theme/theme.dart';
|
|
import '../../shared/widgets/buzz_titled_sheet_layout.dart';
|
|
import '../../shared/widgets/sheet_action_section.dart';
|
|
|
|
/// Collects an agent's fields and a model from the remote service catalog.
|
|
class CreateAgentSheet extends HookConsumerWidget {
|
|
const CreateAgentSheet({
|
|
required this.controller,
|
|
required this.community,
|
|
required this.onCreated,
|
|
super.key,
|
|
});
|
|
final RemoteAgentController controller;
|
|
final Community community;
|
|
final VoidCallback onCreated;
|
|
|
|
@override
|
|
Widget build(BuildContext context, WidgetRef ref) {
|
|
useListenable(controller);
|
|
// Signing out of Builderlab closes remote-agent flows; the Agents sheet
|
|
// under this one stays open for community agents.
|
|
ref.listen(remoteAgentsEnabledProvider, (_, enabled) {
|
|
if (!enabled && (ModalRoute.of(context)?.isCurrent ?? false)) {
|
|
Navigator.of(context).pop();
|
|
}
|
|
});
|
|
final (catalog, retryCatalog) = useAgentModelCatalog(controller);
|
|
final subject = useMemoized(
|
|
() => controller.account.account?.subject,
|
|
const [],
|
|
);
|
|
final name = useTextEditingController(
|
|
text: controller.pending?['name'] as String? ?? '',
|
|
);
|
|
final instructions = useTextEditingController(
|
|
text: controller.pending?['instructions'] as String? ?? '',
|
|
);
|
|
useListenable(name);
|
|
final error = useState<String?>(null);
|
|
final model = useState(controller.pending?['model_id'] as String?);
|
|
final effort = useState(controller.pending?['reasoning_effort'] as String?);
|
|
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 =
|
|
!controller.busy &&
|
|
sameCommunity &&
|
|
name.text.trim().isNotEmpty &&
|
|
validAgentModelSelection(catalog, model.value, effort.value);
|
|
final actionStyle = IconButton.styleFrom(
|
|
minimumSize: const Size.square(44),
|
|
backgroundColor: context.colors.surfaceContainerHighest,
|
|
side: BorderSide(color: context.colors.outlineVariant),
|
|
shape: const CircleBorder(),
|
|
);
|
|
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 != subject) {
|
|
error.value =
|
|
'The account changed. Reopen Agents and choose a model again.';
|
|
return;
|
|
}
|
|
if (!sameCommunity) {
|
|
error.value = 'The active community changed. Reopen Agents.';
|
|
return;
|
|
}
|
|
final success = await controller.create(
|
|
community,
|
|
name.text,
|
|
instructions: instructions.text,
|
|
modelId: model.value,
|
|
reasoningEffort: effort.value,
|
|
);
|
|
if (!context.mounted) return;
|
|
if (success) {
|
|
onCreated();
|
|
Navigator.pop(context, true);
|
|
} else {
|
|
error.value = controller.failure;
|
|
// The server may have rejected a choice the catalog no longer offers.
|
|
retryCatalog();
|
|
}
|
|
}
|
|
|
|
return PopScope(
|
|
canPop: !controller.busy,
|
|
child: Padding(
|
|
padding: EdgeInsets.only(
|
|
bottom: MediaQuery.viewInsetsOf(context).bottom,
|
|
),
|
|
child: BuzzTitledSheetLayout(
|
|
title: 'Create agent',
|
|
leading: IconButton(
|
|
tooltip: 'Cancel',
|
|
style: actionStyle,
|
|
onPressed: controller.busy ? null : () => Navigator.pop(context),
|
|
icon: const Icon(Icons.close),
|
|
),
|
|
trailing: IconButton(
|
|
tooltip: pending ? 'Resume' : 'Create',
|
|
style: actionStyle.copyWith(
|
|
backgroundColor: WidgetStatePropertyAll(
|
|
canCreate
|
|
? context.colors.primary
|
|
: context.colors.surfaceContainerHighest,
|
|
),
|
|
foregroundColor: WidgetStatePropertyAll(
|
|
canCreate
|
|
? context.colors.onPrimary
|
|
: context.colors.onSurfaceVariant,
|
|
),
|
|
),
|
|
onPressed: canCreate ? create : null,
|
|
icon: controller.busy
|
|
? BuzzLoadingIndicator(
|
|
size: 18,
|
|
color: context.colors.onSurfaceVariant,
|
|
)
|
|
: const Icon(Icons.check),
|
|
),
|
|
child: SingleChildScrollView(
|
|
padding: const EdgeInsets.all(Grid.gutter),
|
|
child: Column(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
SheetActionSection(
|
|
label: 'Name',
|
|
children: [
|
|
Semantics(
|
|
label: 'Name',
|
|
child: TextField(
|
|
controller: name,
|
|
autofocus: true,
|
|
enabled: !controller.busy && !pending,
|
|
textCapitalization: TextCapitalization.words,
|
|
decoration: fieldDecoration('Name'),
|
|
),
|
|
),
|
|
],
|
|
),
|
|
const SizedBox(height: Grid.sm),
|
|
SheetActionSection(
|
|
label: 'Instructions',
|
|
children: [
|
|
Semantics(
|
|
label: 'Instructions',
|
|
child: TextField(
|
|
controller: instructions,
|
|
enabled:
|
|
!controller.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),
|
|
AgentModelPicker(
|
|
controller: controller,
|
|
catalog: catalog,
|
|
onRetry: retryCatalog,
|
|
modelId: model.value,
|
|
effort: effort.value,
|
|
onChanged: (id, value) {
|
|
model.value = id;
|
|
effort.value = value;
|
|
},
|
|
),
|
|
if (error.value != null) ...[
|
|
const SizedBox(height: Grid.xs),
|
|
Text(
|
|
error.value!,
|
|
style: TextStyle(color: context.colors.error),
|
|
),
|
|
],
|
|
if (controller.canDiscardPending && !controller.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'),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
),
|
|
),
|
|
);
|
|
}
|
|
}
|