Fix auto assistant readiness and header status chips
This commit is contained in:
parent
7a644e6c84
commit
79cb9064fc
@ -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,
|
||||
|
||||
@ -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 会话。',
|
||||
|
||||
@ -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),
|
||||
|
||||
@ -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,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@ -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/'));
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
46
test/widgets/assistant_connection_chip_test.dart
Normal file
46
test/widgets/assistant_connection_chip_test.dart
Normal 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);
|
||||
},
|
||||
);
|
||||
}
|
||||
Loading…
Reference in New Issue
Block a user