fix(mobile): address #8650 second-pass review (#8691)

Follow-up to #8650. This addresses the items from the [second-pass
review](https://github.com/block/buzz/pull/8650#pullrequestreview-5700660448),
which arrived after #8650 merged.

- **Setting menu semantics.** Options in the shared `BuzzSettingMenu`
(model, reasoning effort, and channel TTL) expose their checked state
again. The trigger is announced as a button with enabled and expanded
state, so a disabled "Reasoning effort" no longer reads as plain text.
The `PopupMenuButton` this menu replaced also provided a named route and
a "Dismiss menu" action. `MenuAnchor` does not provide them, so this PR
does not restore them.
- **Remote listing failures.** "Could not load agents" for a failed
Builderlab listing now appears only in communities that support remote
agents. For example, it no longer appears in a non-hosted community
while signed in.
- **Add agent during a community reload.** No change needed here. #8693
already disables Add while the page has no community.

### Validation

- Added tests: setting-menu checked, button, enabled and expanded
semantics; a failed remote listing stays hidden where remote agents are
off. Both fail without these changes.
- Mobile analysis is clean, and the Agents and shared widget tests pass
locally after rebasing onto #8693.
- Not covered: VoiceOver on a device.

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

---------

Signed-off-by: Tom Brow <tomb@block.xyz>
Co-authored-by: studio <dc2fc481343672df31a689840c09299972e704dac592e0da6f18486646a7ee3e@sprout-oss.stage.blox.sqprod.co>
This commit is contained in:
Tom Brow
2026-10-29 15:36:21 -07:00
committed by GitHub
parent a94ebece0e
commit 64312fa5be
5 changed files with 170 additions and 74 deletions

View File

@@ -190,7 +190,8 @@ class AgentsPage extends HookConsumerWidget {
directory.hasError ||
registered.hasError ||
ownedDirectory.hasError ||
controller.listingFailed;
// Remote listing failures matter only where remote agents are shown.
(supportsRemoteAgents(community) && controller.listingFailed);
final loading =
!creating.value &&
(controller.busy ||

View File

@@ -62,87 +62,106 @@ class BuzzSettingMenu<T> extends StatelessWidget {
),
menuChildren: [
for (final option in options)
MenuItemButton(
key: option.key,
onPressed: () => onSelected(option.value),
style: MenuItemButton.styleFrom(
padding: const EdgeInsets.symmetric(horizontal: Grid.twelve),
),
// A trailing check keeps labels flush with the menu's edge.
trailingIcon: option.value == value
? Icon(Icons.check, size: 20, color: context.colors.primary)
: const SizedBox(width: 20),
// Root menu panels ignore size styles and shrink-wrap their items,
// so the label sets the width: the row's, less the item's padding,
// the check and the gap before it.
child: SizedBox(
width: constraints.maxWidth - Grid.twelve * 3 - 20,
child: Text(option.label),
// The check icon carries no semantics, so mark the selection on
// the item's own node.
MergeSemantics(
child: Semantics(
checked: option.value == value,
inMutuallyExclusiveGroup: true,
child: MenuItemButton(
key: option.key,
onPressed: () => onSelected(option.value),
style: MenuItemButton.styleFrom(
padding: const EdgeInsets.symmetric(
horizontal: Grid.twelve,
),
),
// A trailing check keeps labels flush with the menu's edge.
trailingIcon: option.value == value
? Icon(
Icons.check,
size: 20,
color: context.colors.primary,
)
: const SizedBox(width: 20),
// Root menu panels ignore size styles and shrink-wrap their items,
// so the label sets the width: the row's, less the item's padding,
// the check and the gap before it.
child: SizedBox(
width: constraints.maxWidth - Grid.twelve * 3 - 20,
child: Text(option.label),
),
),
),
),
],
builder: (context, menu, _) => Tooltip(
message: '$label: $valueLabel',
// Draw on an ink surface so the tap ripple shows inside the border.
child: Material(
color: context.colors.surface,
shape: shape,
clipBehavior: Clip.antiAlias,
child: InkWell(
onTap: enabled
? () {
if (menu.isOpen) return menu.close();
// Drop the keyboard so it can't cover the menu.
FocusManager.instance.primaryFocus?.unfocus();
menu.open();
}
: null,
child: Padding(
key: controlKey,
padding: const EdgeInsets.symmetric(
horizontal: Grid.twelve,
vertical: Grid.xs,
),
child: Opacity(
opacity: enabled ? 1 : 0.5,
child: Row(
children: [
Expanded(
child: Text(
label,
style: context.textTheme.labelLarge?.copyWith(
color: context.colors.onSurface,
fontWeight: FontWeight.w600,
builder: (context, menu, _) => Semantics(
button: true,
enabled: enabled,
expanded: menu.isOpen,
child: Tooltip(
message: '$label: $valueLabel',
// Draw on an ink surface so the tap ripple shows inside the border.
child: Material(
color: context.colors.surface,
shape: shape,
clipBehavior: Clip.antiAlias,
child: InkWell(
onTap: enabled
? () {
if (menu.isOpen) return menu.close();
// Drop the keyboard so it can't cover the menu.
FocusManager.instance.primaryFocus?.unfocus();
menu.open();
}
: null,
child: Padding(
key: controlKey,
padding: const EdgeInsets.symmetric(
horizontal: Grid.twelve,
vertical: Grid.xs,
),
child: Opacity(
opacity: enabled ? 1 : 0.5,
child: Row(
children: [
Expanded(
child: Text(
label,
style: context.textTheme.labelLarge?.copyWith(
color: context.colors.onSurface,
fontWeight: FontWeight.w600,
),
),
),
),
if (valueIcon case final icon?) ...[
if (valueIcon case final icon?) ...[
Icon(
icon,
size: 16,
color: context.colors.onSurfaceVariant,
),
const SizedBox(width: Grid.half),
],
// Long values truncate instead of squeezing the label.
ConstrainedBox(
constraints: const BoxConstraints(maxWidth: 180),
child: Text(
valueLabel,
overflow: TextOverflow.ellipsis,
style: context.textTheme.bodyMedium?.copyWith(
color: context.colors.onSurface,
fontWeight: FontWeight.w500,
),
),
),
const SizedBox(width: Grid.half),
Icon(
icon,
BuzzIcons.chevronDown,
size: 16,
color: context.colors.onSurfaceVariant,
),
const SizedBox(width: Grid.half),
],
// Long values truncate instead of squeezing the label.
ConstrainedBox(
constraints: const BoxConstraints(maxWidth: 180),
child: Text(
valueLabel,
overflow: TextOverflow.ellipsis,
style: context.textTheme.bodyMedium?.copyWith(
color: context.colors.onSurface,
fontWeight: FontWeight.w500,
),
),
),
const SizedBox(width: Grid.half),
Icon(
BuzzIcons.chevronDown,
size: 16,
color: context.colors.onSurfaceVariant,
),
],
),
),
),
),

View File

@@ -119,7 +119,8 @@ class AgentsHarness {
/// Fails agent profile fetches until the relay recovers.
bool relayDown = false;
static Future<AgentsHarness> create() async {
/// [relayUrl] defaults to a hosted community that supports remote agents.
static Future<AgentsHarness> create({String? relayUrl}) async {
FlutterSecureStorage.setMockInitialValues({});
SharedPreferences.setMockInitialValues({});
final registry = FakeRegistry(bound: fakeOwner.public);
@@ -137,7 +138,7 @@ class AgentsHarness {
controller,
Community.create(
name: 'Test',
relayUrl: 'wss://test.${HostedEnvironment.communitySuffix}',
relayUrl: relayUrl ?? 'wss://test.${HostedEnvironment.communitySuffix}',
pubkey: fakeOwner.public,
nsec: fakeOwner.nsec,
),

View File

@@ -0,0 +1,18 @@
import 'package:flutter_test/flutter_test.dart';
import 'agents_harness.dart';
void main() {
testWidgets(
'a remote listing failure is hidden where remote agents are off',
(tester) async {
final h = await AgentsHarness.create(relayUrl: 'wss://relay.example');
h.registry.failListing = true;
await h.open(tester, directory: []);
expect(h.controller.listingFailed, isTrue);
expect(find.text('Could not load agents'), findsNothing);
expect(find.text('You haven’t added any agents yet.'), findsOneWidget);
},
);
}

View File

@@ -0,0 +1,57 @@
import 'dart:ui' show CheckedState, Tristate;
import 'package:flutter/material.dart';
import 'package:flutter/rendering.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:buzz/shared/theme/theme.dart';
import 'package:buzz/shared/widgets/buzz_setting_menu.dart';
void main() {
Widget menu({required bool enabled}) => MaterialApp(
theme: AppTheme.light(),
home: Scaffold(
body: BuzzSettingMenu<String>(
controlKey: const Key('menu'),
enabled: enabled,
label: 'Effort',
onSelected: (_) {},
options: const [
BuzzSettingMenuOption(key: Key('low'), label: 'Low', value: 'low'),
BuzzSettingMenuOption(key: Key('high'), label: 'High', value: 'high'),
],
value: 'high',
valueLabel: 'High',
),
),
);
testWidgets('exposes button, expanded and checked semantics', (tester) async {
final handle = tester.ensureSemantics();
await tester.pumpWidget(menu(enabled: true));
SemanticsData trigger() =>
tester.getSemantics(find.byKey(const Key('menu'))).getSemanticsData();
expect(trigger().flagsCollection.isButton, isTrue);
expect(trigger().flagsCollection.isEnabled, Tristate.isTrue);
expect(trigger().flagsCollection.isExpanded, Tristate.isFalse);
await tester.tap(find.byKey(const Key('menu')));
await tester.pumpAndSettle();
expect(trigger().flagsCollection.isExpanded, Tristate.isTrue);
SemanticsData option(String key) =>
tester.getSemantics(find.byKey(Key(key))).getSemanticsData();
expect(option('high').flagsCollection.isChecked, CheckedState.isTrue);
expect(option('low').flagsCollection.isChecked, CheckedState.isFalse);
handle.dispose();
});
testWidgets('a disabled menu reads as a disabled button', (tester) async {
final handle = tester.ensureSemantics();
await tester.pumpWidget(menu(enabled: false));
final data = tester
.getSemantics(find.byKey(const Key('menu')))
.getSemanticsData();
expect(data.flagsCollection.isButton, isTrue);
expect(data.flagsCollection.isEnabled, Tristate.isFalse);
handle.dispose();
});
}