mirror of
https://github.com/block/buzz.git
synced 2026-11-03 16:01:35 +00:00
fix(mobile): prompt Builderlab sign-in instead of labeling agents desktop-only (#8907)
Without a Builderlab session, mobile can't tell which agents run on Builderbot, so the Agents page labeled every agent "Manage on desktop", including remote ones. Now, when the community supports remote agents and there's no Builderlab session: - The Agents page shows "Sign in to Builderlab to manage remote agents" with a **Sign in** button (the same reconnect flow as Add). - Agent cards and the navigation subtitle drop the "Manage on desktop" label. - An agent's profile says to sign in to see whether it runs on Builderbot or desktop. ### Before / after iOS simulator, signed in to the remote-agent-pilot community with no Builderlab session. | Before (main) | After (this PR) | | --- | --- | | <img src="https://raw.githubusercontent.com/block/buzz/9be9cebedc8f5ff172e97a3df2cbf505a90e41c4/pr-8907--before-agents.png" width="300"> | <img src="https://raw.githubusercontent.com/block/buzz/9be9cebedc8f5ff172e97a3df2cbf505a90e41c4/pr-8907--after-agents.png" width="300"> | ### Validation - New widget test `agents_signed_out_prompt_test.dart` (runs with `BUZZ_AUTH_DOMAINS`, added to the CI list). - `just mobile-check` and the mobile test suite pass. - Checked the signed-out Agents page in the iOS simulator. buzz-review-completed (manual testing waived by Tom) 🤖 Generated with [Claude Code](https://claude.com/claude-code) --------- Signed-off-by: Tom Brow <tomb@squareup.com> Co-authored-by: Tom Brow <tomb@squareup.com> Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2
.github/workflows/_ci-clients.yml
vendored
2
.github/workflows/_ci-clients.yml
vendored
@@ -148,6 +148,8 @@ jobs:
|
||||
test/features/onboarding/onboarding_entry_routes_test.dart
|
||||
test/shared/hosted/hosted_environment_test.dart
|
||||
test/shared/hosted/self_hosted_entry_test.dart
|
||||
test/features/agents/agents_signed_out_prompt_test.dart
|
||||
test/features/agents/agents_restore_prompt_test.dart
|
||||
test/shared/hosted/relay_auth_isolation_test.dart
|
||||
test/features/onboarding/hosted_app_gate_environment_test.dart
|
||||
test/features/onboarding/hosted_journeys_test.dart
|
||||
|
||||
@@ -2,8 +2,8 @@ import 'package:flutter/material.dart';
|
||||
import 'package:flutter_hooks/flutter_hooks.dart';
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
|
||||
import '../../shared/community/community_provider.dart';
|
||||
import '../../shared/agents/remote_agents.dart';
|
||||
import '../../shared/community/community_provider.dart';
|
||||
import '../../shared/theme/theme.dart';
|
||||
import '../../shared/widgets/app_list_card.dart';
|
||||
import 'agent_instructions.dart';
|
||||
@@ -22,12 +22,15 @@ class AgentIdentitySections extends HookConsumerWidget {
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
useListenable(controller);
|
||||
useListenable(controller.account.subjectChanges);
|
||||
final community = ref.watch(activeCommunityProvider).asData?.value;
|
||||
final unknown = agentLocationUnknown(community, controller);
|
||||
final remote = controller.agents.any(
|
||||
(agent) => agent['agent_pubkey'] == pubkey,
|
||||
);
|
||||
// Remote instructions come from the registry row, listed for the active
|
||||
// community's owner only (as the agents page decides).
|
||||
final owner = ref.watch(activeCommunityProvider).asData?.value?.pubkey;
|
||||
final owner = community?.pubkey;
|
||||
final row = owner != null && controller.owner == owner
|
||||
? controller.agents
|
||||
.where((agent) => agent['agent_pubkey'] == pubkey)
|
||||
@@ -58,7 +61,11 @@ class AgentIdentitySections extends HookConsumerWidget {
|
||||
child: SizedBox(
|
||||
width: double.infinity,
|
||||
child: Text(
|
||||
remote
|
||||
unknown && !remote && controller.busy
|
||||
? 'Checking whether this agent runs on Builderbot…'
|
||||
: unknown && !remote
|
||||
? 'Sign in to Builderlab to see whether this agent runs on Builderbot or your desktop.'
|
||||
: remote
|
||||
? 'This agent runs on Builderbot. Your desktop doesn’t need to be online for it to work.'
|
||||
: 'This agent is managed on your desktop. Keep Buzz open and your desktop online for it to work.',
|
||||
style: context.textTheme.bodyMedium,
|
||||
|
||||
@@ -97,6 +97,11 @@ class _AgentsNavigation extends HookConsumerWidget {
|
||||
(agent) => agent['agent_pubkey'] == pubkey,
|
||||
)
|
||||
? 'Builderbot agent'
|
||||
: agentLocationUnknown(
|
||||
ref.read(activeCommunityProvider).asData?.value,
|
||||
controller,
|
||||
)
|
||||
? null
|
||||
: 'Manage on desktop',
|
||||
primaryActionsSpacing: Grid.md,
|
||||
contextualActions: (_) =>
|
||||
@@ -205,6 +210,8 @@ class AgentsPage extends HookConsumerWidget {
|
||||
});
|
||||
final profiles = ref.watch(userCacheProvider);
|
||||
final creating = useState(false);
|
||||
final needsSignIn = agentLocationUnknown(community, controller);
|
||||
final signInNeeded = builderlabSignInNeeded(community, controller);
|
||||
final failed =
|
||||
registered.hasError ||
|
||||
ownedDirectory.hasError ||
|
||||
@@ -323,7 +330,19 @@ class AgentsPage extends HookConsumerWidget {
|
||||
child: const Text('Retry'),
|
||||
),
|
||||
),
|
||||
if (!loading && !failed && keys.isEmpty)
|
||||
if (signInNeeded)
|
||||
ListTile(
|
||||
title: const Text(
|
||||
'Sign in to Builderlab to manage remote agents',
|
||||
),
|
||||
trailing: TextButton(
|
||||
onPressed: signingIn
|
||||
? null
|
||||
: () => flow.reconnectAccount(),
|
||||
child: const Text('Sign in'),
|
||||
),
|
||||
),
|
||||
if (!loading && !failed && !needsSignIn && keys.isEmpty)
|
||||
const Padding(
|
||||
padding: EdgeInsets.all(Grid.sm),
|
||||
child: Text(
|
||||
@@ -379,6 +398,8 @@ class AgentsPage extends HookConsumerWidget {
|
||||
? null
|
||||
: remote != null
|
||||
? 'Builderbot agent'
|
||||
: needsSignIn
|
||||
? null
|
||||
: 'Manage on desktop',
|
||||
);
|
||||
},
|
||||
|
||||
@@ -32,6 +32,23 @@ bool supportsRemoteAgents(Community? community) =>
|
||||
community.pubkey != null &&
|
||||
HostedEnvironment.supportsRelay(Uri.parse(community.relayUrl));
|
||||
|
||||
/// Whether a signed-out Builderlab account hides where this community's agents
|
||||
/// run: remote agents are unlistable until the user signs in, so an agent that
|
||||
/// is not in the registry is not necessarily desktop-managed.
|
||||
bool agentLocationUnknown(
|
||||
Community? community,
|
||||
RemoteAgentController controller,
|
||||
) => supportsRemoteAgents(community) && controller.account.account == null;
|
||||
|
||||
/// Whether to ask the user to sign in to Builderlab: the agent location is
|
||||
/// unknown and no load is in flight. A cold start's saved-session restore
|
||||
/// runs inside the controller's refresh, so prompting then would start a
|
||||
/// needless browser sign-in for a session that is about to be adopted.
|
||||
bool builderlabSignInNeeded(
|
||||
Community? community,
|
||||
RemoteAgentController controller,
|
||||
) => agentLocationUnknown(community, controller) && !controller.busy;
|
||||
|
||||
/// Restores a saved Builderlab session once, so a cold start with a stored
|
||||
/// session shows remote agents without waiting for another sign-in path.
|
||||
final builderlabRestoreProvider = FutureProvider<void>((ref) async {
|
||||
|
||||
@@ -19,6 +19,7 @@ import 'package:flutter/material.dart';
|
||||
import 'package:flutter_secure_storage/flutter_secure_storage.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:hooks_riverpod/misc.dart';
|
||||
import 'package:shared_preferences/shared_preferences.dart';
|
||||
|
||||
import '../../helpers/remote_agents_fixture.dart';
|
||||
@@ -196,6 +197,7 @@ class AgentsHarness {
|
||||
WidgetTester tester, {
|
||||
required List<String> directory,
|
||||
Widget? home,
|
||||
List<Override> overrides = const [],
|
||||
}) async {
|
||||
registered = [...directory, oldAppAgentKeys.public];
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
@@ -226,6 +228,7 @@ class AgentsHarness {
|
||||
AgentDirectoryEntry(pubkey: pubkey),
|
||||
],
|
||||
),
|
||||
...overrides,
|
||||
],
|
||||
child: MaterialApp(
|
||||
theme: AppTheme.light(),
|
||||
|
||||
36
mobile/test/features/agents/agents_restore_prompt_test.dart
Normal file
36
mobile/test/features/agents/agents_restore_prompt_test.dart
Normal file
@@ -0,0 +1,36 @@
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:buzz/shared/hosted/hosted_environment.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
|
||||
import 'agents_harness.dart';
|
||||
|
||||
void main() {
|
||||
testWidgets(
|
||||
'Agents does not ask to sign in while a saved session is restoring',
|
||||
(tester) async {
|
||||
final h = await AgentsHarness.create();
|
||||
h.registry.signedIn = false;
|
||||
final restore = Completer<void>();
|
||||
h.registry.restoreGate = restore;
|
||||
await h.open(tester, directory: []);
|
||||
|
||||
const prompt = 'Sign in to Builderlab to manage remote agents';
|
||||
expect(h.controller.busy, isTrue);
|
||||
expect(find.text(prompt), findsNothing);
|
||||
expect(find.widgetWithText(TextButton, 'Sign in'), findsNothing);
|
||||
expect(find.text('You haven’t added any agents yet.'), findsNothing);
|
||||
|
||||
// No saved session after all: now the prompt is the way forward.
|
||||
restore.complete();
|
||||
await tester.pumpAndSettle();
|
||||
expect(h.controller.busy, isFalse);
|
||||
expect(find.text(prompt), findsOneWidget);
|
||||
// Remote agents may exist but are unlistable until sign-in, so an
|
||||
// empty listing must not claim there are none.
|
||||
expect(find.text('You haven’t added any agents yet.'), findsNothing);
|
||||
},
|
||||
skip: !HostedEnvironment.enabled,
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
import 'package:buzz/features/profile/user_profile_sheet.dart';
|
||||
import 'package:buzz/shared/hosted/account_identity_store.dart';
|
||||
import 'package:buzz/shared/hosted/browser_auth.dart';
|
||||
import 'package:buzz/shared/hosted/hosted_environment.dart';
|
||||
import 'package:buzz/shared/hosted/hosted_flow.dart';
|
||||
import 'package:buzz/shared/hosted/hosted_flow_provider.dart';
|
||||
import 'package:buzz/shared/hosted/hosted_identity_service.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
|
||||
import 'agents_harness.dart';
|
||||
|
||||
class _Flow extends HostedFlow {
|
||||
_Flow(AgentsHarness h)
|
||||
: super(
|
||||
account: h.registry,
|
||||
store: AccountIdentityStore(),
|
||||
browser: BrowserAuth(),
|
||||
identity: HostedIdentityService(
|
||||
account: h.registry,
|
||||
store: AccountIdentityStore(),
|
||||
),
|
||||
enterCommunity: (_, _, _, _) async {},
|
||||
);
|
||||
int reconnects = 0;
|
||||
|
||||
@override
|
||||
Future<void> reconnectAccount({bool changeAccount = false}) async {
|
||||
reconnects++;
|
||||
}
|
||||
}
|
||||
|
||||
void main() {
|
||||
testWidgets(
|
||||
'signed out of Builderlab, Agents asks to sign in instead of claiming desktop',
|
||||
(tester) async {
|
||||
final h = await AgentsHarness.create();
|
||||
final flow = _Flow(h);
|
||||
addTearDown(() async {
|
||||
flow.dispose();
|
||||
await flow.browser.dispose();
|
||||
});
|
||||
h.registry.signedIn = false;
|
||||
await h.open(
|
||||
tester,
|
||||
directory: [existingDesktopKeys.public],
|
||||
overrides: [hostedFlowProvider.overrideWithValue(flow)],
|
||||
);
|
||||
|
||||
const prompt = 'Sign in to Builderlab to manage remote agents';
|
||||
expect(find.text(prompt), findsOneWidget);
|
||||
expect(find.byKey(ValueKey(existingDesktopKeys.public)), findsOneWidget);
|
||||
expect(find.text('Manage on desktop'), findsNothing);
|
||||
|
||||
// The Management section cannot claim the agent runs on the desktop.
|
||||
await tester.tap(find.byKey(ValueKey(existingDesktopKeys.public)));
|
||||
await tester.pumpAndSettle();
|
||||
expect(find.byType(UserProfileSheet), findsOneWidget);
|
||||
expect(find.textContaining('Keep Buzz open'), findsNothing);
|
||||
expect(find.textContaining('Sign in to Builderlab to see'), findsOne);
|
||||
await tester.tapAt(const Offset(5, 5));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
await tester.tap(find.widgetWithText(TextButton, 'Sign in'));
|
||||
await tester.pump();
|
||||
expect(flow.reconnects, 1);
|
||||
|
||||
// Signing in restores the desktop labelling for unregistered agents.
|
||||
h.registry.signedIn = true;
|
||||
await tester.pumpAndSettle();
|
||||
expect(find.text(prompt), findsNothing);
|
||||
expect(find.text('Manage on desktop'), findsOneWidget);
|
||||
},
|
||||
skip: !HostedEnvironment.enabled,
|
||||
);
|
||||
}
|
||||
@@ -68,8 +68,16 @@ class FakeRegistry extends BuilderlabAccountClient {
|
||||
BuilderlabAccount? get account => signedIn
|
||||
? BuilderlabAccount(subject: subject, expiresAt: DateTime.utc(2030))
|
||||
: null;
|
||||
|
||||
/// Holds [restore] open so a test can observe a cold-start restoration.
|
||||
Completer<void>? restoreGate;
|
||||
|
||||
@override
|
||||
Future<BuilderlabAccount?> restore() async => account;
|
||||
Future<BuilderlabAccount?> restore() async {
|
||||
await restoreGate?.future;
|
||||
return account;
|
||||
}
|
||||
|
||||
@override
|
||||
Future<Map<String, dynamic>> currentIdentity() async => {
|
||||
'identity': {'pubkey_hex': bound},
|
||||
|
||||
Reference in New Issue
Block a user