feat: add breadcrumbs and sidebar previews
This commit is contained in:
parent
a5762e1440
commit
debe3be6da
@ -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,
|
||||
|
||||
@ -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(
|
||||
'用户身份、工作区切换与登录会话。',
|
||||
|
||||
@ -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 代理与模型网关配置管理中心。',
|
||||
|
||||
@ -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'),
|
||||
|
||||
@ -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(
|
||||
|
||||
@ -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 服务器连接与工具配置。',
|
||||
|
||||
@ -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、代理、节点、技能和平台服务。',
|
||||
|
||||
@ -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(
|
||||
'管理密钥提供方、凭证和模块间的安全引用。',
|
||||
|
||||
@ -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 工作区、网关默认项、界面与诊断选项',
|
||||
|
||||
@ -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(
|
||||
'管理已安装的技能包,查看技能状态与依赖。',
|
||||
|
||||
@ -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
@ -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,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@ -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');
|
||||
});
|
||||
}
|
||||
|
||||
@ -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');
|
||||
});
|
||||
}
|
||||
|
||||
@ -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,
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
Loading…
Reference in New Issue
Block a user