refactor: simplify sidebar to flat task-first layout

This commit is contained in:
Haitao Pan 2026-03-30 10:47:02 +08:00
parent aa040bd7c4
commit 7721589719
5 changed files with 948 additions and 2082 deletions

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,289 @@
part of 'sidebar_navigation.dart';
class SidebarFooter extends StatelessWidget {
const SidebarFooter({
super.key,
required this.isCollapsed,
required this.currentSection,
required this.appLanguage,
required this.themeMode,
required this.onToggleLanguage,
required this.onOpenThemeToggle,
required this.onOpenSettings,
required this.showSettingsButton,
required this.sidebarState,
required this.onCycleSidebarState,
required this.onOpenAccount,
required this.showAccountButton,
required this.accountSelected,
required this.showCollapseControl,
});
final bool isCollapsed;
final WorkspaceDestination currentSection;
final AppLanguage appLanguage;
final ThemeMode themeMode;
final VoidCallback onToggleLanguage;
final VoidCallback onOpenThemeToggle;
final VoidCallback onOpenSettings;
final bool showSettingsButton;
final AppSidebarState sidebarState;
final VoidCallback onCycleSidebarState;
final VoidCallback onOpenAccount;
final bool showAccountButton;
final bool accountSelected;
final bool showCollapseControl;
@override
Widget build(BuildContext context) {
final palette = context.palette;
final actions = <Widget>[
if (showSettingsButton)
_SidebarFooterButton(
key: const ValueKey<String>('sidebar-footer-settings'),
icon: currentSection == WorkspaceDestination.settings
? Icons.settings_rounded
: Icons.settings_outlined,
label: appText('设置', 'Settings'),
tooltip: appText('打开设置页', 'Open settings'),
selected: currentSection == WorkspaceDestination.settings,
collapsed: isCollapsed,
onTap: onOpenSettings,
),
if (showAccountButton)
_SidebarFooterButton(
key: const ValueKey<String>('sidebar-footer-account'),
icon: accountSelected
? Icons.account_circle_rounded
: Icons.account_circle_outlined,
label: appText('账户', 'Account'),
tooltip: appText('打开账号页', 'Open account'),
selected: accountSelected,
collapsed: isCollapsed,
onTap: onOpenAccount,
),
_SidebarFooterButton(
key: const ValueKey<String>('sidebar-footer-language'),
icon: Icons.translate_rounded,
label: appText('语言', 'Language'),
tooltip: appText('切换语言', 'Toggle language'),
collapsed: isCollapsed,
trailingLabel: isCollapsed ? null : _languageBadge(appLanguage),
onTap: onToggleLanguage,
),
_SidebarFooterButton(
key: const ValueKey<String>('sidebar-footer-theme'),
icon: _themeIcon(themeMode),
label: appText('主题', 'Theme'),
tooltip: appText('切换主题', 'Toggle theme'),
collapsed: isCollapsed,
trailingLabel: isCollapsed ? null : _themeBadge(themeMode),
onTap: onOpenThemeToggle,
),
if (showCollapseControl)
_SidebarFooterButton(
key: const ValueKey<String>('sidebar-footer-collapse'),
icon: _sidebarStateIcon(sidebarState),
label: _sidebarStateLabel(sidebarState),
tooltip: _sidebarStateTooltip(sidebarState),
collapsed: isCollapsed,
onTap: onCycleSidebarState,
),
];
return Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
mainAxisSize: MainAxisSize.min,
children: [
Container(
height: 1,
color: palette.chromeStroke.withValues(alpha: 0.9),
),
const SizedBox(height: AppSpacing.xs),
for (var index = 0; index < actions.length; index++) ...[
actions[index],
if (index != actions.length - 1) const SizedBox(height: AppSpacing.xs),
],
],
);
}
IconData _sidebarStateIcon(AppSidebarState state) {
return switch (state) {
AppSidebarState.expanded => Icons.keyboard_double_arrow_left_rounded,
AppSidebarState.collapsed => Icons.keyboard_double_arrow_right_rounded,
AppSidebarState.hidden => Icons.keyboard_double_arrow_right_rounded,
};
}
String _sidebarStateLabel(AppSidebarState state) {
return switch (state) {
AppSidebarState.expanded => appText('折叠', 'Collapse'),
AppSidebarState.collapsed => appText('展开', 'Expand'),
AppSidebarState.hidden => appText('展开', 'Expand'),
};
}
String _sidebarStateTooltip(AppSidebarState state) {
return switch (state) {
AppSidebarState.expanded => appText('收起侧边栏', 'Collapse sidebar'),
AppSidebarState.collapsed => appText('展开侧边栏', 'Expand sidebar'),
AppSidebarState.hidden => appText('展开侧边栏', 'Expand sidebar'),
};
}
IconData _themeIcon(ThemeMode mode) {
return switch (mode) {
ThemeMode.light => Icons.light_mode_rounded,
ThemeMode.dark => Icons.dark_mode_rounded,
ThemeMode.system => Icons.brightness_auto_rounded,
};
}
String _themeBadge(ThemeMode mode) {
return switch (mode) {
ThemeMode.light => appText('浅色', 'Light'),
ThemeMode.dark => appText('深色', 'Dark'),
ThemeMode.system => appText('跟随', 'Auto'),
};
}
String _languageBadge(AppLanguage language) {
return switch (language) {
AppLanguage.zh => '',
AppLanguage.en => 'EN',
};
}
}
class _SidebarFooterButton extends StatefulWidget {
const _SidebarFooterButton({
super.key,
required this.icon,
required this.label,
required this.tooltip,
required this.collapsed,
required this.onTap,
this.selected = false,
this.trailingLabel,
});
final IconData icon;
final String label;
final String tooltip;
final bool collapsed;
final VoidCallback onTap;
final bool selected;
final String? trailingLabel;
@override
State<_SidebarFooterButton> createState() => _SidebarFooterButtonState();
}
class _SidebarFooterButtonState extends State<_SidebarFooterButton> {
bool _hovered = false;
@override
Widget build(BuildContext context) {
final palette = context.palette;
final theme = Theme.of(context);
final active = widget.selected || _hovered;
final background = widget.selected
? palette.surfacePrimary
: _hovered
? palette.chromeSurfacePressed
: Colors.transparent;
return Tooltip(
message: widget.tooltip,
child: MouseRegion(
onEnter: (_) => setState(() => _hovered = true),
onExit: (_) => setState(() => _hovered = false),
child: AnimatedContainer(
duration: const Duration(milliseconds: 160),
decoration: BoxDecoration(
color: active ? background.withValues(alpha: 0.98) : Colors.transparent,
borderRadius: BorderRadius.circular(AppRadius.button),
border: Border.all(
color: active ? palette.strokeSoft : Colors.transparent,
),
),
child: Material(
color: Colors.transparent,
child: InkWell(
borderRadius: BorderRadius.circular(AppRadius.button),
onTap: widget.onTap,
child: SizedBox(
height: 40,
child: widget.collapsed
? Center(
child: Icon(
widget.icon,
size: AppSizes.sidebarIconSize,
color: active
? palette.textPrimary
: palette.textSecondary,
),
)
: Padding(
padding: const EdgeInsets.symmetric(horizontal: 8),
child: Row(
children: [
SizedBox(
width: 20,
child: Icon(
widget.icon,
size: AppSizes.sidebarIconSize,
color: active
? palette.textPrimary
: palette.textSecondary,
),
),
const SizedBox(width: 8),
Expanded(
child: Text(
widget.label,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: theme.textTheme.labelLarge?.copyWith(
color: active
? palette.textPrimary
: palette.textSecondary,
fontWeight: active
? FontWeight.w600
: FontWeight.w500,
),
),
),
if (widget.trailingLabel != null) ...[
const SizedBox(width: 8),
Container(
padding: const EdgeInsets.symmetric(
horizontal: 8,
vertical: 4,
),
decoration: BoxDecoration(
color: palette.surfacePrimary,
borderRadius: BorderRadius.circular(999),
border: Border.all(color: palette.strokeSoft),
),
child: Text(
widget.trailingLabel!,
style: theme.textTheme.labelSmall?.copyWith(
color: palette.textSecondary,
fontWeight: FontWeight.w600,
),
),
),
],
],
),
),
),
),
),
),
),
);
}
}

