mirror of
https://github.com/block/buzz.git
synced 2026-10-30 14:01:37 +00:00
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:
@@ -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 ||
|
||||
|
||||
@@ -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,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
@@ -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,
|
||||
),
|
||||
|
||||
@@ -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);
|
||||
},
|
||||
);
|
||||
}
|
||||
57
mobile/test/shared/widgets/buzz_setting_menu_test.dart
Normal file
57
mobile/test/shared/widgets/buzz_setting_menu_test.dart
Normal 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();
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user