Clean bridge provider unavailable UX copy

This commit is contained in:
Haitao Pan 2026-04-12 16:41:38 +08:00
parent 6db804f133
commit 3cfae35b08
19 changed files with 245 additions and 256 deletions

View File

@ -258,15 +258,14 @@ extension AppControllerDesktopRuntimeHelpers on AppController {
}
final profile = gatewayProfileForAssistantExecutionTargetInternal(target);
final address = gatewayAddressLabelInternal(profile);
final targetLabel = target.label;
return address == appText('未连接目标', 'No target')
? appText(
'当前线程目标网关未连接。请先连接 $targetLabel,然后再重试。',
'The selected gateway target for this thread is not connected. Connect $targetLabel first, then try again.',
'当前 xworkmate-bridge 未连接。请先恢复 bridge 连接后再重试。',
'xworkmate-bridge is not connected. Restore the bridge connection, then try again.',
)
: appText(
'当前线程目标网关未连接:$address。请先连接后再重试。',
'The selected gateway target for this thread is not connected: $address. Connect it first, then try again.',
'当前 xworkmate-bridge 未连接:$address。请先恢复 bridge 连接后再重试。',
'xworkmate-bridge is not connected: $address. Restore the bridge connection, then try again.',
);
}
return raw;

View File

@ -76,32 +76,6 @@ Future<void> sendSingleAgentMessageDesktopGoTaskFlowInternal(
);
throw error;
}
final preflightUnavailableReason =
controller.singleAgentShouldSuggestAcpSwitchForSession(sessionKey) ||
controller.singleAgentNeedsBridgeProviderForSession(sessionKey)
? singleAgentUnavailableLabelDesktopInternal(
controller,
sessionKey,
null,
)
: null;
if (preflightUnavailableReason != null) {
controller.upsertTaskThreadInternal(
sessionKey,
lifecycleStatus: 'ready',
lastRunAtMs: DateTime.now().millisecondsSinceEpoch.toDouble(),
lastResultCode: 'error',
updatedAtMs: DateTime.now().millisecondsSinceEpoch.toDouble(),
);
controller.appendAssistantThreadMessageInternal(
sessionKey,
assistantErrorMessageSingleAgentDesktopInternal(
controller,
preflightUnavailableReason,
),
);
return;
}
if (controller.resolveExternalAcpEndpointForTargetInternal(
AssistantExecutionTarget.singleAgent,
) ==
@ -148,19 +122,13 @@ Future<void> sendSingleAgentMessageDesktopGoTaskFlowInternal(
sessionKey,
routingResolution.unavailableMessage,
)
: controller.singleAgentShouldSuggestAcpSwitchForSession(sessionKey)
? singleAgentUnavailableLabelDesktopInternal(
controller,
sessionKey,
null,
)
: controller.singleAgentNeedsBridgeProviderForSession(sessionKey)
: resolvedProviderId.isEmpty && effectiveProvider.isUnspecified
? singleAgentUnavailableLabelDesktopInternal(
controller,
sessionKey,
appText(
'Bridge 当前没有同步到可用 Provider。',
'The bridge does not currently have any synced providers.',
'Bridge 当前没有广告可用 Provider。',
'The bridge is not advertising any available providers.',
),
)
: null;

View File

@ -52,19 +52,11 @@ import 'app_controller_desktop_thread_sessions_collaboration_impl.dart';
AssistantThreadConnectionState resolveGatewayThreadConnectionStateInternal({
required AssistantExecutionTarget target,
required GatewayConnectionSnapshot connection,
required GatewayConnectionProfile targetProfile,
}) {
const expectedMode = RuntimeConnectionMode.remote;
final matchesTarget = connection.mode == expectedMode;
final targetAddress =
targetProfile.host.trim().isNotEmpty && targetProfile.port > 0
? '${targetProfile.host.trim()}:${targetProfile.port}'
: appText('未连接目标', 'No target');
final rawStatus = matchesTarget
? connection.status
: RuntimeConnectionStatus.offline;
final pairingRequired = matchesTarget && connection.pairingRequired;
final gatewayTokenMissing = matchesTarget && connection.gatewayTokenMissing;
final bridgeAddress = connection.remoteAddress?.trim() ?? '';
final rawStatus = connection.status;
final pairingRequired = connection.pairingRequired;
final gatewayTokenMissing = connection.gatewayTokenMissing;
final status = pairingRequired || gatewayTokenMissing
? RuntimeConnectionStatus.error
: rawStatus;
@ -77,11 +69,13 @@ AssistantThreadConnectionState resolveGatewayThreadConnectionStateInternal({
executionTarget: target,
status: status,
primaryLabel: primaryLabel,
detailLabel: targetAddress,
detailLabel: bridgeAddress.isEmpty
? appText('xworkmate-bridge 未连接', 'xworkmate-bridge is not connected')
: bridgeAddress,
ready: status == RuntimeConnectionStatus.connected,
pairingRequired: pairingRequired,
gatewayTokenMissing: gatewayTokenMissing,
lastError: matchesTarget ? connection.lastError?.trim() : null,
lastError: connection.lastError?.trim(),
);
}
@ -303,6 +297,12 @@ extension AppControllerDesktopThreadSessions on AppController {
AssistantExecutionTarget.singleAgent) {
return false;
}
if (resolveExternalAcpEndpointForTargetInternal(
AssistantExecutionTarget.singleAgent,
) ==
null) {
return false;
}
return bridgeProviderCatalog.isEmpty;
}
@ -317,6 +317,12 @@ extension AppControllerDesktopThreadSessions on AppController {
AssistantExecutionTarget.singleAgent) {
return false;
}
if (resolveExternalAcpEndpointForTargetInternal(
AssistantExecutionTarget.singleAgent,
) ==
null) {
return false;
}
final selection = singleAgentProviderForSession(normalizedSessionKey);
if (selection.isUnspecified) {
return false;
@ -461,7 +467,6 @@ extension AppControllerDesktopThreadSessions on AppController {
return resolveGatewayThreadConnectionStateInternal(
target: target,
connection: connection,
targetProfile: gatewayProfileForAssistantExecutionTargetInternal(target),
);
}

View File

@ -527,8 +527,8 @@ class AssistantEmptyStateInternal extends StatelessWidget {
: connected
? appText('开始对话或运行任务', 'Start a chat or run a task')
: connectionState.status == RuntimeConnectionStatus.error
? appText('Gateway 连接失败', 'Gateway connection failed')
: appText('先连接 Gateway', 'Connect a gateway first');
? appText('Bridge 连接失败', 'Bridge connection failed')
: appText('先连接 Bridge', 'Connect xworkmate-bridge first');
final description = singleAgent
? connected
? appText(
@ -566,8 +566,8 @@ class AssistantEmptyStateInternal extends StatelessWidget {
)
: !connected
? appText(
'当前线程目标网关尚未连接。请先连接对应 Gateway,再继续当前任务。',
'The selected gateway target for this thread is not connected yet. Connect that Gateway first, then continue this task.',
'当前 xworkmate-bridge 尚未连接。请先恢复 bridge 连接,再继续当前任务。',
'xworkmate-bridge is not connected yet. Restore the bridge connection, then continue this task.',
)
: (connectionState.lastError?.trim().isNotEmpty == true
? connectionState.lastError!.trim()
@ -627,8 +627,11 @@ class AssistantEmptyStateInternal extends StatelessWidget {
: singleAgent
? appText('查看线程工具栏', 'Open toolbar')
: reconnectAvailable
? appText('重新连接', 'Reconnect')
: appText('连接 Gateway', 'Connect gateway'),
? appText('重新连接 Bridge', 'Reconnect bridge')
: appText(
'连接 Bridge',
'Connect xworkmate-bridge',
),
),
style: FilledButton.styleFrom(
minimumSize: const Size(0, 28),
@ -641,32 +644,6 @@ class AssistantEmptyStateInternal extends StatelessWidget {
),
),
),
if (!connected && !singleAgent)
OutlinedButton.icon(
onPressed: singleAgent
? onOpenAiGatewaySettings
: onOpenGateway,
icon: Icon(
singleAgent
? Icons.hub_outlined
: Icons.settings_rounded,
),
label: Text(
singleAgent
? appText('打开设置中心', 'Open settings')
: appText('编辑连接', 'Edit connection'),
),
style: OutlinedButton.styleFrom(
minimumSize: const Size(0, 28),
padding: const EdgeInsets.symmetric(
horizontal: 10,
vertical: 6,
),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(8),
),
),
),
],
),
],

