diff --git a/lib/features/assistant/assistant_page_components.dart b/lib/features/assistant/assistant_page_components.dart index 46af22e8..d673b3b1 100644 --- a/lib/features/assistant/assistant_page_components.dart +++ b/lib/features/assistant/assistant_page_components.dart @@ -286,18 +286,23 @@ List groupTasksForRailInternal( List tasks, List visibleExecutionTargets, ) { + final compactTargets = compactAssistantExecutionTargets( + visibleExecutionTargets, + ); final grouped = >{ - for (final target in visibleExecutionTargets) - target: [], + for (final target in compactTargets) target: [], }; for (final task in tasks) { - final bucket = grouped[task.executionTarget]; + final bucket = + grouped[collapseAssistantExecutionTargetForDisplay( + task.executionTarget, + )]; if (bucket == null) { continue; } bucket.add(task); } - return visibleExecutionTargets + return compactTargets .map( (target) => AssistantTaskGroupInternal( executionTarget: target, @@ -452,7 +457,7 @@ class AssistantTaskGroupHeaderInternal extends StatelessWidget { const SizedBox(width: 6), Flexible( child: Text( - executionTarget.label, + executionTarget.compactLabel, maxLines: 1, overflow: TextOverflow.ellipsis, style: theme.textTheme.labelMedium?.copyWith( diff --git a/lib/features/assistant/assistant_page_composer_bar.dart b/lib/features/assistant/assistant_page_composer_bar.dart index 6874122c..886a5183 100644 --- a/lib/features/assistant/assistant_page_composer_bar.dart +++ b/lib/features/assistant/assistant_page_composer_bar.dart @@ -365,6 +365,12 @@ class ComposerBarStateInternal extends State { : (visibleExecutionTargets.isNotEmpty ? visibleExecutionTargets.first : currentExecutionTarget); + final compactExecutionTargets = compactAssistantExecutionTargets( + visibleExecutionTargets, + ); + final compactExecutionTarget = collapseAssistantExecutionTargetForDisplay( + executionTarget, + ); final permissionLevel = controller.assistantPermissionLevel; final selectedSkills = widget.availableSkills .where((skill) => widget.selectedSkillKeys.contains(skill.key)) @@ -418,14 +424,21 @@ class ComposerBarStateInternal extends State { ), const SizedBox(width: 6), ], - if (visibleExecutionTargets.isNotEmpty) ...[ + if (compactExecutionTargets.isNotEmpty) ...[ PopupMenuButton( key: const Key('assistant-execution-target-button'), tooltip: appText('任务对话模式', 'Task Dialog Mode'), onSelected: (value) { - controller.setAssistantExecutionTarget(value); + final resolvedTarget = + value == AssistantExecutionTarget.singleAgent + ? AssistantExecutionTarget.singleAgent + : resolveGatewayExecutionTargetFromVisibleTargets( + visibleExecutionTargets, + currentTarget: executionTarget, + ); + controller.setAssistantExecutionTarget(resolvedTarget); }, - itemBuilder: (context) => visibleExecutionTargets + itemBuilder: (context) => compactExecutionTargets .map( (value) => PopupMenuItem( value: value, @@ -436,8 +449,8 @@ class ComposerBarStateInternal extends State { children: [ Icon(value.icon, size: 18), const SizedBox(width: 10), - Expanded(child: Text(value.label)), - if (value == executionTarget) + Expanded(child: Text(value.compactLabel)), + if (value == compactExecutionTarget) const Icon(Icons.check_rounded, size: 18), ], ), @@ -445,8 +458,10 @@ class ComposerBarStateInternal extends State { ) .toList(), child: ComposerToolbarChipInternal( - icon: executionTarget.icon, - tooltip: executionTargetTooltipInternal(executionTarget), + icon: compactExecutionTarget.icon, + tooltip: executionTargetTooltipInternal( + compactExecutionTarget, + ), showChevron: true, padding: const EdgeInsets.symmetric( horizontal: 10, @@ -796,7 +811,7 @@ class ComposerBarStateInternal extends State { const SizedBox(width: 8), Tooltip( message: submitLabel, - child: FilledButton( + child: FilledButton( key: const Key('assistant-send-button'), onPressed: connecting ? null diff --git a/lib/features/assistant/assistant_page_tooltip_labels.dart b/lib/features/assistant/assistant_page_tooltip_labels.dart index bf5db23e..e6a9cb12 100644 --- a/lib/features/assistant/assistant_page_tooltip_labels.dart +++ b/lib/features/assistant/assistant_page_tooltip_labels.dart @@ -38,7 +38,10 @@ import 'assistant_page_composer_clipboard.dart'; import 'assistant_page_components_core.dart'; String executionTargetTooltipInternal(AssistantExecutionTarget target) => - appText('任务对话模式: ${target.label}', 'Task dialog mode: ${target.label}'); + appText( + '任务对话模式: ${target.compactLabel}', + 'Task dialog mode: ${target.compactLabel}', + ); String singleAgentProviderTooltipInternal(SingleAgentProvider provider) => appText( diff --git a/lib/runtime/runtime_models_connection.dart b/lib/runtime/runtime_models_connection.dart index 92dbcfd1..b321c1fb 100644 --- a/lib/runtime/runtime_models_connection.dart +++ b/lib/runtime/runtime_models_connection.dart @@ -63,6 +63,16 @@ extension AssistantExecutionTargetCopy on AssistantExecutionTarget { AssistantExecutionTarget.remote => 'remote', }; + bool get isGateway => + this == AssistantExecutionTarget.local || + this == AssistantExecutionTarget.remote; + + String get compactLabel => switch (this) { + AssistantExecutionTarget.singleAgent => appText('智能体', 'Agent'), + AssistantExecutionTarget.local || AssistantExecutionTarget.remote => + appText('OpenClaw Gateway', 'OpenClaw Gateway'), + }; + static AssistantExecutionTarget fromJsonValue(String? value) { final normalized = value?.trim() ?? ''; switch (normalized) { @@ -83,6 +93,49 @@ extension AssistantExecutionTargetCopy on AssistantExecutionTarget { } } +List compactAssistantExecutionTargets( + Iterable targets, +) { + final ordered = []; + var addedGateway = false; + for (final target in targets) { + if (target == AssistantExecutionTarget.singleAgent) { + if (!ordered.contains(AssistantExecutionTarget.singleAgent)) { + ordered.add(AssistantExecutionTarget.singleAgent); + } + continue; + } + if (!addedGateway) { + ordered.add(AssistantExecutionTarget.remote); + addedGateway = true; + } + } + return List.unmodifiable(ordered); +} + +AssistantExecutionTarget collapseAssistantExecutionTargetForDisplay( + AssistantExecutionTarget target, +) => target.isGateway ? AssistantExecutionTarget.remote : target; + +AssistantExecutionTarget resolveGatewayExecutionTargetFromVisibleTargets( + Iterable visibleTargets, { + AssistantExecutionTarget? currentTarget, +}) { + final visible = visibleTargets.toList(growable: false); + if (currentTarget != null && + currentTarget.isGateway && + visible.contains(currentTarget)) { + return currentTarget; + } + if (visible.contains(AssistantExecutionTarget.local)) { + return AssistantExecutionTarget.local; + } + if (visible.contains(AssistantExecutionTarget.remote)) { + return AssistantExecutionTarget.remote; + } + return AssistantExecutionTarget.remote; +} + String normalizeSingleAgentProviderId(String value) { final trimmed = value.trim().toLowerCase(); if (trimmed.isEmpty) { diff --git a/lib/widgets/sidebar_navigation_task_section.dart b/lib/widgets/sidebar_navigation_task_section.dart index c80549d5..1c9a1c34 100644 --- a/lib/widgets/sidebar_navigation_task_section.dart +++ b/lib/widgets/sidebar_navigation_task_section.dart @@ -260,26 +260,33 @@ class _SidebarTaskSectionState extends State { if (_query.isEmpty) { return widget.items; } - return widget.items.where((item) { - final haystack = '${item.title}\n${item.preview}\n${item.sessionKey}' - .toLowerCase(); - return haystack.contains(_query); - }).toList(growable: false); + return widget.items + .where((item) { + final haystack = '${item.title}\n${item.preview}\n${item.sessionKey}' + .toLowerCase(); + return haystack.contains(_query); + }) + .toList(growable: false); } List<_SidebarTaskGroup> _groupedItems(List items) { + final compactTargets = compactAssistantExecutionTargets( + widget.visibleExecutionTargets, + ); final grouped = >{ - for (final target in widget.visibleExecutionTargets) - target: [], + for (final target in compactTargets) target: [], }; for (final item in items) { - final bucket = grouped[item.executionTarget]; + final bucket = + grouped[collapseAssistantExecutionTargetForDisplay( + item.executionTarget, + )]; if (bucket == null) { continue; } bucket.add(item); } - return widget.visibleExecutionTargets + return compactTargets .map( (target) => _SidebarTaskGroup( executionTarget: target, @@ -328,15 +335,14 @@ class _SidebarTaskSectionState extends State { if (_expandedTargets.isNotEmpty) { return; } - _expandedTargets.addAll(AssistantExecutionTarget.values); + _expandedTargets.addAll( + compactAssistantExecutionTargets(widget.visibleExecutionTargets), + ); } } class _SidebarTaskGroup { - const _SidebarTaskGroup({ - required this.executionTarget, - required this.items, - }); + const _SidebarTaskGroup({required this.executionTarget, required this.items}); final AssistantExecutionTarget executionTarget; final List items; @@ -362,7 +368,9 @@ class _SidebarTaskGroupHeader extends StatelessWidget { return Material( color: Colors.transparent, child: InkWell( - key: ValueKey('workspace-sidebar-task-group-${executionTarget.name}'), + key: ValueKey( + 'workspace-sidebar-task-group-${executionTarget.name}', + ), borderRadius: BorderRadius.circular(8), onTap: onTap, child: Padding( @@ -385,7 +393,7 @@ class _SidebarTaskGroupHeader extends StatelessWidget { const SizedBox(width: 6), Expanded( child: Text( - executionTarget.label, + executionTarget.compactLabel, maxLines: 1, overflow: TextOverflow.ellipsis, style: theme.textTheme.labelMedium?.copyWith( @@ -450,7 +458,9 @@ class _SidebarTaskTile extends StatelessWidget { child: Container( padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 8), decoration: BoxDecoration( - color: item.isCurrent ? palette.surfaceSecondary : Colors.transparent, + color: item.isCurrent + ? palette.surfaceSecondary + : Colors.transparent, borderRadius: BorderRadius.circular(8), border: Border.all( color: item.isCurrent ? palette.strokeSoft : Colors.transparent, diff --git a/test/features/assistant_page_suite_composer.dart b/test/features/assistant_page_suite_composer.dart index dbbae7bd..5c6cc569 100644 --- a/test/features/assistant_page_suite_composer.dart +++ b/test/features/assistant_page_suite_composer.dart @@ -789,7 +789,7 @@ void registerAssistantPageSuiteComposerTestsInternal() { expect( find.descendant( of: find.byKey(const Key('assistant-execution-target-button')), - matching: find.text('本地 OpenClaw Gateway'), + matching: find.text('OpenClaw Gateway'), ), findsOneWidget, ); @@ -810,15 +810,56 @@ void registerAssistantPageSuiteComposerTestsInternal() { expect( find.descendant( of: find.byKey(const Key('assistant-execution-target-button')), - matching: find.text('单机智能体'), + matching: find.text('智能体'), ), findsOneWidget, ); - expect(find.textContaining('单机智能体'), findsWidgets); + expect(find.textContaining('智能体'), findsWidgets); }, skip: true, ); + testWidgets( + 'AssistantPage collapses execution target menu into agent and gateway modes', + (WidgetTester tester) async { + final controller = await createControllerWithThreadRecordsInternal( + records: [], + useFakeGatewayRuntime: true, + ); + addTearDown(controller.dispose); + + await pumpPage( + tester, + child: AssistantPage(controller: controller, onOpenDetail: (_) {}), + ); + + await tester.tap(find.byKey(const Key('assistant-new-task-button'))); + await pumpForUiSyncInternal(tester); + + await tester.tap( + find.byKey(const Key('assistant-execution-target-button')), + ); + await pumpForUiSyncInternal(tester); + + expect( + find.byKey( + const Key('assistant-execution-target-menu-item-singleAgent'), + ), + findsOneWidget, + ); + expect( + find.byKey(const Key('assistant-execution-target-menu-item-remote')), + findsOneWidget, + ); + expect( + find.byKey(const Key('assistant-execution-target-menu-item-local')), + findsNothing, + ); + expect(find.text('智能体'), findsWidgets); + expect(find.text('OpenClaw Gateway'), findsWidgets); + }, + ); + testWidgets('AssistantPage shows thread-level message view chip', ( WidgetTester tester, ) async { diff --git a/test/widgets/sidebar_navigation_suite.dart b/test/widgets/sidebar_navigation_suite.dart index 7a18a0a4..14dbffbe 100644 --- a/test/widgets/sidebar_navigation_suite.dart +++ b/test/widgets/sidebar_navigation_suite.dart @@ -270,7 +270,7 @@ void main() { ); testWidgets( - 'SidebarNavigation only shows configured execution target groups', + 'SidebarNavigation merges local and remote tasks into one gateway group', (WidgetTester tester) async { await tester.pumpWidget( MaterialApp( @@ -350,7 +350,8 @@ void main() { ); expect(find.text('单机任务'), findsOneWidget); expect(find.text('远程任务'), findsOneWidget); - expect(find.text('本地任务'), findsNothing); + expect(find.text('本地任务'), findsOneWidget); + expect(find.text('OpenClaw Gateway'), findsOneWidget); }, );