View File

@ -0,0 +1,526 @@
part of 'sidebar_navigation.dart';
class SidebarTaskItem {
const SidebarTaskItem({
required this.sessionKey,
required this.title,
required this.preview,
required this.updatedAtMs,
required this.executionTarget,
required this.isCurrent,
required this.pending,
this.draft = false,
});
final String sessionKey;
final String title;
final String preview;
final double? updatedAtMs;
final AssistantExecutionTarget executionTarget;
final bool isCurrent;
final bool pending;
final bool draft;
}
class SidebarTaskSection extends StatefulWidget {
const SidebarTaskSection({
super.key,
required this.items,
required this.skillCount,
this.onRefreshTasks,
this.onCreateTask,
this.onSelectTask,
this.onArchiveTask,
this.onRenameTask,
});
final List<SidebarTaskItem> items;
final int skillCount;
final Future<void> Function()? onRefreshTasks;
final Future<void> Function()? onCreateTask;
final Future<void> Function(String sessionKey)? onSelectTask;
final Future<void> Function(String sessionKey)? onArchiveTask;
final Future<void> Function(String sessionKey, String title)? onRenameTask;
@override
State<SidebarTaskSection> createState() => _SidebarTaskSectionState();
}
class _SidebarTaskSectionState extends State<SidebarTaskSection> {
final TextEditingController _searchController = TextEditingController();
final Set<AssistantExecutionTarget> _expandedTargets =
<AssistantExecutionTarget>{};
String _query = '';
@override
void initState() {
super.initState();
_syncExpandedTargets();
}
@override
void didUpdateWidget(covariant SidebarTaskSection oldWidget) {
super.didUpdateWidget(oldWidget);
if (oldWidget.items != widget.items) {
_syncExpandedTargets();
}
}
@override
void dispose() {
_searchController.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final palette = context.palette;
final filteredItems = _filteredItems();
final groups = _groupedItems(filteredItems);
return Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Padding(
padding: const EdgeInsets.fromLTRB(4, 0, 4, 8),
child: TextField(
key: const Key('workspace-sidebar-task-search'),
controller: _searchController,
onChanged: (value) {
setState(() {
_query = value.trim().toLowerCase();
});
},
decoration: InputDecoration(
hintText: appText('搜索任务', 'Search tasks'),
prefixIcon: const Icon(Icons.search_rounded),
suffixIcon: _query.isEmpty
? null
: IconButton(
tooltip: appText('清除搜索', 'Clear search'),
onPressed: () {
_searchController.clear();
setState(() {
_query = '';
});
},
icon: const Icon(Icons.close_rounded),
),
),
),
),
Padding(
padding: const EdgeInsets.fromLTRB(4, 0, 4, 8),
child: FilledButton.tonalIcon(
key: const Key('workspace-sidebar-new-task-button'),
onPressed: widget.onCreateTask == null
? null
: () async {
await widget.onCreateTask!();
},
icon: const Icon(Icons.edit_note_rounded),
label: Text(appText('新对话', 'New conversation')),
style: FilledButton.styleFrom(
minimumSize: const Size(0, 40),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(10),
),
),
),
),
Padding(
padding: const EdgeInsets.fromLTRB(4, 4, 4, 6),
child: Text(
appText('任务列表', 'Task list'),
style: theme.textTheme.titleSmall,
),
),
Expanded(
child: Scrollbar(
child: ListView(
padding: const EdgeInsets.fromLTRB(0, 0, 0, 4),
children: [
for (final group in groups) ...[
_SidebarTaskGroupHeader(
executionTarget: group.executionTarget,
count: group.items.length,
expanded: _expandedTargets.contains(group.executionTarget),
onTap: () {
setState(() {
if (_expandedTargets.contains(group.executionTarget)) {
_expandedTargets.remove(group.executionTarget);
} else {
_expandedTargets.add(group.executionTarget);
}
});
},
),
if (_expandedTargets.contains(group.executionTarget)) ...[
if (group.items.isEmpty)
Padding(
padding: const EdgeInsets.fromLTRB(28, 0, 8, 6),
child: Text(
appText('当前分组没有任务。', 'No tasks in this group.'),
style: theme.textTheme.bodySmall?.copyWith(
color: palette.textMuted,
),
),
),
for (final item in group.items)
Padding(
padding: const EdgeInsets.only(bottom: 4),
child: _SidebarTaskTile(
item: item,
onTap: widget.onSelectTask == null
? null
: () async {
await widget.onSelectTask!(item.sessionKey);
},
onArchive:
widget.onArchiveTask == null || item.pending
? null
: () async {
await widget.onArchiveTask!(item.sessionKey);
},
onRename: widget.onRenameTask == null
? null
: () async {
final renamed = await _promptRenameTask(
context,
item.title,
);
if (!mounted || renamed == null) {
return;
}
await widget.onRenameTask!(
item.sessionKey,
renamed,
);
},
),
),
],
const SizedBox(height: 4),
],
],
),
),
),
],
);
}
List<SidebarTaskItem> _filteredItems() {
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);
}
List<_SidebarTaskGroup> _groupedItems(List<SidebarTaskItem> items) {
final grouped = <AssistantExecutionTarget, List<SidebarTaskItem>>{
for (final target in AssistantExecutionTarget.values)
target: <SidebarTaskItem>[],
};
for (final item in items) {
grouped[item.executionTarget]!.add(item);
}
return AssistantExecutionTarget.values
.map(
(target) => _SidebarTaskGroup(
executionTarget: target,
items: grouped[target]!,
),
)
.toList(growable: false);
}
Future<String?> _promptRenameTask(
BuildContext context,
String currentTitle,
) async {
final input = TextEditingController(text: currentTitle);
final result = await showDialog<String>(
context: context,
builder: (context) => AlertDialog(
title: Text(appText('重命名任务', 'Rename task')),
content: TextField(
key: const Key('workspace-sidebar-task-rename-input'),
controller: input,
autofocus: true,
decoration: InputDecoration(
labelText: appText('任务名称', 'Task name'),
hintText: appText('留空后恢复默认名称', 'Leave empty to restore default'),
),
onSubmitted: (value) => Navigator.of(context).pop(value.trim()),
),
actions: [
TextButton(
onPressed: () => Navigator.of(context).pop(),
child: Text(appText('取消', 'Cancel')),
),
FilledButton(
onPressed: () => Navigator.of(context).pop(input.text.trim()),
child: Text(appText('保存', 'Save')),
),
],
),
);
input.dispose();
return result;
}
void _syncExpandedTargets() {
if (_expandedTargets.isNotEmpty) {
return;
}
_expandedTargets.addAll(AssistantExecutionTarget.values);
}
}
class _SidebarTaskGroup {
const _SidebarTaskGroup({
required this.executionTarget,
required this.items,
});
final AssistantExecutionTarget executionTarget;
final List<SidebarTaskItem> items;
}
class _SidebarTaskGroupHeader extends StatelessWidget {
const _SidebarTaskGroupHeader({
required this.executionTarget,
required this.count,
required this.expanded,
required this.onTap,
});
final AssistantExecutionTarget executionTarget;
final int count;
final bool expanded;
final VoidCallback onTap;
@override
Widget build(BuildContext context) {
final palette = context.palette;
final theme = Theme.of(context);
return Material(
color: Colors.transparent,
child: InkWell(
key: ValueKey<String>('workspace-sidebar-task-group-${executionTarget.name}'),
borderRadius: BorderRadius.circular(8),
onTap: onTap,
child: Padding(
padding: const EdgeInsets.fromLTRB(4, 4, 4, 2),
child: Row(
children: [
Icon(
expanded
? Icons.keyboard_arrow_down_rounded
: Icons.keyboard_arrow_right_rounded,
size: 16,
color: palette.textMuted,
),
const SizedBox(width: 4),
Icon(
_sidebarTaskTargetIcon(executionTarget),
size: 14,
color: palette.textMuted,
),
const SizedBox(width: 6),
Expanded(
child: Text(
executionTarget.label,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: theme.textTheme.labelMedium?.copyWith(
color: palette.textSecondary,
fontWeight: FontWeight.w600,
),
),
),
const SizedBox(width: 6),
Text(
'$count',
style: theme.textTheme.bodySmall?.copyWith(
color: palette.textMuted,
),
),
],
),
),
),
);
}
}
class _SidebarTaskTile extends StatelessWidget {
const _SidebarTaskTile({
required this.item,
this.onTap,
this.onArchive,
this.onRename,
});
final SidebarTaskItem item;
final Future<void> Function()? onTap;
final Future<void> Function()? onArchive;
final Future<void> Function()? onRename;
@override
Widget build(BuildContext context) {
final palette = context.palette;
final theme = Theme.of(context);
return Material(
color: item.isCurrent ? palette.surfacePrimary : Colors.transparent,
borderRadius: BorderRadius.circular(8),
child: InkWell(
key: ValueKey<String>('workspace-sidebar-task-item-${item.sessionKey}'),
borderRadius: BorderRadius.circular(8),
onTap: onTap == null
? null
: () async {
await onTap!();
},
onLongPress: onRename == null
? null
: () async {
await onRename!();
},
onSecondaryTap: onRename == null
? null
: () async {
await onRename!();
},
child: Container(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 8),
decoration: BoxDecoration(
color: item.isCurrent ? palette.surfaceSecondary : Colors.transparent,
borderRadius: BorderRadius.circular(8),
border: Border.all(
color: item.isCurrent ? palette.strokeSoft : Colors.transparent,
),
),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Container(
width: 24,
height: 24,
decoration: BoxDecoration(
color: item.pending
? palette.accentMuted.withValues(alpha: 0.88)
: palette.surfacePrimary,
borderRadius: BorderRadius.circular(6),
),
child: Icon(
item.draft
? Icons.edit_note_rounded
: item.pending
? Icons.play_arrow_rounded
: Icons.task_alt_rounded,
size: 15,
color: item.pending ? palette.accent : palette.textSecondary,
),
),
const SizedBox(width: 8),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
item.title,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: theme.textTheme.titleSmall?.copyWith(
fontWeight: item.isCurrent
? FontWeight.w700
: FontWeight.w600,
),
),
if (item.preview.trim().isNotEmpty) ...[
const SizedBox(height: 2),
Text(
item.preview.trim(),
maxLines: 2,
overflow: TextOverflow.ellipsis,
style: theme.textTheme.bodySmall?.copyWith(
color: palette.textMuted,
),
),
],
],
),
),
const SizedBox(width: 8),
Column(
crossAxisAlignment: CrossAxisAlignment.end,
children: [
Text(
_sidebarTaskUpdatedAtLabel(item.updatedAtMs),
style: theme.textTheme.bodySmall?.copyWith(
color: palette.textMuted,
),
),
if (onArchive != null)
IconButton(
key: ValueKey<String>(
'workspace-sidebar-task-archive-${item.sessionKey}',
),
tooltip: appText('归档任务', 'Archive task'),
visualDensity: VisualDensity.compact,
splashRadius: 12,
onPressed: () async {
await onArchive!();
},
icon: Icon(
Icons.archive_outlined,
size: 18,
color: palette.textMuted,
),
),
],
),
],
),
),
),
);
}
}
String _sidebarTaskUpdatedAtLabel(double? updatedAtMs) {
if (updatedAtMs == null) {
return '';
}
final timestamp = DateTime.fromMillisecondsSinceEpoch(updatedAtMs.round());
final now = DateTime.now();
final delta = now.difference(timestamp);
if (delta.inMinutes < 1) {
return appText('刚刚', 'Just now');
}
if (delta.inHours < 1) {
return appText('${delta.inMinutes} 分钟前', '${delta.inMinutes}m ago');
}
if (delta.inDays < 1) {
return appText('${delta.inHours} 小时前', '${delta.inHours}h ago');
}
if (delta.inDays < 7) {
return appText('${delta.inDays} 天前', '${delta.inDays}d ago');
}
return '${timestamp.month}/${timestamp.day}';
}
IconData _sidebarTaskTargetIcon(AssistantExecutionTarget target) {
return switch (target) {
AssistantExecutionTarget.singleAgent => Icons.hub_outlined,
AssistantExecutionTarget.local => Icons.computer_outlined,
AssistantExecutionTarget.remote => Icons.cloud_outlined,
};
}

