refactor: collapse assistant modes into gateway grouping

This commit is contained in:
Haitao Pan 2026-04-09 10:52:25 +08:00
parent b5e0492c5b
commit 4e80afa7d0
7 changed files with 164 additions and 36 deletions

View File

@ -286,18 +286,23 @@ List<AssistantTaskGroupInternal> groupTasksForRailInternal(
List<AssistantTaskEntryInternal> tasks,
List<AssistantExecutionTarget> visibleExecutionTargets,
) {
final compactTargets = compactAssistantExecutionTargets(
visibleExecutionTargets,
);
final grouped = <AssistantExecutionTarget, List<AssistantTaskEntryInternal>>{
for (final target in visibleExecutionTargets)
target: <AssistantTaskEntryInternal>[],
for (final target in compactTargets) target: <AssistantTaskEntryInternal>[],
};
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(

View File

@ -365,6 +365,12 @@ class ComposerBarStateInternal extends State<ComposerBarInternal> {
: (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<ComposerBarInternal> {
),
const SizedBox(width: 6),
],
if (visibleExecutionTargets.isNotEmpty) ...[
if (compactExecutionTargets.isNotEmpty) ...[
PopupMenuButton<AssistantExecutionTarget>(
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<AssistantExecutionTarget>(
value: value,
@ -436,8 +449,8 @@ class ComposerBarStateInternal extends State<ComposerBarInternal> {
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<ComposerBarInternal> {
)
.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<ComposerBarInternal> {
const SizedBox(width: 8),
Tooltip(
message: submitLabel,
child: FilledButton(
child: FilledButton(
key: const Key('assistant-send-button'),
onPressed: connecting
? null

View File

@ -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(

View File

@ -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<AssistantExecutionTarget> compactAssistantExecutionTargets(
Iterable<AssistantExecutionTarget> targets,
) {
final ordered = <AssistantExecutionTarget>[];
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<AssistantExecutionTarget>.unmodifiable(ordered);
}
AssistantExecutionTarget collapseAssistantExecutionTargetForDisplay(
AssistantExecutionTarget target,
) => target.isGateway ? AssistantExecutionTarget.remote : target;
AssistantExecutionTarget resolveGatewayExecutionTargetFromVisibleTargets(
Iterable<AssistantExecutionTarget> 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) {

View File

@ -260,26 +260,33 @@ class _SidebarTaskSectionState extends State<SidebarTaskSection> {
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<SidebarTaskItem> items) {
final compactTargets = compactAssistantExecutionTargets(
widget.visibleExecutionTargets,
);
final grouped = <AssistantExecutionTarget, List<SidebarTaskItem>>{
for (final target in widget.visibleExecutionTargets)
target: <SidebarTaskItem>[],
for (final target in compactTargets) target: <SidebarTaskItem>[],
};
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<SidebarTaskSection> {
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<SidebarTaskItem> items;
@ -362,7 +368,9 @@ class _SidebarTaskGroupHeader extends StatelessWidget {
return Material(
color: Colors.transparent,
child: InkWell(
key: ValueKey<String>('workspace-sidebar-task-group-${executionTarget.name}'),
key: ValueKey<String>(
'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,

View File

@ -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: <TaskThread>[],
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 {

View File

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