View File

@ -76,8 +76,8 @@ class McpServerPage extends StatelessWidget {
'No MCP servers connected.',
)
: appText(
'连接 Gateway 后可查看 MCP 服务器。',
'Connect a gateway to view MCP servers.',
'恢复 xworkmate-bridge 连接后可查看 MCP 服务器。',
'MCP servers are visible again after xworkmate-bridge reconnects.',
),
),
)

View File

@ -203,8 +203,8 @@ class MobileShellStateInternal extends State<MobileShell> {
SnackBar(
content: Text(
appText(
'已写入配置码并开始连接 Gateway',
'Setup code applied and Gateway connection started.',
'已写入配置码并开始连接 xworkmate-bridge',
'Setup code applied and xworkmate-bridge connection started.',
),
),
),

View File

@ -245,8 +245,8 @@ class MobileSafeSheetInternal extends StatelessWidget {
if (!controller.runtime.isConnected)
Text(
appText(
'连接 Gateway 后加载待审批设备与已配对设备。',
'Connect the gateway to load pending and paired devices.',
'恢复 xworkmate-bridge 连接后加载待审批设备与已配对设备。',
'Pending and paired devices load again after xworkmate-bridge reconnects.',
),
style: theme.textTheme.bodyMedium,
)
@ -279,8 +279,8 @@ class MobileSafeSheetInternal extends StatelessWidget {
if (!controller.runtime.isConnected)
Text(
appText(
'连接 Gateway 后可查看 paired device并在移动端直接吊销。',
'Connect the gateway to view paired devices and revoke them from mobile.',
'恢复 xworkmate-bridge 连接后可查看 paired device并在移动端直接吊销。',
'Paired devices are visible again after xworkmate-bridge reconnects, and can be revoked from mobile.',
),
style: theme.textTheme.bodyMedium,
)

View File

@ -92,7 +92,7 @@ class MobileWorkspaceLauncherInternal extends StatelessWidget {
),
primaryLabel: connection.status == RuntimeConnectionStatus.connected
? appText('查看连接', 'Connection')
: appText('连接 Gateway', 'Connect Gateway'),
: appText('连接 Bridge', 'Connect Bridge'),
secondaryLabel: appText('返回助手', 'Open Assistant'),
onPrimaryPressed: onOpenGatewayConnect,
onSecondaryPressed: () =>

View File

@ -259,8 +259,8 @@ class _NodesPanel extends StatelessWidget {
controller.connection.status == RuntimeConnectionStatus.connected
? appText('暂时还没有上报在线实例。', 'No live instances reported yet.')
: appText(
'连接 Gateway 后可加载实例与在线状态。',
'Connect a gateway to load instances / presence.',
'恢复 xworkmate-bridge 连接后可加载实例与在线状态。',
'Instances and presence return after xworkmate-bridge reconnects.',
),
),
)
@ -366,8 +366,8 @@ class _AgentsPanel extends StatelessWidget {
'No agents reported by the gateway.',
)
: appText(
'连接 Gateway 后可加载代理。',
'Connect a gateway to load agents.',
'恢复 xworkmate-bridge 连接后可加载代理。',
'Agents return after xworkmate-bridge reconnects.',
),
),
);
@ -539,8 +539,8 @@ class _SkillsPanel extends StatelessWidget {
'No skills loaded for the active gateway / agent.',
)
: appText(
'连接 Gateway 后可加载技能。',
'Connect a gateway to load skills.',
'恢复 xworkmate-bridge 连接后可加载技能。',
'Skills return after xworkmate-bridge reconnects.',
),
),
)