View File

@ -31,6 +31,9 @@ void main() {
find.byKey(const Key('workspace-sidebar-new-task-button')),
findsOneWidget,
);
expect(find.text('自动化'), findsNothing);
expect(find.text('MCP Hub'), findsNothing);
expect(find.text('ClawHub'), findsNothing);
expect(
find.byKey(const Key('assistant-workspace-chrome-toggle')),
findsOneWidget,
@ -92,49 +95,20 @@ void main() {
expect(find.byKey(const Key('assistant-side-pane-tab-quick')), findsNothing);
expect(find.byKey(const Key('assistant-focus-panel-title')), findsNothing);
await tester.tap(find.text('设置').last);
await tester.tap(
find.byKey(const ValueKey<String>('sidebar-footer-settings')),
);
await tester.pumpAndSettle();
expect(find.byType(SidebarNavigation), findsOneWidget);
await tester.ensureVisible(find.text('自动化'));
await tester.tap(find.text('自动化').hitTestable());
await tester.pumpAndSettle();
expect(find.text('任务工作台'), findsOneWidget);
await tester.tap(find.text('设置').last);
await tester.pumpAndSettle();
expect(find.text('设置'), findsWidgets);
expect(
find.byKey(const ValueKey('web-settings-search-field')),
findsOneWidget,
);
await tester.tap(
find.byKey(const ValueKey<String>('sidebar-settings-tab-gateway')),
);
await tester.pumpAndSettle();
expect(find.text('OpenClaw Gateway'), findsWidgets);
expect(find.text('LLM 接入点'), findsWidgets);
expect(find.textContaining('浏览器本地存储'), findsOneWidget);
expect(find.textContaining('Local Gateway'), findsWidgets);
expect(find.textContaining('Remote Gateway'), findsWidgets);
await tester.ensureVisible(
find.byKey(const ValueKey('web-external-acp-provider-add-button')),
);
await tester.pumpAndSettle();
expect(find.text('Claude'), findsNothing);
expect(find.text('Gemini'), findsNothing);
expect(
find.byKey(const ValueKey('web-external-acp-provider-add-button')),
findsOneWidget,
find.byKey(const ValueKey<String>('sidebar-settings-tab-gateway')),
findsNothing,
);
expect(find.text('添加更多自定义配置'), findsOneWidget);
expect(find.text('标志'), findsNothing);
expect(find.text('Badge'), findsNothing);
});
}

