Fix auto assistant readiness and header status chips

This commit is contained in:
Haitao Pan 2026-04-06 07:28:48 +08:00
parent 7a644e6c84
commit 79cb9064fc
6 changed files with 237 additions and 61 deletions

View File

@ -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(<String>[
latestResolvedEndpointTarget.isEmpty
? appText('OpenClaw Gateway', 'OpenClaw Gateway')
: latestResolvedEndpointTarget,
latestResolvedModel,
]),
latestResolvedEndpointTarget.isEmpty
? appText('OpenClaw Gateway', 'OpenClaw Gateway')
: latestResolvedEndpointTarget,
latestResolvedModel,
]),
'multi-agent' => joinConnectionPartsInternal(<String>[
appText('Multi-Agent', 'Multi-Agent'),
latestResolvedModel,
]),
appText('Multi-Agent', 'Multi-Agent'),
latestResolvedModel,
]),
_ => joinConnectionPartsInternal(<String>[
latestResolvedProviderId.isEmpty
? appText('Single Agent', 'Single Agent')
: latestResolvedProviderId,
latestResolvedModel,
]),
latestResolvedProviderId.isEmpty
? appText('Single Agent', 'Single Agent')
: latestResolvedProviderId,
latestResolvedModel,
]),
};
return AssistantThreadConnectionState(
executionTarget: target,

View File

@ -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 会话。',

View File

@ -187,8 +187,7 @@ class AssistantPageStateInternal extends State<AssistantPage> {
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<double>(
constraints.maxWidth,
math.max<double>(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),

View File

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

View File

@ -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>{SingleAgentProvider.opencode},
raw: <String, dynamic>{},
),
);
final controller = await createAppControllerInternal(
store: store,
availableSingleAgentProvidersOverride: const <SingleAgentProvider>[
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/'));
},
);
});

View File

@ -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<Text>(
find.descendant(of: chipFinder, matching: find.byType(Text)),
);
expect(chipText.maxLines, 1);
expect(chipText.overflow, TextOverflow.ellipsis);
expect(chipText.softWrap, isFalse);
},
);
}