From 29db45035236a0cd54a553cc156dbfbfbd90122a Mon Sep 17 00:00:00 2001 From: Haitao Pan Date: Mon, 13 Apr 2026 09:24:06 +0800 Subject: [PATCH] Remove app-side pairingRequired connection state --- ...app_controller_desktop_thread_actions.dart | 5 +- ...pp_controller_desktop_thread_sessions.dart | 25 ++-- .../assistant/assistant_page_components.dart | 12 +- lib/features/mobile/mobile_shell_core.dart | 18 --- lib/features/mobile/mobile_shell_sheet.dart | 14 +- lib/runtime/runtime_models_profiles.dart | 2 - .../runtime_models_runtime_payloads.dart | 12 -- .../assistant_connection_status_test.dart | 124 ++++++++++++++++ .../assistant_connection_state_test.dart | 139 ++++++++++++++++++ 9 files changed, 290 insertions(+), 61 deletions(-) create mode 100644 test/features/assistant/assistant_connection_status_test.dart create mode 100644 test/runtime/assistant_connection_state_test.dart diff --git a/lib/app/app_controller_desktop_thread_actions.dart b/lib/app/app_controller_desktop_thread_actions.dart index a97d50f9..b4103d01 100644 --- a/lib/app/app_controller_desktop_thread_actions.dart +++ b/lib/app/app_controller_desktop_thread_actions.dart @@ -462,6 +462,7 @@ extension AppControllerDesktopThreadActions on AppController { } Map desktopStatusSnapshot() { + final connectionState = currentAssistantConnectionState; final pausedTasks = tasksControllerInternal.scheduled .where((item) => item.status == 'Disabled') .length; @@ -475,9 +476,9 @@ extension AppControllerDesktopThreadActions on AppController { final badgeCount = runningTasks + pausedTasks + timedOutTasks; return { 'connectionStatus': desktopConnectionStatusValueInternal( - connection.status, + connectionState.status, ), - 'connectionLabel': connection.status.label, + 'connectionLabel': connectionState.primaryLabel, 'runningTasks': runningTasks, 'pausedTasks': pausedTasks, 'timedOutTasks': timedOutTasks, diff --git a/lib/app/app_controller_desktop_thread_sessions.dart b/lib/app/app_controller_desktop_thread_sessions.dart index 0de5bb06..65b1a760 100644 --- a/lib/app/app_controller_desktop_thread_sessions.dart +++ b/lib/app/app_controller_desktop_thread_sessions.dart @@ -54,25 +54,32 @@ AssistantThreadConnectionState resolveGatewayThreadConnectionStateInternal({ }) { final bridgeAddress = connection.remoteAddress?.trim() ?? ''; final rawStatus = connection.status; - final pairingRequired = connection.pairingRequired; final gatewayTokenMissing = connection.gatewayTokenMissing; - final status = pairingRequired || gatewayTokenMissing + final hasFailureEvidence = + rawStatus == RuntimeConnectionStatus.error || + (connection.lastError?.trim().isNotEmpty ?? false) || + (connection.lastErrorCode?.trim().isNotEmpty ?? false) || + (connection.lastErrorDetailCode?.trim().isNotEmpty ?? false); + final genericFailure = !gatewayTokenMissing && hasFailureEvidence; + final status = gatewayTokenMissing || genericFailure ? RuntimeConnectionStatus.error : rawStatus; - final primaryLabel = pairingRequired - ? appText('需配对', 'Pairing Required') - : gatewayTokenMissing + final primaryLabel = gatewayTokenMissing ? appText('缺少令牌', 'Missing Token') + : genericFailure + ? appText('连接失败', 'Connection Failed') : status.label; + final detailLabel = bridgeAddress.isNotEmpty + ? bridgeAddress + : genericFailure + ? appText('xworkmate-bridge 连接失败', 'xworkmate-bridge connection failed') + : appText('xworkmate-bridge 未连接', 'xworkmate-bridge is not connected'); return AssistantThreadConnectionState( executionTarget: target, status: status, primaryLabel: primaryLabel, - detailLabel: bridgeAddress.isEmpty - ? appText('xworkmate-bridge 未连接', 'xworkmate-bridge is not connected') - : bridgeAddress, + detailLabel: detailLabel, ready: status == RuntimeConnectionStatus.connected, - pairingRequired: pairingRequired, gatewayTokenMissing: gatewayTokenMissing, lastError: connection.lastError?.trim(), ); diff --git a/lib/features/assistant/assistant_page_components.dart b/lib/features/assistant/assistant_page_components.dart index 373c5872..4c1511bc 100644 --- a/lib/features/assistant/assistant_page_components.dart +++ b/lib/features/assistant/assistant_page_components.dart @@ -510,16 +510,18 @@ class AssistantEmptyStateInternal extends StatelessWidget { '输入需求后即可开始执行,结果会回到当前会话并同步到任务页。', 'Type a request to start execution. Results return to this session and the Tasks page.', ) - : connectionState.pairingRequired - ? appText( - '当前设备还没通过 Gateway 配对审批。请先在已授权设备上批准该 pairing request,再重新连接。', - 'This device has not been approved yet. Approve the pairing request from an authorized device, then reconnect.', - ) : connectionState.gatewayTokenMissing ? appText( '首次连接需要共享 Token;配对完成后可继续使用本机的 device token。', 'The first connection requires a shared token; after pairing, this device can continue with its device token.', ) + : connectionState.status == RuntimeConnectionStatus.error + ? (connectionState.lastError?.trim().isNotEmpty == true + ? connectionState.lastError!.trim() + : appText( + '当前 bridge 连接失败。请重试连接;如果问题持续存在,请检查 bridge 运行状态和本机身份材料。', + 'The bridge connection failed. Retry the connection, and if it keeps failing, check bridge health and local device identity material.', + )) : !connected ? appText( '当前 xworkmate-bridge 尚未连接。请先恢复 bridge 连接,再继续当前任务。', diff --git a/lib/features/mobile/mobile_shell_core.dart b/lib/features/mobile/mobile_shell_core.dart index 81e5e570..937d2fba 100644 --- a/lib/features/mobile/mobile_shell_core.dart +++ b/lib/features/mobile/mobile_shell_core.dart @@ -213,24 +213,6 @@ class MobileShellStateInternal extends State { if (!mounted) { return; } - if (widget.controller.connection.pairingRequired) { - messenger?.showSnackBar( - SnackBar( - content: Text( - appText( - '配置码有效,已向 Gateway 发起配对请求。请先在已授权设备上审批。', - 'Setup code accepted. This device has requested pairing and now waits for approval.', - ), - ), - ), - ); - WidgetsBinding.instance.addPostFrameCallback((_) { - if (mounted) { - showMobileSafeSheetInternal(); - } - }); - return; - } await openGatewaySetupCodeEntryInternal(prefilledSetupCode: setupCode); if (!mounted) { return; diff --git a/lib/features/mobile/mobile_shell_sheet.dart b/lib/features/mobile/mobile_shell_sheet.dart index bcb8111e..e491730e 100644 --- a/lib/features/mobile/mobile_shell_sheet.dart +++ b/lib/features/mobile/mobile_shell_sheet.dart @@ -201,19 +201,7 @@ class MobileSafeSheetInternal extends StatelessWidget { ], ), ), - if (connection.pairingRequired) ...[ - const SizedBox(height: 12), - MobileSafetyNoticeInternal( - tone: palette.warning.withValues(alpha: 0.12), - borderColor: palette.warning.withValues(alpha: 0.32), - icon: Icons.approval_outlined, - title: appText('需要设备审批', 'Pairing Required'), - message: appText( - '当前设备已经向 Gateway 发起配对。请在已授权的 operator 设备上审批,然后重新连接。', - 'This device already requested pairing. Approve it from an authorized operator device, then reconnect.', - ), - ), - ] else if (connection.gatewayTokenMissing) ...[ + if (connection.gatewayTokenMissing) ...[ const SizedBox(height: 12), MobileSafetyNoticeInternal( tone: palette.danger.withValues(alpha: 0.1), diff --git a/lib/runtime/runtime_models_profiles.dart b/lib/runtime/runtime_models_profiles.dart index f834a377..21b22507 100644 --- a/lib/runtime/runtime_models_profiles.dart +++ b/lib/runtime/runtime_models_profiles.dart @@ -309,7 +309,6 @@ class AssistantThreadConnectionState { required this.primaryLabel, required this.detailLabel, required this.ready, - required this.pairingRequired, required this.gatewayTokenMissing, required this.lastError, }); @@ -319,7 +318,6 @@ class AssistantThreadConnectionState { final String primaryLabel; final String detailLabel; final bool ready; - final bool pairingRequired; final bool gatewayTokenMissing; final String? lastError; diff --git a/lib/runtime/runtime_models_runtime_payloads.dart b/lib/runtime/runtime_models_runtime_payloads.dart index 2b3fb3e6..2b233e94 100644 --- a/lib/runtime/runtime_models_runtime_payloads.dart +++ b/lib/runtime/runtime_models_runtime_payloads.dart @@ -158,18 +158,6 @@ class GatewayConnectionSnapshot { ); } - bool get pairingRequired { - if (status == RuntimeConnectionStatus.connected) { - return false; - } - final detailCode = lastErrorDetailCode?.trim().toUpperCase(); - final errorCode = lastErrorCode?.trim().toUpperCase(); - final errorText = lastError?.toLowerCase() ?? ''; - return detailCode == 'PAIRING_REQUIRED' || - errorCode == 'NOT_PAIRED' || - errorText.contains('pairing required'); - } - bool get gatewayTokenMissing { if (status == RuntimeConnectionStatus.connected) { return false; diff --git a/test/features/assistant/assistant_connection_status_test.dart b/test/features/assistant/assistant_connection_status_test.dart new file mode 100644 index 00000000..47fe9fb5 --- /dev/null +++ b/test/features/assistant/assistant_connection_status_test.dart @@ -0,0 +1,124 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:xworkmate/app/app_controller.dart'; +import 'package:xworkmate/features/assistant/assistant_page_main.dart'; +import 'package:xworkmate/models/app_models.dart'; +import 'package:xworkmate/runtime/runtime_models.dart'; +import 'package:xworkmate/theme/app_theme.dart'; + +void main() { + group('Assistant connection status surfaces', () { + testWidgets('shows connection failed chip for generic bridge failures', ( + tester, + ) async { + final controller = AppController(); + addTearDown(controller.dispose); + + await controller.sessionsController.switchSession('session-1'); + controller.runtimeInternal.snapshotInternal = controller + .runtimeInternal + .snapshot + .copyWith( + status: RuntimeConnectionStatus.error, + statusText: 'Connection failed', + remoteAddress: 'openclaw.svc.plus:443', + lastError: 'unsupported Ed25519 private key length: 0', + lastErrorCode: 'DEVICE_IDENTITY_SIGN_FAILED', + ); + + await tester.binding.setSurfaceSize(const Size(1440, 960)); + addTearDown(() async { + await tester.binding.setSurfaceSize(null); + }); + + await tester.pumpWidget(_buildAssistantPage(controller)); + await tester.pump(const Duration(milliseconds: 100)); + + expect( + find.byKey(const Key('assistant-connection-chip')), + findsOneWidget, + ); + expect(find.text('连接失败 · openclaw.svc.plus:443'), findsOneWidget); + expect(find.text('离线 · xworkmate-bridge 未连接'), findsNothing); + }); + + testWidgets('shows failure empty state for generic bridge errors', ( + tester, + ) async { + final controller = AppController(); + addTearDown(controller.dispose); + + await controller.sessionsController.switchSession('session-1'); + controller.runtimeInternal.snapshotInternal = controller + .runtimeInternal + .snapshot + .copyWith( + status: RuntimeConnectionStatus.error, + statusText: 'Connection failed', + lastError: 'unsupported Ed25519 private key length: 0', + lastErrorCode: 'DEVICE_IDENTITY_SIGN_FAILED', + clearRemoteAddress: true, + ); + + await tester.binding.setSurfaceSize(const Size(1440, 960)); + addTearDown(() async { + await tester.binding.setSurfaceSize(null); + }); + + await tester.pumpWidget(_buildAssistantPage(controller)); + await tester.pump(const Duration(milliseconds: 100)); + + expect( + find.byKey(const Key('assistant-empty-state-card')), + findsOneWidget, + ); + expect(find.text('Bridge 连接失败'), findsOneWidget); + expect( + find.text('unsupported Ed25519 private key length: 0'), + findsOneWidget, + ); + expect(find.text('先连接 Bridge'), findsNothing); + }); + + testWidgets('shows offline empty state only for true offline', ( + tester, + ) async { + final controller = AppController(); + addTearDown(controller.dispose); + + await controller.sessionsController.switchSession('session-1'); + controller.runtimeInternal.snapshotInternal = + GatewayConnectionSnapshot.initial( + mode: controller.runtimeInternal.snapshot.mode, + ); + + await tester.binding.setSurfaceSize(const Size(1440, 960)); + addTearDown(() async { + await tester.binding.setSurfaceSize(null); + }); + + await tester.pumpWidget(_buildAssistantPage(controller)); + await tester.pump(const Duration(milliseconds: 100)); + + expect( + find.byKey(const Key('assistant-empty-state-card')), + findsOneWidget, + ); + expect(find.text('先连接 Bridge'), findsOneWidget); + expect( + find.text('当前 xworkmate-bridge 尚未连接。请先恢复 bridge 连接,再继续当前任务。'), + findsOneWidget, + ); + }); + }); +} + +Widget _buildAssistantPage(AppController controller) { + return MaterialApp( + theme: AppTheme.light(), + home: AssistantPage( + controller: controller, + onOpenDetail: (DetailPanelData _) {}, + ), + ); +} diff --git a/test/runtime/assistant_connection_state_test.dart b/test/runtime/assistant_connection_state_test.dart new file mode 100644 index 00000000..c0b54d8c --- /dev/null +++ b/test/runtime/assistant_connection_state_test.dart @@ -0,0 +1,139 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:xworkmate/app/app_controller.dart'; +import 'package:xworkmate/runtime/runtime_models.dart'; + +void main() { + group('Assistant connection state', () { + test('maps generic bridge runtime failures to connection failed', () async { + final controller = AppController(); + addTearDown(controller.dispose); + + await controller.sessionsController.switchSession('session-1'); + await controller.setAssistantExecutionTarget( + AssistantExecutionTarget.gateway, + ); + + controller.runtimeInternal.snapshotInternal = controller + .runtimeInternal + .snapshot + .copyWith( + status: RuntimeConnectionStatus.error, + statusText: 'Connection failed', + remoteAddress: 'openclaw.svc.plus:443', + lastError: 'unsupported Ed25519 private key length: 0', + lastErrorCode: 'DEVICE_IDENTITY_SIGN_FAILED', + lastErrorDetailCode: null, + ); + + final state = controller.currentAssistantConnectionState; + expect(state.status, RuntimeConnectionStatus.error); + expect(state.primaryLabel, '连接失败'); + expect(state.detailLabel, 'openclaw.svc.plus:443'); + }); + + test('keeps true offline state as bridge not connected', () async { + final controller = AppController(); + addTearDown(controller.dispose); + + await controller.sessionsController.switchSession('session-1'); + await controller.setAssistantExecutionTarget( + AssistantExecutionTarget.gateway, + ); + + controller.runtimeInternal.snapshotInternal = + GatewayConnectionSnapshot.initial( + mode: controller.runtimeInternal.snapshot.mode, + ); + + final state = controller.currentAssistantConnectionState; + expect(state.status, RuntimeConnectionStatus.offline); + expect(state.primaryLabel, '离线'); + expect(state.detailLabel, 'xworkmate-bridge 未连接'); + }); + + test( + 'maps generic failures without address to bridge connection failed', + () async { + final controller = AppController(); + addTearDown(controller.dispose); + + await controller.sessionsController.switchSession('session-1'); + await controller.setAssistantExecutionTarget( + AssistantExecutionTarget.gateway, + ); + + controller.runtimeInternal.snapshotInternal = controller + .runtimeInternal + .snapshot + .copyWith( + status: RuntimeConnectionStatus.error, + statusText: 'Connection failed', + lastError: 'socket closed', + lastErrorCode: 'SOCKET_CLOSED', + lastErrorDetailCode: null, + clearRemoteAddress: true, + ); + + final state = controller.currentAssistantConnectionState; + expect(state.status, RuntimeConnectionStatus.error); + expect(state.primaryLabel, '连接失败'); + expect(state.detailLabel, 'xworkmate-bridge 连接失败'); + }, + ); + + test( + 'keeps gateway token missing as dedicated app-visible state', + () async { + final controller = AppController(); + addTearDown(controller.dispose); + + await controller.sessionsController.switchSession('session-1'); + await controller.setAssistantExecutionTarget( + AssistantExecutionTarget.gateway, + ); + + controller.runtimeInternal.snapshotInternal = controller + .runtimeInternal + .snapshot + .copyWith( + status: RuntimeConnectionStatus.error, + statusText: 'Connection failed', + lastError: 'gateway token missing', + lastErrorCode: 'AUTH_FAILED', + lastErrorDetailCode: 'AUTH_TOKEN_MISSING', + clearRemoteAddress: true, + ); + + final state = controller.currentAssistantConnectionState; + expect(state.status, RuntimeConnectionStatus.error); + expect(state.primaryLabel, '缺少令牌'); + expect(state.detailLabel, 'xworkmate-bridge 未连接'); + }, + ); + + test('desktop snapshot uses derived assistant connection labels', () async { + final controller = AppController(); + addTearDown(controller.dispose); + + await controller.sessionsController.switchSession('session-1'); + await controller.setAssistantExecutionTarget( + AssistantExecutionTarget.gateway, + ); + + controller.runtimeInternal.snapshotInternal = controller + .runtimeInternal + .snapshot + .copyWith( + status: RuntimeConnectionStatus.error, + statusText: 'Connection failed', + remoteAddress: 'openclaw.svc.plus:443', + lastError: 'unsupported Ed25519 private key length: 0', + lastErrorCode: 'DEVICE_IDENTITY_SIGN_FAILED', + ); + + final snapshot = controller.desktopStatusSnapshot(); + expect(snapshot['connectionStatus'], 'error'); + expect(snapshot['connectionLabel'], '连接失败'); + }); + }); +}