View File

@ -52,7 +52,6 @@ void main() {
var themeToggled = 0;
var sidebarCycled = 0;
var accountOpened = 0;
var workspaceFollowToggled = 0;
await tester.pumpWidget(
MaterialApp(
@ -72,152 +71,58 @@ void main() {
onOpenThemeToggle: () => themeToggled++,
accountName: 'Tester',
accountSubtitle: 'Workspace',
onToggleAccountWorkspaceFollowed: () async {
workspaceFollowToggled++;
},
favoriteDestinations: const <AssistantFocusEntry>{
AssistantFocusEntry.skills,
},
onToggleFavorite: (_) async {},
onToggleAccountWorkspaceFollowed: () async {},
),
),
),
);
await tester.pumpAndSettle();
expect(find.text('工具'), findsOneWidget);
expect(find.text('MCP Hub'), findsOneWidget);
expect(find.text('工具'), findsNothing);
expect(find.text('工作区'), findsNothing);
expect(find.text('自动化'), findsNothing);
expect(find.text('MCP Hub'), findsNothing);
expect(find.text('ClawHub'), findsNothing);
expect(find.text('回到 APP首页'), findsNothing);
expect(find.text('设置'), findsOneWidget);
expect(find.text('账户'), findsOneWidget);
expect(find.text('语言'), findsOneWidget);
expect(find.text('主题'), findsOneWidget);
await tester.ensureVisible(find.text('自动化'));
await tester.tap(find.text('自动化').hitTestable());
await tester.pumpAndSettle();
expect(selected, WorkspaceDestination.tasks);
expect(
find.byKey(const ValueKey<String>('sidebar-favorite-skills')),
findsNothing,
await tester.tap(
find.byKey(const ValueKey<String>('sidebar-footer-settings')),
);
await tester.pumpAndSettle();
expect(selected, WorkspaceDestination.settings);
await tester.tap(find.byTooltip('切换语言'));
await tester.tap(
find.byKey(const ValueKey<String>('sidebar-footer-language')),
);
await tester.pumpAndSettle();
expect(languageToggled, 1);
await tester.tap(find.byTooltip('切换深色'));
await tester.tap(
find.byKey(const ValueKey<String>('sidebar-footer-theme')),
);
await tester.pumpAndSettle();
expect(themeToggled, 1);
await tester.tap(find.byTooltip('收起侧边栏'));
await tester.pumpAndSettle();
expect(sidebarCycled, 1);
await tester.tap(find.text('Tester'));
await tester.tap(
find.byKey(const ValueKey<String>('sidebar-footer-account')),
);
await tester.pumpAndSettle();
expect(accountOpened, 1);
await tester.tap(
find.byKey(const ValueKey<String>('sidebar-account-follow')),
find.byKey(const ValueKey<String>('sidebar-footer-collapse')),
);
await tester.pumpAndSettle();
expect(workspaceFollowToggled, 1);
expect(sidebarCycled, 1);
});
testWidgets('SidebarNavigation toggles footer quick action favorites', (
testWidgets('SidebarNavigation no longer expands settings sub navigation in sidebar', (
WidgetTester tester,
) async {
final toggled = <AssistantFocusEntry>[];
await tester.pumpWidget(
MaterialApp(
theme: AppTheme.light(),
home: Scaffold(
body: SidebarNavigation(
currentSection: WorkspaceDestination.assistant,
sidebarState: AppSidebarState.expanded,
appLanguage: AppLanguage.zh,
themeMode: ThemeMode.light,
onSectionChanged: (_) {},
onToggleLanguage: () {},
onCycleSidebarState: () {},
onExpandFromCollapsed: () {},
onOpenHome: () {},
onOpenAccount: () {},
onOpenThemeToggle: () {},
accountName: 'Tester',
accountSubtitle: 'Workspace',
onToggleAccountWorkspaceFollowed: () async {},
favoriteDestinations: const <AssistantFocusEntry>{
AssistantFocusEntry.language,
},
onToggleFavorite: (value) async => toggled.add(value),
),
),
),
);
await tester.pumpAndSettle();
await tester.tap(
find.byKey(const ValueKey<String>('sidebar-favorite-language')),
);
await tester.pumpAndSettle();
await tester.tap(
find.byKey(const ValueKey<String>('sidebar-favorite-theme')),
);
await tester.pumpAndSettle();
expect(toggled, const <AssistantFocusEntry>[
AssistantFocusEntry.language,
AssistantFocusEntry.theme,
]);
});
testWidgets(
'SidebarNavigation shows app home shortcut copy on settings page',
(WidgetTester tester) async {
var selected = WorkspaceDestination.settings;
var homeOpened = 0;
await tester.pumpWidget(
MaterialApp(
theme: AppTheme.light(),
home: Scaffold(
body: SidebarNavigation(
currentSection: selected,
sidebarState: AppSidebarState.expanded,
appLanguage: AppLanguage.zh,
themeMode: ThemeMode.light,
onSectionChanged: (value) => selected = value,
onToggleLanguage: () {},
onCycleSidebarState: () {},
onExpandFromCollapsed: () {},
onOpenHome: () => homeOpened++,
onOpenAccount: () {},
onOpenThemeToggle: () {},
accountName: 'Tester',
accountSubtitle: 'Workspace',
onToggleAccountWorkspaceFollowed: () async {},
),
),
),
);
await tester.pumpAndSettle();
expect(find.text('回到 APP首页'), findsOneWidget);
expect(find.text('新对话'), findsWidgets);
await tester.ensureVisible(find.text('回到 APP首页'));
await tester.tap(find.text('回到 APP首页').hitTestable());
await tester.pumpAndSettle();
expect(homeOpened, 1);
expect(selected, WorkspaceDestination.settings);
},
);
testWidgets('SidebarNavigation exposes settings sub navigation in sidebar', (
WidgetTester tester,
) async {
final changedTabs = <SettingsTab>[];
await tester.pumpWidget(
MaterialApp(
theme: AppTheme.light(),
@ -227,13 +132,6 @@ void main() {
sidebarState: AppSidebarState.expanded,
appLanguage: AppLanguage.zh,
themeMode: ThemeMode.light,
currentSettingsTab: SettingsTab.general,
availableSettingsTabs: const <SettingsTab>[
SettingsTab.general,
SettingsTab.workspace,
SettingsTab.gateway,
],
onSettingsTabChanged: changedTabs.add,
onSectionChanged: (_) {},
onToggleLanguage: () {},
onCycleSidebarState: () {},
@ -252,23 +150,16 @@ void main() {
expect(
find.byKey(const ValueKey<String>('sidebar-settings-tab-general')),
findsOneWidget,
findsNothing,
);
expect(
find.byKey(const ValueKey<String>('sidebar-settings-tab-workspace')),
findsOneWidget,
findsNothing,
);
expect(
find.byKey(const ValueKey<String>('sidebar-settings-tab-gateway')),
findsOneWidget,
findsNothing,
);
await tester.tap(
find.byKey(const ValueKey<String>('sidebar-settings-tab-gateway')),
);
await tester.pumpAndSettle();
expect(changedTabs, <SettingsTab>[SettingsTab.gateway]);
});
testWidgets('SidebarNavigation merges task controls into the global left bar', (
@ -321,7 +212,83 @@ void main() {
findsOneWidget,
);
expect(find.text('任务列表'), findsOneWidget);
expect(find.text('自动化'), findsOneWidget);
expect(find.text('自动化'), findsNothing);
expect(find.text('MCP Hub'), findsNothing);
expect(find.text('新的任务'), findsOneWidget);
expect(
find.byKey(
const ValueKey<String>('workspace-sidebar-task-group-singleAgent'),
),
findsOneWidget,
);
});
testWidgets('SidebarNavigation keeps footer pinned while task list scrolls', (
WidgetTester tester,
) async {
tester.view.devicePixelRatio = 1;
tester.view.physicalSize = const Size(1280, 900);
addTearDown(() {
tester.view.resetPhysicalSize();
tester.view.resetDevicePixelRatio();
});
final items = List<SidebarTaskItem>.generate(
18,
(index) => SidebarTaskItem(
sessionKey: 'session-$index',
title: '任务 $index',
preview: '预览 $index',
updatedAtMs: 1710000000000 + index.toDouble(),
executionTarget: AssistantExecutionTarget.singleAgent,
isCurrent: index == 0,
pending: false,
),
);
await tester.pumpWidget(
MaterialApp(
theme: AppTheme.light(),
home: Scaffold(
body: Center(
child: SizedBox(
width: 320,
height: 560,
child: SidebarNavigation(
currentSection: WorkspaceDestination.assistant,
sidebarState: AppSidebarState.expanded,
appLanguage: AppLanguage.zh,
themeMode: ThemeMode.light,
onSectionChanged: (_) {},
onToggleLanguage: () {},
onCycleSidebarState: () {},
onExpandFromCollapsed: () {},
onOpenHome: () {},
onOpenAccount: () {},
onOpenThemeToggle: () {},
accountName: 'Tester',
accountSubtitle: 'Workspace',
onToggleAccountWorkspaceFollowed: () async {},
taskItems: items,
),
),
),
),
),
);
await tester.pumpAndSettle();
final footerBefore = tester.getTopLeft(
find.byKey(const ValueKey<String>('sidebar-footer-settings')),
);
await tester.drag(find.byType(ListView), const Offset(0, -240));
await tester.pumpAndSettle();
final footerAfter = tester.getTopLeft(
find.byKey(const ValueKey<String>('sidebar-footer-settings')),
);
expect(footerAfter.dy, footerBefore.dy);
});
}