feat: add breadcrumbs and sidebar previews

This commit is contained in:
Haitao Pan 2026-03-15 10:45:40 +08:00
parent a5762e1440
commit debe3be6da
16 changed files with 1135 additions and 225 deletions

View File

@ -254,6 +254,23 @@ class AppController extends ChangeNotifier {
notifyListeners();
}
void navigateHome() {
final mainSessionKey = _runtime.snapshot.mainSessionKey?.trim().isNotEmpty ==
true
? _runtime.snapshot.mainSessionKey!.trim()
: 'main';
final destinationChanged = _destination != WorkspaceDestination.assistant;
final detailChanged = _detailPanel != null;
_destination = WorkspaceDestination.assistant;
_detailPanel = null;
if (destinationChanged || detailChanged) {
notifyListeners();
}
if (_sessionsController.currentSessionKey != mainSessionKey) {
unawaited(switchSession(mainSessionKey));
}
}
void cycleSidebarState() {
_sidebarState = switch (_sidebarState) {
AppSidebarState.expanded => AppSidebarState.collapsed,

View File

@ -33,6 +33,15 @@ class _AccountPageState extends State<AccountPage> {
crossAxisAlignment: CrossAxisAlignment.start,
children: [
TopBar(
breadcrumbs: [
AppBreadcrumbItem(
label: appText('主页', 'Home'),
icon: Icons.home_rounded,
onTap: controller.navigateHome,
),
AppBreadcrumbItem(label: appText('账号', 'Account')),
AppBreadcrumbItem(label: _tab.label),
],
title: appText('账号', 'Account'),
subtitle: appText(
'用户身份、工作区切换与登录会话。',

View File

@ -67,6 +67,15 @@ class _AiGatewayPageState extends State<AiGatewayPage> {
crossAxisAlignment: CrossAxisAlignment.start,
children: [
TopBar(
breadcrumbs: [
AppBreadcrumbItem(
label: appText('主页', 'Home'),
icon: Icons.home_rounded,
onTap: controller.navigateHome,
),
const AppBreadcrumbItem(label: 'AI Gateway'),
AppBreadcrumbItem(label: _tab.label),
],
title: 'AI Gateway',
subtitle: appText(
'AI 代理与模型网关配置管理中心。',

View File

@ -14,6 +14,7 @@ import '../../theme/app_theme.dart';
import '../../widgets/gateway_connect_dialog.dart';
import '../../widgets/pane_resize_handle.dart';
import '../../widgets/surface_card.dart';
import '../../widgets/top_bar.dart';
class AssistantPage extends StatefulWidget {
const AssistantPage({
@ -200,6 +201,10 @@ class _AssistantPageState extends State<AssistantPage> {
),
onSelectPane: (pane) {
setState(() {
if (_activeSidePane == pane) {
_sidePaneCollapsed = !_sidePaneCollapsed;
return;
}
_activeSidePane = pane;
_sidePaneCollapsed = false;
});
@ -1197,6 +1202,17 @@ class _ConversationArea extends StatelessWidget {
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
AppBreadcrumbs(
items: [
AppBreadcrumbItem(
label: appText('主页', 'Home'),
icon: Icons.home_rounded,
onTap: controller.navigateHome,
),
AppBreadcrumbItem(label: currentTask.title),
],
),
const SizedBox(height: 10),
Text(
currentTask.title,
key: const Key('assistant-conversation-title'),

View File

@ -1,13 +1,12 @@
import 'package:flutter/material.dart';
import '../../app/app_controller.dart';
import '../../i18n/app_language.dart';
import '../../models/app_models.dart';
import '../../theme/app_palette.dart';
import '../../theme/app_theme.dart';
import '../../widgets/section_header.dart';
import '../../widgets/surface_card.dart';
import '../../widgets/top_bar.dart';
import '../../app/app_controller.dart';
import '../../i18n/app_language.dart';
import '../../models/app_models.dart';
import '../../theme/app_palette.dart';
import '../../widgets/section_header.dart';
import '../../widgets/surface_card.dart';
import '../../widgets/top_bar.dart';
class ClawHubPage extends StatefulWidget {
const ClawHubPage({
@ -120,14 +119,16 @@ class _ClawHubPageState extends State<ClawHubPage> {
return;
}
final slug = args[0];
setState(() => _isExecuting = true);
_addLog('Installing \$slug...');
_addLog('Installing ${args[0]}...');
Future.delayed(const Duration(milliseconds: 1200), () {
setState(() => _isExecuting = false);
_addLog('✓ Successfully installed \$slug', type: ClawHubLogType.success);
_addLog(' Location: ~/.clawhub/skills/\$slug');
_addLog(
'✓ Successfully installed ${args[0]}',
type: ClawHubLogType.success,
);
_addLog(' Location: ~/.clawhub/skills/${args[0]}');
_addLog(' Run "clawhub update" to check for updates.');
});
}
@ -184,6 +185,14 @@ class _ClawHubPageState extends State<ClawHubPage> {
crossAxisAlignment: CrossAxisAlignment.start,
children: [
TopBar(
breadcrumbs: [
AppBreadcrumbItem(
label: appText('主页', 'Home'),
icon: Icons.home_rounded,
onTap: widget.controller.navigateHome,
),
const AppBreadcrumbItem(label: 'ClawHub'),
],
title: 'ClawHub',
subtitle: appText(
'NPM 风格的包管理中心,支持搜索、安装和更新 Skills。',
@ -200,7 +209,7 @@ class _ClawHubPageState extends State<ClawHubPage> {
child: Container(
height: 400,
decoration: BoxDecoration(
color: palette.surfaceSecondary.withOpacity(0.5),
color: palette.surfaceSecondary.withValues(alpha: 0.5),
borderRadius: BorderRadius.circular(12),
),
child: Column(

View File

@ -30,6 +30,14 @@ class McpServerPage extends StatelessWidget {
crossAxisAlignment: CrossAxisAlignment.start,
children: [
TopBar(
breadcrumbs: [
AppBreadcrumbItem(
label: appText('主页', 'Home'),
icon: Icons.home_rounded,
onTap: controller.navigateHome,
),
const AppBreadcrumbItem(label: 'MCP Hub'),
],
title: 'MCP Hub',
subtitle: appText(
'管理 MCP 服务器连接与工具配置。',

View File

@ -77,6 +77,15 @@ import '../../widgets/top_bar.dart';
crossAxisAlignment: CrossAxisAlignment.start,
children: [
TopBar(
breadcrumbs: [
AppBreadcrumbItem(
label: appText('主页', 'Home'),
icon: Icons.home_rounded,
onTap: controller.navigateHome,
),
AppBreadcrumbItem(label: appText('模块', 'Modules')),
AppBreadcrumbItem(label: _tab.label),
],
title: appText('模块', 'Modules'),
subtitle: appText(
'管理 Gateway、代理、节点、技能和平台服务。',

View File

@ -40,6 +40,15 @@ class _SecretsPageState extends State<SecretsPage> {
crossAxisAlignment: CrossAxisAlignment.start,
children: [
TopBar(
breadcrumbs: [
AppBreadcrumbItem(
label: appText('主页', 'Home'),
icon: Icons.home_rounded,
onTap: controller.navigateHome,
),
AppBreadcrumbItem(label: appText('密钥', 'Secrets')),
AppBreadcrumbItem(label: _tab.label),
],
title: appText('密钥', 'Secrets'),
subtitle: appText(
'管理密钥提供方、凭证和模块间的安全引用。',

View File

@ -75,6 +75,15 @@ class _SettingsPageState extends State<SettingsPage> {
crossAxisAlignment: CrossAxisAlignment.start,
children: [
TopBar(
breadcrumbs: [
AppBreadcrumbItem(
label: appText('主页', 'Home'),
icon: Icons.home_rounded,
onTap: controller.navigateHome,
),
AppBreadcrumbItem(label: appText('设置', 'Settings')),
AppBreadcrumbItem(label: _tab.label),
],
title: appText('设置', 'Settings'),
subtitle: appText(
'配置 $kProductBrandName 工作区、网关默认项、界面与诊断选项',

View File

@ -31,6 +31,14 @@ class SkillsPage extends StatelessWidget {
crossAxisAlignment: CrossAxisAlignment.start,
children: [
TopBar(
breadcrumbs: [
AppBreadcrumbItem(
label: appText('主页', 'Home'),
icon: Icons.home_rounded,
onTap: controller.navigateHome,
),
AppBreadcrumbItem(label: appText('技能', 'Skills')),
],
title: appText('技能', 'Skills'),
subtitle: appText(
'管理已安装的技能包,查看技能状态与依赖。',

View File

@ -72,6 +72,15 @@ class _TasksPageState extends State<TasksPage> {
crossAxisAlignment: CrossAxisAlignment.start,
children: [
TopBar(
breadcrumbs: [
AppBreadcrumbItem(
label: appText('主页', 'Home'),
icon: Icons.home_rounded,
onTap: controller.navigateHome,
),
AppBreadcrumbItem(label: appText('任务', 'Tasks')),
AppBreadcrumbItem(label: _tab.label),
],
title: appText('任务', 'Tasks'),
subtitle: appText(
'查看任务队列、执行状态与历史记录',

File diff suppressed because it is too large Load Diff

View File

@ -1,4 +1,6 @@
import 'package:flutter/material.dart';
import '../theme/app_palette.dart';
import '../theme/app_theme.dart';
class TopBar extends StatelessWidget {
@ -6,11 +8,13 @@ class TopBar extends StatelessWidget {
super.key,
required this.title,
required this.subtitle,
this.breadcrumbs = const <AppBreadcrumbItem>[],
this.trailing,
});
final String title;
final String subtitle;
final List<AppBreadcrumbItem> breadcrumbs;
final Widget? trailing;
@override
@ -23,10 +27,17 @@ class TopBar extends StatelessWidget {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
if (breadcrumbs.isNotEmpty) ...[
AppBreadcrumbs(items: breadcrumbs),
const SizedBox(height: AppSpacing.sm),
],
Text(title, style: Theme.of(context).textTheme.headlineSmall),
const SizedBox(height: AppSpacing.xs),
Text(subtitle, style: Theme.of(context).textTheme.bodyMedium),
if (trailing != null) ...[const SizedBox(height: AppSpacing.md), trailing!],
if (trailing != null) ...[
const SizedBox(height: AppSpacing.md),
trailing!,
],
],
);
}
@ -38,6 +49,10 @@ class TopBar extends StatelessWidget {
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
if (breadcrumbs.isNotEmpty) ...[
AppBreadcrumbs(items: breadcrumbs),
const SizedBox(height: AppSpacing.sm),
],
Text(title, style: Theme.of(context).textTheme.headlineSmall),
const SizedBox(height: AppSpacing.xs),
Text(subtitle, style: Theme.of(context).textTheme.bodyMedium),
@ -54,3 +69,103 @@ class TopBar extends StatelessWidget {
);
}
}
class AppBreadcrumbItem {
const AppBreadcrumbItem({
required this.label,
this.onTap,
this.icon,
});
final String label;
final VoidCallback? onTap;
final IconData? icon;
}
class AppBreadcrumbs extends StatelessWidget {
const AppBreadcrumbs({super.key, required this.items});
final List<AppBreadcrumbItem> items;
@override
Widget build(BuildContext context) {
final palette = context.palette;
final theme = Theme.of(context);
return Wrap(
spacing: 8,
runSpacing: 8,
crossAxisAlignment: WrapCrossAlignment.center,
children: [
for (var index = 0; index < items.length; index++) ...[
if (index > 0)
Icon(
Icons.chevron_right_rounded,
size: 16,
color: palette.textMuted,
),
_BreadcrumbChip(
key: ValueKey<String>('workspace-breadcrumb-$index'),
item: items[index],
textStyle: theme.textTheme.labelLarge?.copyWith(
color: items[index].onTap != null
? palette.textPrimary
: palette.textSecondary,
fontWeight: index == items.length - 1
? FontWeight.w700
: FontWeight.w600,
),
),
],
],
);
}
}
class _BreadcrumbChip extends StatelessWidget {
const _BreadcrumbChip({
super.key,
required this.item,
required this.textStyle,
});
final AppBreadcrumbItem item;
final TextStyle? textStyle;
@override
Widget build(BuildContext context) {
final palette = context.palette;
final content = Row(
mainAxisSize: MainAxisSize.min,
children: [
if (item.icon != null) ...[
Icon(item.icon, size: 15, color: textStyle?.color),
const SizedBox(width: 6),
],
Text(item.label, style: textStyle),
],
);
final body = Container(
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 6),
decoration: BoxDecoration(
color: item.onTap != null
? palette.surfaceSecondary
: palette.surfacePrimary,
borderRadius: BorderRadius.circular(999),
border: Border.all(color: palette.strokeSoft),
),
child: content,
);
if (item.onTap == null) {
return body;
}
return InkWell(
onTap: item.onTap,
borderRadius: BorderRadius.circular(999),
child: body,
);
}
}

View File

@ -163,4 +163,29 @@ void main() {
expect(find.text('Gateway 访问'), findsOneWidget);
});
testWidgets('AssistantPage breadcrumb returns to default task home', (
WidgetTester tester,
) async {
final controller = await createTestController(tester);
await pumpPage(
tester,
child: AssistantPage(controller: controller, onOpenDetail: (_) {}),
);
await tester.tap(find.byKey(const Key('assistant-new-task-button')));
await tester.pumpAndSettle();
expect(find.text('新对话'), findsWidgets);
await tester.tap(find.byKey(const ValueKey<String>('workspace-breadcrumb-0')));
await tester.pumpAndSettle();
final titleAfter = tester.widget<Text>(
find.byKey(const Key('assistant-conversation-title')),
);
expect(titleAfter.data, '默认任务');
expect(controller.currentSessionKey, 'main');
});
}

View File

@ -1,3 +1,4 @@
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:xworkmate/features/tasks/tasks_page.dart';
import 'package:xworkmate/models/app_models.dart';
@ -40,4 +41,22 @@ void main() {
expect(find.text('这些项目来自 Gateway cron 调度器,本页当前仅支持只读展示。'), findsOneWidget);
expect(find.text('新建任务'), findsNothing);
});
testWidgets('TasksPage breadcrumb routes back to assistant home', (
WidgetTester tester,
) async {
final controller = await createTestController(tester);
controller.navigateTo(WorkspaceDestination.tasks);
await pumpPage(
tester,
child: TasksPage(controller: controller, onOpenDetail: (_) {}),
);
await tester.tap(find.byKey(const ValueKey<String>('workspace-breadcrumb-0')));
await tester.pumpAndSettle();
expect(controller.destination, WorkspaceDestination.assistant);
expect(controller.currentSessionKey, 'main');
});
}

View File

@ -1,46 +0,0 @@
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:xworkmate/models/app_models.dart';
import 'package:xworkmate/widgets/assistant_focus_panel.dart';
import '../test_support.dart';
void main() {
testWidgets(
'AssistantFocusPanel renders focused and available destinations',
(WidgetTester tester) async {
final controller = await createTestController(tester);
await controller.saveSettings(
controller.settings.copyWith(
assistantNavigationDestinations: const <WorkspaceDestination>[
WorkspaceDestination.tasks,
],
),
refreshAfterSave: false,
);
await pumpPage(
tester,
child: AssistantFocusPanel(controller: controller),
);
expect(
find.byKey(const Key('assistant-focus-panel-title')),
findsOneWidget,
);
expect(
find.byKey(const ValueKey<String>('assistant-focus-item-tasks')),
findsOneWidget,
);
expect(
find.byKey(const ValueKey<String>('assistant-focus-add-aiGateway')),
findsOneWidget,
);
expect(
find.byKey(const ValueKey<String>('assistant-focus-remove-tasks')),
findsOneWidget,
);
},
);
}