From daeb463f37fc7dfe27ba3c6ff33588e9b56ac81c Mon Sep 17 00:00:00 2001 From: Tom Brow Date: Mon, 2 Nov 2026 09:22:14 -0800 Subject: [PATCH] fix(mobile): prompt Builderlab sign-in instead of labeling agents desktop-only (#8907) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) | | --- | --- | | | | ### 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 Co-authored-by: Tom Brow Co-authored-by: Claude Opus 5.5 --- .github/workflows/_ci-clients.yml | 2 + .../agents/agent_identity_sections.dart | 13 +++- mobile/lib/features/agents/agents_page.dart | 23 +++++- mobile/lib/shared/agents/remote_agents.dart | 17 +++++ .../test/features/agents/agents_harness.dart | 3 + .../agents/agents_restore_prompt_test.dart | 36 +++++++++ .../agents/agents_signed_out_prompt_test.dart | 76 +++++++++++++++++++ .../test/helpers/remote_agents_fixture.dart | 10 ++- 8 files changed, 175 insertions(+), 5 deletions(-) create mode 100644 mobile/test/features/agents/agents_restore_prompt_test.dart create mode 100644 mobile/test/features/agents/agents_signed_out_prompt_test.dart diff --git a/.github/workflows/_ci-clients.yml b/.github/workflows/_ci-clients.yml index ba683a8a62..ad968b92b7 100644 --- a/.github/workflows/_ci-clients.yml +++ b/.github/workflows/_ci-clients.yml @@ -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 diff --git a/mobile/lib/features/agents/agent_identity_sections.dart b/mobile/lib/features/agents/agent_identity_sections.dart index 063fcc1c60..53c0f12729 100644 --- a/mobile/lib/features/agents/agent_identity_sections.dart +++ b/mobile/lib/features/agents/agent_identity_sections.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, diff --git a/mobile/lib/features/agents/agents_page.dart b/mobile/lib/features/agents/agents_page.dart index 7715b47aab..8430e93d9d 100644 --- a/mobile/lib/features/agents/agents_page.dart +++ b/mobile/lib/features/agents/agents_page.dart @@ -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', ); }, diff --git a/mobile/lib/shared/agents/remote_agents.dart b/mobile/lib/shared/agents/remote_agents.dart index 0abfd4ba81..00adee54ef 100644 --- a/mobile/lib/shared/agents/remote_agents.dart +++ b/mobile/lib/shared/agents/remote_agents.dart @@ -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((ref) async { diff --git a/mobile/test/features/agents/agents_harness.dart b/mobile/test/features/agents/agents_harness.dart index 7ef98dc972..0f9b21bf43 100644 --- a/mobile/test/features/agents/agents_harness.dart +++ b/mobile/test/features/agents/agents_harness.dart @@ -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 directory, Widget? home, + List 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(), diff --git a/mobile/test/features/agents/agents_restore_prompt_test.dart b/mobile/test/features/agents/agents_restore_prompt_test.dart new file mode 100644 index 0000000000..955d91b445 --- /dev/null +++ b/mobile/test/features/agents/agents_restore_prompt_test.dart @@ -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(); + 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, + ); +} diff --git a/mobile/test/features/agents/agents_signed_out_prompt_test.dart b/mobile/test/features/agents/agents_signed_out_prompt_test.dart new file mode 100644 index 0000000000..f662cc067c --- /dev/null +++ b/mobile/test/features/agents/agents_signed_out_prompt_test.dart @@ -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 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, + ); +} diff --git a/mobile/test/helpers/remote_agents_fixture.dart b/mobile/test/helpers/remote_agents_fixture.dart index 25b70d5324..40e1a3174c 100644 --- a/mobile/test/helpers/remote_agents_fixture.dart +++ b/mobile/test/helpers/remote_agents_fixture.dart @@ -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? restoreGate; + @override - Future restore() async => account; + Future restore() async { + await restoreGate?.future; + return account; + } + @override Future> currentIdentity() async => { 'identity': {'pubkey_hex': bound},