Remove app-side pairingRequired connection state

This commit is contained in:
Haitao Pan 2026-04-13 09:24:06 +08:00
parent 5e9813bf62
commit 29db450352
9 changed files with 290 additions and 61 deletions

View File

@ -462,6 +462,7 @@ extension AppControllerDesktopThreadActions on AppController {
}
Map<String, dynamic> 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 <String, dynamic>{
'connectionStatus': desktopConnectionStatusValueInternal(
connection.status,
connectionState.status,
),
'connectionLabel': connection.status.label,
'connectionLabel': connectionState.primaryLabel,
'runningTasks': runningTasks,
'pausedTasks': pausedTasks,
'timedOutTasks': timedOutTasks,

View File

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

View File

@ -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 连接,再继续当前任务。',

View File

@ -213,24 +213,6 @@ class MobileShellStateInternal extends State<MobileShell> {
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;

View File

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

View File

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

View File

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

View File

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

View File

@ -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'], '连接失败');
});
});
}