mirror of
https://github.com/block/buzz.git
synced 2026-11-12 12:22:06 +00:00
## Summary
New users had no cue that the slot at the bottom of Home is where they
make an agent. A one-time, deliberately loud "Create your first agent"
card now sits above the empty Bestie slot and points at it.
- The card spans the screen above the bottom bar, with agent pictures
(the same ones the empty slot cycles through) scattered around its edges
and the message centered as plain text. It's allowed to cover part of
the chat list.
- It waits three seconds on Home, then pops up from the agent slot with
the popover entrance (a quick fade, rise and scale). Leaving Home
restarts the wait, so it never pops over another screen. With reduced
motion it appears without animation after the same wait.
- Tapping the card starts agent creation. The close button dismisses it
for good, as does a screen reader's dismiss action, activating the slot,
or ending up with a Bestie. Dismissal is remembered per account and
community. Other taps on Home go through to the page and leave the card
up.
- It shows when remote agents are on, the Bestie preference has loaded
with none set, and the account owns no agents yet. The last check keeps
existing users who never picked a Bestie from being told to create their
"first" agent.
- It only shows while Home is the current route, so sheets and pushed
screens hide it and it comes back afterwards if it was never dismissed.
The card is a new `BuzzCallout.card` variant of the reusable
`BuzzCallout` in the mobile design system (the plain tip variant stays
available). It uses the inverse surface in light mode and the raised
dark surface in dark mode (matching toasts), with an arrow, existing
radius, spacing and popover elevation tokens, and stays phone-sized on
wider screens. It grows to fit large text below its close button, and it
has live-region semantics, so screen readers announce it.
## Screenshots
These come from the widget test harness with the real agent pictures, at
small (320), standard (393) and landscape phone sizes, plus a tablet.
The harness can't render the native iOS glass bar, so these use the
Flutter bar; the card is the same on both platforms. Pictures look a
little soft because the harness renders at 1x and scales up. In
landscape and on tablets the card stops at large-phone width so the
pictures don't spread too thin.
| Standard, light | Standard, dark | Small, light |
| --- | --- | --- |
| 
| 
| 
|
| Landscape, light | Landscape, dark |
| --- | --- |
| 
| 
|
| Tablet, dark |
| --- |
| 
|
## Testing
- Launcher tests cover first show and placement, Home staying tappable
underneath without dismissing, the card tap starting creation, the close
button, slot activation without a touch, screen reader dismiss, a Bestie
appearing, no reshow after dismissal, a separate community getting its
own first run, staying hidden for people with agents or without remote
agents, hiding under a sheet, the three-second wait and entrance,
reduced motion, and leaving Home during the wait (each
mutation-checked).
- Component tests cover the tip beside an edge target (320 and 1024
wide, 3x text) and the card: full width at 320, capped on wide screens,
growing at 3x text so the message stays clear of the close button
(mutation-checked), and the close button staying its own screen reader
control.
- The full mobile suite passes locally with CI's dart-defines, and
pre-push mobile checks passed at e2fdb791c.
- Still needs a device check on a real iPhone with a fresh account.
🤖 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>
Co-authored-by: Morgarita <6e967af659416160ab4f2fd56d74c8c6f273791ee7bfc9468a1be37c387947be@buzz.block.builderlab.xyz>
305 lines
12 KiB
Dart
305 lines
12 KiB
Dart
import 'package:flutter/services.dart';
|
||
import 'package:buzz/shared/widgets/app_list_card.dart';
|
||
import 'package:buzz/shared/agents/remote_agents.dart';
|
||
import 'package:buzz/shared/me/me_sync.dart';
|
||
import 'package:buzz/shared/me/default_agent.dart';
|
||
import 'package:buzz/shared/theme/theme_provider.dart';
|
||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||
import 'package:buzz/features/channels/default_agent_launcher.dart';
|
||
import 'package:buzz/features/agents/agents_page.dart';
|
||
import 'package:buzz/features/agents/agent_card.dart';
|
||
import 'package:buzz/features/profile/user_profile_sheet.dart';
|
||
import 'package:flutter/material.dart';
|
||
import 'package:buzz/features/home/home_page.dart';
|
||
import 'package:flutter_test/flutter_test.dart';
|
||
import 'package:shared_preferences/shared_preferences.dart';
|
||
|
||
import 'agents_harness.dart';
|
||
import '../../helpers/remote_agents_fixture.dart';
|
||
|
||
void main() {
|
||
final existing = existingDesktopKeys.public;
|
||
|
||
testWidgets('agent profile sheet closes back to the full-page directory', (
|
||
tester,
|
||
) async {
|
||
tester.binding.defaultBinaryMessenger.setMockMethodCallHandler(
|
||
const MethodChannel('dev.fluttercommunity.plus/connectivity_status'),
|
||
(_) async => null,
|
||
);
|
||
addTearDown(
|
||
() => tester.binding.defaultBinaryMessenger.setMockMethodCallHandler(
|
||
const MethodChannel('dev.fluttercommunity.plus/connectivity_status'),
|
||
null,
|
||
),
|
||
);
|
||
tester.view.physicalSize = const Size(390, 844);
|
||
tester.view.devicePixelRatio = 1;
|
||
addTearDown(tester.view.resetPhysicalSize);
|
||
addTearDown(tester.view.resetDevicePixelRatio);
|
||
addTearDown(tester.platformDispatcher.clearTextScaleFactorTestValue);
|
||
final h = await AgentsHarness.create();
|
||
final prefs = await SharedPreferences.getInstance();
|
||
await h.open(
|
||
tester,
|
||
directory: [existing, otherAgentKeys.public],
|
||
overrides: [savedPrefsProvider.overrideWithValue(prefs)],
|
||
);
|
||
expect(find.byKey(ValueKey(otherAgentKeys.public)), findsNothing);
|
||
expect(find.byKey(ValueKey(oldAppAgentKeys.public)), findsNothing);
|
||
if (!supportsRemoteAgents(h.community)) {
|
||
expect(find.text('Sign in to create an agent'), findsNothing);
|
||
for (final button in tester.widgetList<IconButton>(
|
||
find.byWidgetPredicate(
|
||
(widget) => widget is IconButton && widget.tooltip == 'Add agent',
|
||
),
|
||
)) {
|
||
expect(button.onPressed, isNull);
|
||
}
|
||
}
|
||
await h.controller.run(() async {
|
||
throw StateError('Resume unfinished setup');
|
||
});
|
||
await tester.pumpAndSettle();
|
||
expect(find.textContaining('Resume unfinished setup'), findsNothing);
|
||
expect(find.byType(LinearProgressIndicator), findsNothing);
|
||
final list = tester.element(find.byType(AgentsPage));
|
||
final container = ProviderScope.containerOf(list);
|
||
expect(find.text('Set as default'), findsNothing);
|
||
expect(find.text('Model not shared'), findsNothing);
|
||
expect(find.byIcon(Icons.star), findsNothing);
|
||
expect(find.text('Manage on desktop'), findsOneWidget);
|
||
expect(find.byIcon(Icons.keyboard_arrow_down), findsNothing);
|
||
|
||
await tester.tap(find.byKey(ValueKey(existing)));
|
||
await tester.pump();
|
||
await tester.pumpAndSettle();
|
||
expect(find.byType(BottomSheet), findsOneWidget);
|
||
expect(find.byType(UserProfileSheet), findsOneWidget);
|
||
expect(find.text('Model not shared'), findsNothing);
|
||
expect(list.mounted, isTrue);
|
||
expect(find.text('Identity'), findsNothing);
|
||
expect(find.text('Settings'), findsNothing);
|
||
expect(
|
||
find.descendant(
|
||
of: find.byType(UserProfileSheet),
|
||
matching: find.text('Manage on desktop'),
|
||
),
|
||
findsNothing,
|
||
);
|
||
expect(
|
||
find.descendant(
|
||
of: find.byType(UserProfileSheet),
|
||
matching: find.text('Unknown'),
|
||
),
|
||
findsNothing,
|
||
);
|
||
expect(find.text('Instructions'), findsOneWidget);
|
||
expect(find.text('Management'), findsOneWidget);
|
||
expect(
|
||
find.textContaining('Keep Buzz open and your desktop online'),
|
||
findsOneWidget,
|
||
);
|
||
expect(
|
||
find.textContaining('Instructions aren’t available'),
|
||
findsOneWidget,
|
||
);
|
||
await tester.pumpAndSettle();
|
||
expect(find.byTooltip('Copy public key'), findsOneWidget);
|
||
final semantics = tester.ensureSemantics();
|
||
final platformCalls = <MethodCall>[];
|
||
tester.binding.defaultBinaryMessenger.setMockMethodCallHandler(
|
||
SystemChannels.platform,
|
||
(call) async {
|
||
platformCalls.add(call);
|
||
return null;
|
||
},
|
||
);
|
||
addTearDown(
|
||
() => tester.binding.defaultBinaryMessenger.setMockMethodCallHandler(
|
||
SystemChannels.platform,
|
||
null,
|
||
),
|
||
);
|
||
final keyRow = find.byTooltip('Copy public key');
|
||
await tester.ensureVisible(keyRow);
|
||
await tester.tapAt(tester.getTopLeft(keyRow) + const Offset(8, 8));
|
||
await tester.pumpAndSettle();
|
||
expect(
|
||
platformCalls.where((call) => call.method == 'Clipboard.setData'),
|
||
hasLength(1),
|
||
);
|
||
expect(
|
||
platformCalls.where((call) => call.method == 'HapticFeedback.vibrate'),
|
||
hasLength(1),
|
||
);
|
||
expect(find.bySemanticsLabel('Copied public key'), findsOneWidget);
|
||
semantics.dispose();
|
||
|
||
await tester.pumpAndSettle();
|
||
expect(find.byTooltip('Delete agent'), findsNothing);
|
||
// Reopen through the real reader, recover a failed network query, then
|
||
// verify the registered instruction text appears rather than profile bio.
|
||
await tester.tap(find.byTooltip('Close sheet'));
|
||
await tester.pumpAndSettle();
|
||
h.onQuery = (_) async => throw StateError('Offline');
|
||
await tester.tap(find.byKey(ValueKey(existing)));
|
||
await tester.pumpAndSettle();
|
||
expect(find.text('Could not load instructions.'), findsOneWidget);
|
||
h.onQuery = (_) async => [];
|
||
h.authorityDown = true;
|
||
await tester.ensureVisible(find.text('Retry'));
|
||
await tester.tap(find.text('Retry'));
|
||
await tester.pumpAndSettle();
|
||
expect(find.text('Could not load instructions.'), findsOneWidget);
|
||
h.authorityDown = false;
|
||
await tester.ensureVisible(find.text('Retry'));
|
||
await tester.tap(find.text('Retry'));
|
||
await tester.pumpAndSettle();
|
||
expect(
|
||
find.textContaining('Instructions aren’t available'),
|
||
findsOneWidget,
|
||
);
|
||
tester.platformDispatcher.textScaleFactorTestValue = 2;
|
||
await tester.pumpAndSettle();
|
||
await tester.pumpAndSettle();
|
||
await tester.ensureVisible(find.byTooltip('Copy public key'));
|
||
expect(tester.takeException(), isNull);
|
||
tester.platformDispatcher.clearTextScaleFactorTestValue();
|
||
await tester.pumpAndSettle();
|
||
await tester.pumpAndSettle();
|
||
h.relay.loseAcknowledgment = true;
|
||
await tester.ensureVisible(find.text('Make Bestie'));
|
||
await tester.tap(find.text('Make Bestie'));
|
||
await tester.pumpAndSettle();
|
||
expect(find.textContaining('Could not save Bestie.'), findsOneWidget);
|
||
h.relay.loseAcknowledgment = false;
|
||
h.relay.events = []; // The unacknowledged write was not retained.
|
||
await tester.ensureVisible(find.text('Make Bestie'));
|
||
await tester.tap(find.text('Make Bestie'));
|
||
await tester.pumpAndSettle();
|
||
expect(find.text('Your Bestie'), findsOneWidget);
|
||
final savedSync = container.read(meSyncProvider);
|
||
expect(decodeDefaultAgent(h.relay.events.single, savedSync), existing);
|
||
expect(
|
||
savedSync.prefs!.containsKey(
|
||
'main-agent:${savedSync.community}:${savedSync.pubkey}',
|
||
),
|
||
isTrue,
|
||
);
|
||
|
||
await tester.tap(find.byTooltip('Close sheet'));
|
||
await tester.pumpAndSettle();
|
||
expect(find.byType(UserProfileSheet), findsNothing);
|
||
expect(find.byKey(ValueKey(existing)), findsOneWidget);
|
||
expect(tester.element(find.byType(AgentsPage)), same(list));
|
||
expect(
|
||
tester.widget<AgentCard>(find.byKey(ValueKey(existing))).isBestie,
|
||
isTrue,
|
||
);
|
||
expect(
|
||
find.descendant(
|
||
of: find.byKey(ValueKey(existing)),
|
||
matching: find.byIcon(Icons.star),
|
||
),
|
||
findsOneWidget,
|
||
);
|
||
if (supportsRemoteAgents(h.community)) {
|
||
h.registry.listed = [
|
||
{
|
||
'agent_pubkey': fakeAgent,
|
||
'agent_id': 'remote-id',
|
||
'agent_name': 'Scout',
|
||
'agent_instructions': 'Review carefully.',
|
||
},
|
||
];
|
||
await h.controller.run(h.controller.refresh);
|
||
await tester.pumpAndSettle();
|
||
await tester.tap(find.byKey(ValueKey(fakeAgent)));
|
||
await tester.pumpAndSettle();
|
||
expect(
|
||
find.textContaining('This agent runs on Builderbot.'),
|
||
findsOneWidget,
|
||
);
|
||
// Remote instructions come from the registry row, not published config.
|
||
expect(h.registry.bodies['/v3/beekeeper/list-agents'], {
|
||
'include_instructions': true,
|
||
});
|
||
expect(find.text('Review carefully.'), findsOneWidget);
|
||
expect(
|
||
find.textContaining('Instructions aren’t available'),
|
||
findsNothing,
|
||
);
|
||
await tester.pumpAndSettle();
|
||
await tester.ensureVisible(find.text('Delete agent'));
|
||
expect(
|
||
tester.getTopLeft(find.text('Delete agent')).dy,
|
||
greaterThan(tester.getTopLeft(find.text('Public key')).dy),
|
||
);
|
||
final deleteCard = find.ancestor(
|
||
of: find.text('Delete agent'),
|
||
matching: find.byType(AppListCard),
|
||
);
|
||
expect(deleteCard, findsOneWidget);
|
||
expect(
|
||
find.descendant(of: deleteCard, matching: find.text('Public key')),
|
||
findsNothing,
|
||
);
|
||
await tester.tap(find.text('Delete agent'));
|
||
await tester.pumpAndSettle();
|
||
await tester.tap(find.text('Cancel'));
|
||
await tester.pumpAndSettle();
|
||
await tester.pumpAndSettle();
|
||
h.relay.events = [];
|
||
await tester.ensureVisible(find.text('Make Bestie'));
|
||
await tester.tap(find.text('Make Bestie'));
|
||
await tester.pumpAndSettle();
|
||
expect(find.text('Your Bestie'), findsOneWidget);
|
||
expect(decodeDefaultAgent(h.relay.events.single, savedSync), fakeAgent);
|
||
await tester.tap(find.byTooltip('Close sheet'));
|
||
await tester.pumpAndSettle();
|
||
expect(
|
||
tester.widget<AgentCard>(find.byKey(ValueKey(existing))).isBestie,
|
||
isFalse,
|
||
);
|
||
expect(
|
||
tester.widget<AgentCard>(find.byKey(ValueKey(fakeAgent))).isBestie,
|
||
isTrue,
|
||
);
|
||
}
|
||
ScaffoldMessenger.of(list).clearSnackBars();
|
||
await tester.tap(find.byTooltip('Close agents'));
|
||
await tester.pumpAndSettle();
|
||
expect(find.byType(BottomSheet), findsNothing);
|
||
h.relay.events = [];
|
||
final sync = container.read(meSyncProvider);
|
||
await sync.prefs!.remove('main-agent:${sync.community}:${sync.pubkey}');
|
||
await h.open(
|
||
tester,
|
||
directory: [existing],
|
||
overrides: [savedPrefsProvider.overrideWithValue(prefs)],
|
||
home: HomePage(
|
||
onAgents: showAgents,
|
||
defaultAgentBuilder: (context) => DefaultAgentLauncher(
|
||
onChooseAgent: () => showAgents(context, createOnOpen: true),
|
||
),
|
||
settingsPageBuilder: (_) => const SizedBox.shrink(),
|
||
mePageBuilder: (_, _, _) =>
|
||
const SliverToBoxAdapter(child: Text('Solo workspace')),
|
||
),
|
||
);
|
||
await tester.tap(find.byTooltip('Bestie'));
|
||
await tester.pumpAndSettle();
|
||
await tester.tap(find.byKey(ValueKey(existing)));
|
||
await tester.pumpAndSettle();
|
||
expect(find.byType(UserProfileSheet), findsOneWidget);
|
||
expect(find.text('Model not shared'), findsNothing);
|
||
await tester.binding.handlePopRoute();
|
||
await tester.pumpAndSettle();
|
||
expect(find.byType(UserProfileSheet), findsNothing);
|
||
await tester.tap(find.byTooltip('Close agents'));
|
||
await tester.pumpAndSettle();
|
||
expect(find.byTooltip('Search'), findsOneWidget);
|
||
});
|
||
}
|