diff --git a/lib/app/app_controller_desktop_thread_sessions.dart b/lib/app/app_controller_desktop_thread_sessions.dart index 09b1d1cd..3259389a 100644 --- a/lib/app/app_controller_desktop_thread_sessions.dart +++ b/lib/app/app_controller_desktop_thread_sessions.dart @@ -105,7 +105,9 @@ extension AppControllerDesktopThreadSessions on AppController { sessionKey, ); final target = assistantExecutionTargetForSession(normalizedSessionKey); - final latestRouting = latestRoutingResolutionForSession(normalizedSessionKey); + final latestRouting = latestRoutingResolutionForSession( + normalizedSessionKey, + ); final latestResolvedModel = latestRouting['resolvedModel']?.toString().trim() ?? ''; if (target == AssistantExecutionTarget.singleAgent || @@ -285,6 +287,21 @@ extension AppControllerDesktopThreadSessions on AppController { bool get currentSingleAgentShouldSuggestAutoSwitch => singleAgentShouldSuggestAutoSwitchForSession(currentSessionKey); + bool autoRouteReadyForSession(String sessionKey) { + final normalizedSessionKey = normalizedAssistantSessionKeyInternal( + sessionKey, + ); + if (assistantExecutionTargetForSession(normalizedSessionKey) != + AssistantExecutionTarget.auto) { + return false; + } + return hasAnyAvailableSingleAgentProvider || + canUseAiGatewayConversation || + connection.status == RuntimeConnectionStatus.connected; + } + + bool get currentAutoRouteReady => autoRouteReadyForSession(currentSessionKey); + String singleAgentRuntimeModelForSession(String sessionKey) { final normalizedSessionKey = normalizedAssistantSessionKeyInternal( sessionKey, @@ -380,7 +397,9 @@ extension AppControllerDesktopThreadSessions on AppController { final target = assistantExecutionTargetForSession(normalizedSessionKey); if (target == AssistantExecutionTarget.singleAgent || target == AssistantExecutionTarget.auto) { - final latestRouting = latestRoutingResolutionForSession(normalizedSessionKey); + final latestRouting = latestRoutingResolutionForSession( + normalizedSessionKey, + ); final latestResolvedExecutionTarget = latestRouting['resolvedExecutionTarget']?.toString().trim() ?? ''; final latestResolvedEndpointTarget = @@ -397,12 +416,15 @@ extension AppControllerDesktopThreadSessions on AppController { : ''; if (target == AssistantExecutionTarget.auto && latestResolvedExecutionTarget.isEmpty) { + final autoReady = autoRouteReadyForSession(normalizedSessionKey); return AssistantThreadConnectionState( executionTarget: target, - status: RuntimeConnectionStatus.offline, + status: autoReady + ? RuntimeConnectionStatus.connected + : RuntimeConnectionStatus.offline, primaryLabel: primaryLabel, detailLabel: appText('待服务端路由', 'Waiting for server routing'), - ready: false, + ready: autoReady, pairingRequired: false, gatewayTokenMissing: false, lastError: null, @@ -412,21 +434,21 @@ extension AppControllerDesktopThreadSessions on AppController { latestResolvedExecutionTarget.isNotEmpty) { final detail = switch (latestResolvedExecutionTarget) { 'gateway' => joinConnectionPartsInternal([ - latestResolvedEndpointTarget.isEmpty - ? appText('OpenClaw Gateway', 'OpenClaw Gateway') - : latestResolvedEndpointTarget, - latestResolvedModel, - ]), + latestResolvedEndpointTarget.isEmpty + ? appText('OpenClaw Gateway', 'OpenClaw Gateway') + : latestResolvedEndpointTarget, + latestResolvedModel, + ]), 'multi-agent' => joinConnectionPartsInternal([ - appText('Multi-Agent', 'Multi-Agent'), - latestResolvedModel, - ]), + appText('Multi-Agent', 'Multi-Agent'), + latestResolvedModel, + ]), _ => joinConnectionPartsInternal([ - latestResolvedProviderId.isEmpty - ? appText('Single Agent', 'Single Agent') - : latestResolvedProviderId, - latestResolvedModel, - ]), + latestResolvedProviderId.isEmpty + ? appText('Single Agent', 'Single Agent') + : latestResolvedProviderId, + latestResolvedModel, + ]), }; return AssistantThreadConnectionState( executionTarget: target, diff --git a/lib/features/assistant/assistant_page_components.dart b/lib/features/assistant/assistant_page_components.dart index 0fc62d4f..ccb35b4a 100644 --- a/lib/features/assistant/assistant_page_components.dart +++ b/lib/features/assistant/assistant_page_components.dart @@ -484,6 +484,8 @@ class AssistantEmptyStateInternal extends StatelessWidget { final theme = Theme.of(context); final connectionState = controller.currentAssistantConnectionState; final singleAgent = connectionState.isSingleAgent; + final autoMode = + connectionState.executionTarget == AssistantExecutionTarget.auto; final connected = connectionState.connected; final singleAgentFallback = controller.currentSingleAgentUsesAiChatFallback; final singleAgentNeedsAiGateway = @@ -493,7 +495,11 @@ class AssistantEmptyStateInternal extends StatelessWidget { final providerLabel = controller.currentSingleAgentProvider.label; final reconnectAvailable = controller.canQuickConnectGateway; final title = singleAgent - ? connected + ? autoMode + ? connected + ? appText('开始对话或运行任务', 'Start a chat or run a task') + : appText('先准备 Auto 路由', 'Prepare Auto routing first') + : connected ? appText('开始单机智能体任务', 'Start a single-agent task') : singleAgentNeedsAiGateway ? appText('先配置 LLM API', 'Configure LLM API first') @@ -504,7 +510,17 @@ class AssistantEmptyStateInternal extends StatelessWidget { ? appText('Gateway 连接失败', 'Gateway connection failed') : appText('先连接 Gateway', 'Connect a gateway first'); final description = singleAgent - ? connected + ? autoMode + ? connected + ? appText( + '输入需求后会自动选择可用执行方式,并把结果回写到当前会话。', + 'Type a request and XWorkmate will choose an available execution route automatically, then return results to this session.', + ) + : appText( + 'Auto 当前还没有可用执行方式。请先配置任一外部 Agent ACP 端点、连接 Gateway,或配置 LLM API fallback。', + 'Auto currently has no available execution route. Configure an external Agent ACP endpoint, connect a Gateway, or configure LLM API fallback.', + ) + : connected ? (singleAgentFallback ? appText( '当前没有可用的外部 Agent ACP 连接,这个线程已降级到 AI Chat fallback,不会建立 OpenClaw Gateway 会话。', diff --git a/lib/features/assistant/assistant_page_main.dart b/lib/features/assistant/assistant_page_main.dart index 5f20e3d8..98cd1702 100644 --- a/lib/features/assistant/assistant_page_main.dart +++ b/lib/features/assistant/assistant_page_main.dart @@ -187,8 +187,7 @@ class AssistantPageStateInternal extends State { child: LayoutBuilder( builder: (context, constraints) { final showThreadRail = - widget.showStandaloneTaskRail && - constraints.maxWidth >= 860; + widget.showStandaloneTaskRail && constraints.maxWidth >= 860; final mainWorkspace = buildMainWorkspaceInternal( controller: controller, timelineItems: timelineItems, @@ -644,20 +643,33 @@ class ConversationAreaInternal extends StatelessWidget { children: [ Padding( padding: EdgeInsets.fromLTRB(10, 8, 10 + topTrailingInset, 8), - child: Align( - alignment: Alignment.centerRight, - child: Wrap( - spacing: 6, - runSpacing: 6, - alignment: WrapAlignment.end, - children: [ - MessageViewModeChipInternal( - value: messageViewMode, - onSelected: onMessageViewModeChanged, + child: LayoutBuilder( + builder: (context, constraints) { + final maxConnectionChipWidth = math.min( + constraints.maxWidth, + math.max(180, constraints.maxWidth * 0.62), + ); + return Align( + alignment: Alignment.centerRight, + child: Wrap( + spacing: 6, + runSpacing: 6, + alignment: WrapAlignment.end, + children: [ + MessageViewModeChipInternal( + value: messageViewMode, + onSelected: onMessageViewModeChanged, + ), + ConstrainedBox( + constraints: BoxConstraints( + maxWidth: maxConnectionChipWidth, + ), + child: ConnectionChipInternal(controller: controller), + ), + ], ), - ConnectionChipInternal(controller: controller), - ], - ), + ); + }, ), ), Divider(height: 1, color: palette.strokeSoft), diff --git a/lib/features/assistant/assistant_page_message_widgets.dart b/lib/features/assistant/assistant_page_message_widgets.dart index 80792ede..5fa86c9f 100644 --- a/lib/features/assistant/assistant_page_message_widgets.dart +++ b/lib/features/assistant/assistant_page_message_widgets.dart @@ -625,8 +625,9 @@ class ConnectionChipInternal extends StatelessWidget { @override Widget build(BuildContext context) { - final theme = Theme.of(context); final connectionState = controller.currentAssistantConnectionState; + final statusLabel = + '${controller.assistantConnectionStatusLabel} · ${controller.assistantConnectionTargetLabel}'; final color = connectionState.isSingleAgent ? (connectionState.connected ? context.palette.accentMuted @@ -641,20 +642,47 @@ class ConnectionChipInternal extends StatelessWidget { RuntimeConnectionStatus.offline => context.palette.surfaceSecondary, }; - return Container( + return ConnectionStatusChipInternal( key: const Key('assistant-connection-chip'), - padding: const EdgeInsets.symmetric( - horizontal: AppSpacing.xs, - vertical: 5, - ), - decoration: BoxDecoration( - color: color, - borderRadius: BorderRadius.circular(AppRadius.chip), - border: Border.all(color: context.palette.strokeSoft), - ), - child: Text( - '${controller.assistantConnectionStatusLabel} · ${controller.assistantConnectionTargetLabel}', - style: theme.textTheme.labelMedium, + statusLabel: statusLabel, + backgroundColor: color, + ); + } +} + +class ConnectionStatusChipInternal extends StatelessWidget { + const ConnectionStatusChipInternal({ + super.key, + required this.statusLabel, + required this.backgroundColor, + }); + + final String statusLabel; + final Color backgroundColor; + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + + return Tooltip( + message: statusLabel, + child: Container( + padding: const EdgeInsets.symmetric( + horizontal: AppSpacing.xs, + vertical: 5, + ), + decoration: BoxDecoration( + color: backgroundColor, + borderRadius: BorderRadius.circular(AppRadius.chip), + border: Border.all(color: context.palette.strokeSoft), + ), + child: Text( + statusLabel, + maxLines: 1, + overflow: TextOverflow.ellipsis, + softWrap: false, + style: theme.textTheme.labelMedium, + ), ), ); } diff --git a/test/runtime/app_controller_ai_gateway_chat_suite_single_agent.dart b/test/runtime/app_controller_ai_gateway_chat_suite_single_agent.dart index 816d2b1b..346e0194 100644 --- a/test/runtime/app_controller_ai_gateway_chat_suite_single_agent.dart +++ b/test/runtime/app_controller_ai_gateway_chat_suite_single_agent.dart @@ -56,7 +56,9 @@ void registerAppControllerAiGatewayChatSuiteSingleAgentTestsInternal() { ); await controller.saveSettings( controller.settings.copyWith( - multiAgent: controller.settings.multiAgent.copyWith(autoSync: false), + multiAgent: controller.settings.multiAgent.copyWith( + autoSync: false, + ), ), refreshAfterSave: false, ); @@ -97,6 +99,58 @@ void registerAppControllerAiGatewayChatSuiteSingleAgentTestsInternal() { }, ); + test( + 'AppController treats Auto as ready before the first routing resolution when any route is available', + () async { + final tempDirectory = await createTempDirectoryInternal( + 'xworkmate-auto-route-ready-', + ); + final store = createStoreFromTempDirectoryInternal(tempDirectory); + final client = FakeGoAgentCoreClientInternal( + capabilities: GoAgentCoreCapabilities( + singleAgent: true, + multiAgent: false, + providers: {SingleAgentProvider.opencode}, + raw: {}, + ), + ); + final controller = await createAppControllerInternal( + store: store, + availableSingleAgentProvidersOverride: const [ + SingleAgentProvider.opencode, + ], + runtimeCoordinator: RuntimeCoordinator( + gateway: FakeGatewayRuntimeInternal(store: store), + codex: FakeCodexRuntimeInternal(), + ), + goAgentCoreClient: client, + ); + await controller.saveSettings( + controller.settings.copyWith( + multiAgent: controller.settings.multiAgent.copyWith( + autoSync: false, + ), + ), + refreshAfterSave: false, + ); + await controller.setSingleAgentProvider(SingleAgentProvider.opencode); + await controller.setAssistantExecutionTarget( + AssistantExecutionTarget.auto, + ); + + expect( + controller.currentAssistantConnectionState.executionTarget, + AssistantExecutionTarget.auto, + ); + expect(controller.currentAssistantConnectionState.connected, isTrue); + expect(controller.currentAssistantConnectionState.ready, isTrue); + expect( + controller.currentAssistantConnectionState.detailLabel, + '待服务端路由', + ); + }, + ); + test( 'AppController shows Single Agent runtime status only when debug runtime is enabled', () async { @@ -161,7 +215,9 @@ void registerAppControllerAiGatewayChatSuiteSingleAgentTestsInternal() { final tempDirectory = await createTempDirectoryInternal( 'xworkmate-single-agent-workspace-bootstrap-', ); - final workspaceRoot = Directory('${tempDirectory.path}/thread-workspace'); + final workspaceRoot = Directory( + '${tempDirectory.path}/thread-workspace', + ); final store = createStoreFromTempDirectoryInternal(tempDirectory); await store.initialize(); await store.saveSettingsSnapshot( @@ -196,7 +252,9 @@ void registerAppControllerAiGatewayChatSuiteSingleAgentTestsInternal() { ); await controller.saveSettings( controller.settings.copyWith( - multiAgent: controller.settings.multiAgent.copyWith(autoSync: false), + multiAgent: controller.settings.multiAgent.copyWith( + autoSync: false, + ), ), refreshAfterSave: false, ); @@ -206,9 +264,8 @@ void registerAppControllerAiGatewayChatSuiteSingleAgentTestsInternal() { ); await controller.setSingleAgentProvider(SingleAgentProvider.opencode); - final initialWorkspacePath = controller.assistantWorkspacePathForSession( - controller.currentSessionKey, - ); + final initialWorkspacePath = controller + .assistantWorkspacePathForSession(controller.currentSessionKey); expect(initialWorkspacePath, isNot(workspaceRoot.path)); await controller.sendChatMessage( @@ -282,9 +339,7 @@ void registerAppControllerAiGatewayChatSuiteSingleAgentTestsInternal() { final beforeWorkspacePath = controller.assistantWorkspacePathForSession( controller.currentSessionKey, ); - final placeholderDir = Directory( - '${Directory.current.path}/not-set', - ); + final placeholderDir = Directory('${Directory.current.path}/not-set'); if (await placeholderDir.exists()) { await placeholderDir.delete(recursive: true); } @@ -496,10 +551,7 @@ void registerAppControllerAiGatewayChatSuiteSingleAgentTestsInternal() { expect(client.executeCalls, 0); expect(server.requestCount, 1); expect(workspacePath, isNotEmpty); - expect( - workspacePath, - contains('.xworkmate/threads/'), - ); + expect(workspacePath, contains('.xworkmate/threads/')); }, ); }); diff --git a/test/widgets/assistant_connection_chip_test.dart b/test/widgets/assistant_connection_chip_test.dart new file mode 100644 index 00000000..f4065d91 --- /dev/null +++ b/test/widgets/assistant_connection_chip_test.dart @@ -0,0 +1,46 @@ +@TestOn('vm') +library; + +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:xworkmate/features/assistant/assistant_page_message_widgets.dart'; +import 'package:xworkmate/theme/app_theme.dart'; + +void main() { + testWidgets( + 'ConnectionStatusChipInternal ellipsizes long labels inside narrow containers', + (WidgetTester tester) async { + await tester.pumpWidget( + MaterialApp( + theme: AppTheme.light(), + home: Scaffold( + body: Center( + child: SizedBox( + width: 180, + child: ConnectionStatusChipInternal( + key: const Key('assistant-connection-chip'), + statusLabel: + 'Auto · qwen2.5-coder-super-long-model-name-for-toolbar · 127.0.0.1:11434', + backgroundColor: Colors.blueGrey, + ), + ), + ), + ), + ), + ); + await tester.pumpAndSettle(); + + final chipFinder = find.byKey(const Key('assistant-connection-chip')); + expect(chipFinder, findsOneWidget); + expect(tester.takeException(), isNull); + expect(tester.getSize(chipFinder).width, lessThanOrEqualTo(180)); + + final chipText = tester.widget( + find.descendant(of: chipFinder, matching: find.byType(Text)), + ); + expect(chipText.maxLines, 1); + expect(chipText.overflow, TextOverflow.ellipsis); + expect(chipText.softWrap, isFalse); + }, + ); +}