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:
Tom Brow
2026-11-02 09:22:14 -08:00
committed by GitHub
parent b4509dee8b
commit daeb463f37
8 changed files with 175 additions and 5 deletions

View File

@@ -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

View File

@@ -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,

View File

@@ -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',
);
},

View File

@@ -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 {

View File

@@ -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(),

View 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,
);
}

View File

@@ -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,
);
}

View File

@@ -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},