View File

@ -20,11 +20,6 @@ class ExternalCodeAgentAcpDesktopTransport
@visibleForTesting
GatewayAcpClient get clientForTest => _client;
@override
Future<void> syncExternalProviders(
List<ExternalCodeAgentAcpSyncedProvider> providers,
) async {}
@override
Future<ExternalCodeAgentAcpCapabilities> loadExternalAcpCapabilities({
required AssistantExecutionTarget target,

View File

@ -76,32 +76,6 @@ class ExternalCodeAgentAcpRoutingResolution {
raw['unavailableMessage']?.toString().trim() ?? '';
}
class ExternalCodeAgentAcpSyncedProvider {
const ExternalCodeAgentAcpSyncedProvider({
required this.providerId,
required this.label,
required this.endpoint,
required this.authorizationHeader,
required this.enabled,
});
final String providerId;
final String label;
final String endpoint;
final String authorizationHeader;
final bool enabled;
Map<String, dynamic> toJson() {
return <String, dynamic>{
'providerId': providerId.trim(),
'label': label.trim(),
'endpoint': endpoint.trim(),
'authorizationHeader': authorizationHeader.trim(),
'enabled': enabled,
};
}
}
enum ExternalCodeAgentAcpRoutingMode { auto, explicit }
class ExternalCodeAgentAcpAvailableSkill {
@ -604,10 +578,6 @@ String? goTaskServiceGatewayEntryState({
}
abstract class ExternalCodeAgentAcpTransport {
Future<void> syncExternalProviders(
List<ExternalCodeAgentAcpSyncedProvider> providers,
);
Future<ExternalCodeAgentAcpCapabilities> loadExternalAcpCapabilities({
required AssistantExecutionTarget target,
bool forceRefresh = false,
@ -640,10 +610,6 @@ abstract class ExternalCodeAgentAcpTransport {
}
abstract class GoTaskServiceClient {
Future<void> syncExternalProviders(
List<ExternalCodeAgentAcpSyncedProvider> providers,
);
Future<ExternalCodeAgentAcpCapabilities> loadExternalAcpCapabilities({
required AssistantExecutionTarget target,
bool forceRefresh = false,

View File

@ -11,11 +11,6 @@ class DesktopGoTaskService implements GoTaskServiceClient {
final ExternalCodeAgentAcpTransport _acpTransport;
@override
Future<void> syncExternalProviders(
List<ExternalCodeAgentAcpSyncedProvider> providers,
) => _acpTransport.syncExternalProviders(providers);
@override
Future<ExternalCodeAgentAcpCapabilities> loadExternalAcpCapabilities({
required AssistantExecutionTarget target,

View File

@ -61,8 +61,8 @@ class TasksFocusPreviewInternal extends StatelessWidget {
RuntimeConnectionStatus.connected
? appText('当前没有任务摘要。', 'No task summary yet.')
: appText(
'连接 Gateway 后这里会显示任务摘要。',
'Connect a gateway to load task summaries.',
'恢复 xworkmate-bridge 连接后这里会显示任务摘要。',
'Task summaries appear here after xworkmate-bridge reconnects.',
),
)
else
@ -112,12 +112,23 @@ class SkillsFocusPreviewInternal extends StatelessWidget {
.toList(growable: false)
: typedController.skills.take(4).toList(growable: false);
if (items.isEmpty) {
final bridgeEndpointMissing =
typedController.isSingleAgentMode &&
typedController.resolveExternalAcpEndpointForTargetInternal(
AssistantExecutionTarget.singleAgent,
) ==
null;
return PreviewEmptyStateInternal(
message: typedController.isSingleAgentMode
? (typedController.currentSingleAgentNeedsBridgeProvider
? appText(
'当前没有可用的 Bridge Provider请先在设置里配置并同步连接。',
'No bridge provider is available. Configure and sync a connection in Settings first.',
'Bridge 当前没有广告可用 Provider。恢复后这里会显示线程自己的技能摘要。',
'The bridge is not advertising any available providers right now. Thread-owned skill summaries will appear here after it recovers.',
)
: bridgeEndpointMissing
? appText(
'Bridge Server 当前不可用。恢复后这里会显示线程自己的技能摘要。',
'The bridge server is currently unavailable. Thread-owned skill summaries will appear here after it recovers.',
)
: appText(
'当前线程还没有已加载技能。切换 provider 后会读取该线程自己的 skills 列表。',
@ -130,8 +141,8 @@ class SkillsFocusPreviewInternal extends StatelessWidget {
'No skills are loaded for the active agent.',
)
: appText(
'连接 Gateway 后可查看技能摘要。',
'Connect a gateway to inspect skills here.',
'恢复 xworkmate-bridge 连接后可查看技能摘要。',
'Skill summaries are available again after xworkmate-bridge reconnects.',
),
);
}
@ -236,8 +247,8 @@ class McpFocusPreviewInternal extends StatelessWidget {
if (items.isEmpty) {
return PreviewEmptyStateInternal(
message: appText(
'当前没有 MCP 连接器。连接 Gateway 后这里会显示工具摘要。',
'No MCP connectors yet. Connect a gateway to load tool summaries here.',
'当前没有 MCP 连接器。恢复 xworkmate-bridge 连接后这里会显示工具摘要。',
'No MCP connectors yet. Tool summaries appear here after xworkmate-bridge reconnects.',
),
);
}

View File

@ -215,7 +215,7 @@ void main() {
);
expect(
controller.singleAgentResolvedProviderForSession(sessionKey),
SingleAgentProvider.codex,
isNull,
);
await controller.refreshSingleAgentSkillsForSession(sessionKey);
@ -281,7 +281,7 @@ void main() {
controller.singleAgentResolvedProviderForSession(
'draft:bridge-default',
),
SingleAgentProvider.codex,
isNull,
);
final thread = controller.taskThreadForSessionInternal(
@ -523,8 +523,4 @@ class _FakeGoTaskServiceClient implements GoTaskServiceClient {
);
}
@override
Future<void> syncExternalProviders(
List<ExternalCodeAgentAcpSyncedProvider> providers,
) async {}
}

View File

@ -170,13 +170,7 @@ void main() {
});
group('resolveGatewayThreadConnectionStateInternal', () {
test('uses the thread target profile as the only address source', () {
final targetProfile = GatewayConnectionProfile.defaults().copyWith(
mode: RuntimeConnectionMode.remote,
host: 'bridge.example.internal',
port: 443,
tls: true,
);
test('uses the current bridge connection address as the only address source', () {
final state = resolveGatewayThreadConnectionStateInternal(
target: AssistantExecutionTarget.gateway,
connection:
@ -184,9 +178,8 @@ void main() {
mode: RuntimeConnectionMode.remote,
).copyWith(
status: RuntimeConnectionStatus.connected,
remoteAddress: 'legacy-loopback:18789',
remoteAddress: 'bridge.example.internal:443',
),
targetProfile: targetProfile,
);
expect(state.status, RuntimeConnectionStatus.connected);
@ -194,13 +187,7 @@ void main() {
expect(state.ready, isTrue);
});
test('marks mismatched local snapshot as offline for remote threads', () {
final targetProfile = GatewayConnectionProfile.defaults().copyWith(
mode: RuntimeConnectionMode.remote,
host: 'bridge.example.internal',
port: 443,
tls: true,
);
test('uses current bridge snapshot even when the connection was established locally before', () {
final state = resolveGatewayThreadConnectionStateInternal(
target: AssistantExecutionTarget.gateway,
connection:
@ -210,19 +197,17 @@ void main() {
status: RuntimeConnectionStatus.connected,
remoteAddress: 'legacy-loopback:18789',
),
targetProfile: targetProfile,
);
expect(state.status, RuntimeConnectionStatus.offline);
expect(state.detailLabel, 'bridge.example.internal:443');
expect(state.ready, isFalse);
expect(state.lastError, isNull);
expect(state.status, RuntimeConnectionStatus.connected);
expect(state.detailLabel, 'legacy-loopback:18789');
expect(state.ready, isTrue);
});
});
group('assistantConnectionStateForSession', () {
test(
'uses target profile address instead of connection snapshot address',
'uses bridge connection address instead of thread target profile address',
() {
final gateway = _FakeGatewayRuntime(
GatewayConnectionSnapshot.initial(
@ -257,7 +242,7 @@ void main() {
final state = controller.assistantConnectionStateForSession(sessionKey);
expect(state.status, RuntimeConnectionStatus.connected);
expect(state.detailLabel, '未连接目标');
expect(state.detailLabel, 'legacy-loopback:18789');
expect(state.ready, isTrue);
},
);

View File

@ -27,28 +27,13 @@ void main() {
supportRootPathResolver: () async => root.path,
);
await store.initialize();
await store.saveSettingsSnapshot(
SettingsSnapshot.defaults().copyWith(
acpBridgeServerModeConfig: SettingsSnapshot.defaults()
.acpBridgeServerModeConfig
.copyWith(
cloudSynced: SettingsSnapshot.defaults()
.acpBridgeServerModeConfig
.cloudSynced
.copyWith(
remoteServerSummary:
const AcpBridgeServerRemoteServerSummary(
endpoint: 'https://bridge.customer.example',
hasAdvancedOverrides: false,
),
),
),
),
);
final client = _CapturingGoTaskServiceClient();
final controller = AppController(
store: store,
goTaskServiceClient: client,
environmentOverride: const <String, String>{
'BRIDGE_SERVER_URL': 'https://bridge.customer.example/acp',
},
);
_seedBridgeProviders(controller, const <SingleAgentProvider>[
SingleAgentProvider.codex,
@ -138,6 +123,8 @@ void main() {
);
await controller.switchSession(sessionKey);
expect(controller.currentSingleAgentNeedsBridgeProvider, isFalse);
await controller.sendChatMessage('first turn');
expect(client.requests, isEmpty);
@ -151,7 +138,7 @@ void main() {
);
test(
'single-agent turns stop before routing when bridge has no advertised provider',
'single-agent turns still dispatch when bridge routing resolves a provider even if the local catalog is empty',
() async {
final root = await Directory.systemTemp.createTemp(
'xworkmate-missing-bridge-provider-',
@ -163,28 +150,13 @@ void main() {
supportRootPathResolver: () async => root.path,
);
await store.initialize();
await store.saveSettingsSnapshot(
SettingsSnapshot.defaults().copyWith(
acpBridgeServerModeConfig: SettingsSnapshot.defaults()
.acpBridgeServerModeConfig
.copyWith(
cloudSynced: SettingsSnapshot.defaults()
.acpBridgeServerModeConfig
.cloudSynced
.copyWith(
remoteServerSummary:
const AcpBridgeServerRemoteServerSummary(
endpoint: 'https://bridge.customer.example',
hasAdvancedOverrides: false,
),
),
),
),
);
final client = _CapturingGoTaskServiceClient();
final controller = AppController(
store: store,
goTaskServiceClient: client,
environmentOverride: const <String, String>{
'BRIDGE_SERVER_URL': 'https://bridge.customer.example/acp',
},
);
addTearDown(() async {
controller.dispose();
@ -202,18 +174,19 @@ void main() {
await controller.switchSession(sessionKey);
_seedBridgeProviders(controller, const <SingleAgentProvider>[]);
expect(controller.currentSingleAgentNeedsBridgeProvider, isTrue);
await controller.sendChatMessage('first turn');
expect(client.requests, isEmpty);
expect(client.requests, hasLength(1));
expect(
client.resolveExternalAcpRoutingCallCount,
0,
1,
reason:
'single-agent turns should not call routing.resolve when bridge provider state is already unavailable in app state',
'single-agent turns should trust bridge routing.resolve instead of short-circuiting on the app-side provider cache',
);
expect(
controller.chatMessages.last.text,
isNot('Bridge 当前没有可用 Provider。'),
);
expect(controller.chatMessages.last.text, 'Bridge 当前没有可用 Provider。');
},
);
@ -386,8 +359,4 @@ class _CapturingGoTaskServiceClient implements GoTaskServiceClient {
);
}
@override
Future<void> syncExternalProviders(
List<ExternalCodeAgentAcpSyncedProvider> providers,
) async {}
}

View File

@ -14,6 +14,7 @@ import 'package:xworkmate/runtime/runtime_models.dart';
import 'package:xworkmate/runtime/secure_config_store.dart';
import 'package:xworkmate/runtime/skill_directory_access.dart';
import 'package:xworkmate/theme/app_theme.dart';
import 'package:xworkmate/widgets/assistant_focus_panel_previews.dart';
void main() {
TestWidgetsFlutterBinding.ensureInitialized();
@ -302,12 +303,121 @@ void main() {
await tester.pump();
expect(find.textContaining('设置 -> 集成'), findsNothing);
expect(find.textContaining('本地集成配置'), findsOneWidget);
expect(find.textContaining('等待 Bridge 就绪'), findsOneWidget);
expect(find.textContaining('Bridge Provider 尚未就绪'), findsOneWidget);
expect(find.textContaining('本地集成配置'), findsNothing);
expect(find.text('打开配置中心'), findsNothing);
expect(find.text('打开设置中心'), findsNothing);
expect(find.text('查看线程工具栏'), findsOneWidget);
},
);
testWidgets(
'single-agent skills focus preview describes bridge recovery instead of settings sync when endpoint is missing',
(tester) async {
final root = Directory.systemTemp.createTempSync(
'xworkmate-focus-preview-widget-test-',
);
final store = SecureConfigStore(
enableSecureStorage: false,
appDataRootPathResolver: () async => '${root.path}/settings.sqlite3',
secretRootPathResolver: () async => root.path,
supportRootPathResolver: () async => root.path,
);
final controller = AppController(
store: store,
desktopPlatformService: UnsupportedDesktopPlatformService(),
skillDirectoryAccessService: _FakeSkillDirectoryAccessService(
root.path,
),
goTaskServiceClient: const _FakeGoTaskServiceClient(),
singleAgentSharedSkillScanRootOverrides: const <String>[],
);
addTearDown(() async {
controller.dispose();
if (root.existsSync()) {
await root.delete(recursive: true);
}
});
controller.initializeAssistantThreadContext(
controller.currentSessionKey,
executionTarget: AssistantExecutionTarget.singleAgent,
singleAgentProvider: SingleAgentProvider.codex,
);
controller.bridgeProviderCatalogInternal = const <SingleAgentProvider>[];
await tester.pumpWidget(
MaterialApp(
theme: AppTheme.light(platform: TargetPlatform.macOS),
home: Scaffold(
body: SkillsFocusPreviewInternal(controller: controller),
),
),
);
await tester.pump();
expect(find.textContaining('Bridge Server 当前不可用'), findsOneWidget);
expect(find.textContaining('设置里配置并同步连接'), findsNothing);
},
);
testWidgets(
'gateway empty state only asks for bridge connectivity and removes edit-connection affordance',
(tester) async {
final root = Directory.systemTemp.createTempSync(
'xworkmate-gateway-empty-state-widget-test-',
);
final store = SecureConfigStore(
enableSecureStorage: false,
appDataRootPathResolver: () async => '${root.path}/settings.sqlite3',
secretRootPathResolver: () async => root.path,
supportRootPathResolver: () async => root.path,
);
final controller = AppController(
store: store,
desktopPlatformService: UnsupportedDesktopPlatformService(),
skillDirectoryAccessService: _FakeSkillDirectoryAccessService(
root.path,
),
goTaskServiceClient: const _FakeGoTaskServiceClient(),
singleAgentSharedSkillScanRootOverrides: const <String>[],
);
addTearDown(() async {
controller.dispose();
if (root.existsSync()) {
await root.delete(recursive: true);
}
});
controller.initializeAssistantThreadContext(
controller.currentSessionKey,
executionTarget: AssistantExecutionTarget.gateway,
);
await tester.pumpWidget(
MaterialApp(
theme: AppTheme.light(platform: TargetPlatform.macOS),
home: Scaffold(
body: AssistantEmptyStateInternal(
controller: controller,
onFocusComposer: () {},
onOpenGateway: () {},
onOpenAiGatewaySettings: () {},
onReconnectGateway: () async {},
),
),
),
);
await tester.pump();
expect(find.textContaining('先连接 Bridge'), findsOneWidget);
expect(find.textContaining('xworkmate-bridge 尚未连接'), findsOneWidget);
expect(find.text('连接 Bridge'), findsOneWidget);
expect(find.text('编辑连接'), findsNothing);
expect(find.text('连接 Gateway'), findsNothing);
},
);
}
void _seedBridgeProviders(
@ -422,8 +532,4 @@ class _FakeGoTaskServiceClient implements GoTaskServiceClient {
);
}
@override
Future<void> syncExternalProviders(
List<ExternalCodeAgentAcpSyncedProvider> providers,
) async {}
}

View File

@ -0,0 +1,38 @@
import 'dart:io';
import 'package:flutter_test/flutter_test.dart';
void main() {
test('bridge-only UI copy does not regress to legacy gateway connection wording', () {
final targets = <String>[
'lib/features/assistant/assistant_page_components.dart',
'lib/widgets/assistant_focus_panel_previews.dart',
'lib/features/mcp_server/mcp_server_page.dart',
'lib/features/modules/modules_page.dart',
'lib/features/mobile/mobile_shell_sheet.dart',
'lib/features/mobile/mobile_shell_core.dart',
'lib/features/mobile/mobile_shell_workspace.dart',
];
const forbiddenSnippets = <String>[
'连接 Gateway 后',
'Connect a gateway',
'Connect Gateway',
'编辑连接',
'当前线程目标网关尚未连接',
'Gateway connection failed',
'Connect gateway',
];
for (final path in targets) {
final source = File(path).readAsStringSync();
for (final snippet in forbiddenSnippets) {
expect(
source.contains(snippet),
isFalse,
reason: '$path should not contain legacy gateway-only copy: $snippet',
);
}
}
});
}

View File

@ -84,27 +84,6 @@ void main() {
},
);
test('ignores app-side provider sync in bridge-only mode', () async {
final client = _FakeGatewayAcpClient();
final transport = ExternalCodeAgentAcpDesktopTransport(
client: client,
endpointResolver: (_) => null,
);
await transport
.syncExternalProviders(const <ExternalCodeAgentAcpSyncedProvider>[
ExternalCodeAgentAcpSyncedProvider(
providerId: 'codex',
label: 'Codex',
endpoint: 'https://acp-server.svc.plus/codex/acp/rpc',
authorizationHeader: '',
enabled: true,
),
]);
expect(client.methods, isEmpty);
});
test(
'uses bridge routing resolve for preflight provider selection',
() async {