diff --git a/lib/app/app_controller.dart b/lib/app/app_controller.dart index a726698d..220627cc 100644 --- a/lib/app/app_controller.dart +++ b/lib/app/app_controller.dart @@ -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, diff --git a/lib/features/account/account_page.dart b/lib/features/account/account_page.dart index eda89d0c..497da143 100644 --- a/lib/features/account/account_page.dart +++ b/lib/features/account/account_page.dart @@ -33,6 +33,15 @@ class _AccountPageState extends State { 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( '用户身份、工作区切换与登录会话。', diff --git a/lib/features/ai_gateway/ai_gateway_page.dart b/lib/features/ai_gateway/ai_gateway_page.dart index 60b2bcb7..6b8d791b 100644 --- a/lib/features/ai_gateway/ai_gateway_page.dart +++ b/lib/features/ai_gateway/ai_gateway_page.dart @@ -67,6 +67,15 @@ class _AiGatewayPageState extends State { 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 代理与模型网关配置管理中心。', diff --git a/lib/features/assistant/assistant_page.dart b/lib/features/assistant/assistant_page.dart index 5997ff11..cf51481f 100644 --- a/lib/features/assistant/assistant_page.dart +++ b/lib/features/assistant/assistant_page.dart @@ -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 { ), 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'), diff --git a/lib/features/claw_hub/claw_hub_page.dart b/lib/features/claw_hub/claw_hub_page.dart index 26960a35..13b5b764 100644 --- a/lib/features/claw_hub/claw_hub_page.dart +++ b/lib/features/claw_hub/claw_hub_page.dart @@ -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 { 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 { 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 { 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( diff --git a/lib/features/mcp_server/mcp_server_page.dart b/lib/features/mcp_server/mcp_server_page.dart index b41d5ab6..85dc79f9 100644 --- a/lib/features/mcp_server/mcp_server_page.dart +++ b/lib/features/mcp_server/mcp_server_page.dart @@ -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 服务器连接与工具配置。', diff --git a/lib/features/modules/modules_page.dart b/lib/features/modules/modules_page.dart index 859bcc33..ab6c682c 100644 --- a/lib/features/modules/modules_page.dart +++ b/lib/features/modules/modules_page.dart @@ -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、代理、节点、技能和平台服务。', diff --git a/lib/features/secrets/secrets_page.dart b/lib/features/secrets/secrets_page.dart index ae16c6f2..32d39e60 100644 --- a/lib/features/secrets/secrets_page.dart +++ b/lib/features/secrets/secrets_page.dart @@ -40,6 +40,15 @@ class _SecretsPageState extends State { 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( '管理密钥提供方、凭证和模块间的安全引用。', diff --git a/lib/features/settings/settings_page.dart b/lib/features/settings/settings_page.dart index 330b31fa..fee94ff5 100644 --- a/lib/features/settings/settings_page.dart +++ b/lib/features/settings/settings_page.dart @@ -75,6 +75,15 @@ class _SettingsPageState extends State { 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 工作区、网关默认项、界面与诊断选项', diff --git a/lib/features/skills/skills_page.dart b/lib/features/skills/skills_page.dart index 26fb3c06..ac33889e 100644 --- a/lib/features/skills/skills_page.dart +++ b/lib/features/skills/skills_page.dart @@ -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( '管理已安装的技能包,查看技能状态与依赖。', diff --git a/lib/features/tasks/tasks_page.dart b/lib/features/tasks/tasks_page.dart index 034e9a2f..25f8f8c3 100644 --- a/lib/features/tasks/tasks_page.dart +++ b/lib/features/tasks/tasks_page.dart @@ -72,6 +72,15 @@ class _TasksPageState extends State { 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( '查看任务队列、执行状态与历史记录', diff --git a/lib/widgets/assistant_focus_panel.dart b/lib/widgets/assistant_focus_panel.dart index 0d6b2437..418141c8 100644 --- a/lib/widgets/assistant_focus_panel.dart +++ b/lib/widgets/assistant_focus_panel.dart @@ -3,22 +3,49 @@ import 'package:flutter/material.dart'; import '../app/app_controller.dart'; import '../i18n/app_language.dart'; import '../models/app_models.dart'; +import '../runtime/runtime_models.dart'; import '../theme/app_palette.dart'; import 'surface_card.dart'; -class AssistantFocusPanel extends StatelessWidget { +class AssistantFocusPanel extends StatefulWidget { const AssistantFocusPanel({super.key, required this.controller}); final AppController controller; + @override + State createState() => _AssistantFocusPanelState(); +} + +class _AssistantFocusPanelState extends State { + WorkspaceDestination? _selectedDestination; + + @override + void initState() { + super.initState(); + _selectedDestination = _normalizeSelection( + widget.controller.assistantNavigationDestinations, + _selectedDestination, + ); + } + + @override + void didUpdateWidget(covariant AssistantFocusPanel oldWidget) { + super.didUpdateWidget(oldWidget); + _selectedDestination = _normalizeSelection( + widget.controller.assistantNavigationDestinations, + _selectedDestination, + ); + } + @override Widget build(BuildContext context) { final theme = Theme.of(context); final palette = context.palette; - final favorites = controller.assistantNavigationDestinations; + final favorites = widget.controller.assistantNavigationDestinations; final available = kAssistantNavigationDestinationCandidates .where((item) => !favorites.contains(item)) .toList(growable: false); + final selected = _normalizeSelection(favorites, _selectedDestination); return SurfaceCard( borderRadius: 16, @@ -26,104 +53,308 @@ class AssistantFocusPanel extends StatelessWidget { child: Column( children: [ Padding( - padding: const EdgeInsets.fromLTRB(14, 14, 14, 10), - child: Column( + padding: const EdgeInsets.fromLTRB(14, 14, 10, 10), + child: Row( crossAxisAlignment: CrossAxisAlignment.start, children: [ - Text( - appText('关注入口', 'Focused navigation'), - key: const Key('assistant-focus-panel-title'), - style: theme.textTheme.titleSmall?.copyWith( - fontWeight: FontWeight.w700, + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + appText('关注入口', 'Focused navigation'), + key: const Key('assistant-focus-panel-title'), + style: theme.textTheme.titleSmall?.copyWith( + fontWeight: FontWeight.w700, + ), + ), + const SizedBox(height: 6), + Text( + appText( + '像侧边工作区一样切换常用入口。点 tab 只切左侧预览,不会打断当前主页面。', + 'Switch frequent destinations like a side workspace. Tabs update the left preview without replacing the main page.', + ), + style: theme.textTheme.bodySmall?.copyWith( + color: palette.textSecondary, + height: 1.35, + ), + ), + ], ), ), - const SizedBox(height: 6), - Text( - appText( - '把常看的功能菜单放到这里。左侧菜单点亮星标,也会加入这个关注面板。', - 'Pin the destinations you care about here. Starred menu items also appear in this focused panel.', + if (available.isNotEmpty) + PopupMenuButton( + key: const Key('assistant-focus-add-menu'), + tooltip: appText('添加关注入口', 'Add focused destination'), + onSelected: _addFavorite, + itemBuilder: (context) => available + .map( + (destination) => PopupMenuItem( + value: destination, + child: Row( + children: [ + Icon(destination.icon, size: 18), + const SizedBox(width: 10), + Expanded(child: Text(destination.label)), + ], + ), + ), + ) + .toList(growable: false), + child: Container( + width: 38, + height: 38, + decoration: BoxDecoration( + color: palette.surfaceSecondary, + borderRadius: BorderRadius.circular(12), + border: Border.all(color: palette.strokeSoft), + ), + child: Icon( + Icons.add_rounded, + size: 18, + color: palette.textSecondary, + ), + ), ), - style: theme.textTheme.bodySmall?.copyWith( - color: palette.textSecondary, - height: 1.35, + ], + ), + ), + Divider(height: 1, color: palette.strokeSoft), + Expanded( + child: favorites.isEmpty + ? _AssistantFocusEmptyState( + message: appText( + '还没有关注入口。给左侧菜单点星标,或从右上角添加一个常用入口。', + 'No focused entries yet. Star a menu item on the left or add a frequent destination from the top-right menu.', + ), + available: available, + onAdd: _addFavorite, + ) + : Row( + children: [ + Container( + width: 56, + margin: const EdgeInsets.fromLTRB(10, 12, 0, 10), + decoration: BoxDecoration( + color: palette.surfaceSecondary, + borderRadius: BorderRadius.circular(14), + border: Border.all(color: palette.strokeSoft), + ), + child: ListView( + padding: const EdgeInsets.symmetric(vertical: 8), + children: favorites + .map( + (destination) => Padding( + padding: const EdgeInsets.only(bottom: 6), + child: _AssistantFocusRailButton( + destination: destination, + selected: destination == selected, + onTap: () { + setState(() { + _selectedDestination = destination; + }); + }, + ), + ), + ) + .toList(growable: false), + ), + ), + VerticalDivider( + width: 20, + thickness: 1, + color: palette.strokeSoft, + ), + Expanded( + child: Padding( + padding: const EdgeInsets.fromLTRB(0, 12, 10, 10), + child: _AssistantFocusWorkbench( + controller: widget.controller, + destination: selected!, + onOpenPage: () => + widget.controller.navigateTo(selected), + onRemoveFavorite: () => _removeFavorite(selected), + ), + ), + ), + ], ), + ), + ], + ), + ); + } + + WorkspaceDestination? _normalizeSelection( + List favorites, + WorkspaceDestination? current, + ) { + if (favorites.isEmpty) { + return null; + } + if (current != null && favorites.contains(current)) { + return current; + } + return favorites.first; + } + + Future _addFavorite(WorkspaceDestination destination) async { + await widget.controller.toggleAssistantNavigationDestination(destination); + if (!mounted) { + return; + } + setState(() { + _selectedDestination = destination; + }); + } + + Future _removeFavorite(WorkspaceDestination destination) async { + await widget.controller.toggleAssistantNavigationDestination(destination); + if (!mounted) { + return; + } + setState(() { + final favorites = widget.controller.assistantNavigationDestinations; + _selectedDestination = _normalizeSelection(favorites, _selectedDestination); + }); + } +} + +class _AssistantFocusRailButton extends StatelessWidget { + const _AssistantFocusRailButton({ + required this.destination, + required this.selected, + required this.onTap, + }); + + final WorkspaceDestination destination; + final bool selected; + final VoidCallback onTap; + + @override + Widget build(BuildContext context) { + final palette = context.palette; + + return Tooltip( + message: destination.label, + child: InkWell( + key: ValueKey('assistant-focus-tab-${destination.name}'), + borderRadius: BorderRadius.circular(14), + onTap: onTap, + child: Container( + width: 40, + height: 40, + margin: const EdgeInsets.symmetric(horizontal: 8), + decoration: BoxDecoration( + color: selected ? palette.accentMuted : Colors.transparent, + borderRadius: BorderRadius.circular(14), + ), + child: Icon( + destination.icon, + size: 20, + color: selected ? palette.accent : palette.textSecondary, + ), + ), + ), + ); + } +} + +class _AssistantFocusWorkbench extends StatelessWidget { + const _AssistantFocusWorkbench({ + required this.controller, + required this.destination, + required this.onOpenPage, + required this.onRemoveFavorite, + }); + + final AppController controller; + final WorkspaceDestination destination; + final VoidCallback onOpenPage; + final Future Function() onRemoveFavorite; + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + final palette = context.palette; + + return Container( + decoration: BoxDecoration( + color: palette.surfacePrimary, + borderRadius: BorderRadius.circular(16), + border: Border.all(color: palette.strokeSoft), + ), + child: Column( + children: [ + Padding( + padding: const EdgeInsets.fromLTRB(14, 14, 10, 10), + child: Row( + children: [ + Container( + width: 36, + height: 36, + decoration: BoxDecoration( + color: palette.surfaceSecondary, + borderRadius: BorderRadius.circular(12), + ), + child: Icon( + destination.icon, + size: 18, + color: palette.accent, + ), + ), + const SizedBox(width: 10), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + destination.label, + key: const Key('assistant-focus-active-title'), + style: theme.textTheme.titleSmall?.copyWith( + fontWeight: FontWeight.w700, + ), + ), + const SizedBox(height: 3), + Text( + destination.description, + maxLines: 2, + overflow: TextOverflow.ellipsis, + style: theme.textTheme.bodySmall?.copyWith( + color: palette.textSecondary, + height: 1.3, + ), + ), + ], + ), + ), + IconButton( + key: const Key('assistant-focus-open-page'), + tooltip: appText('打开全页', 'Open full page'), + onPressed: onOpenPage, + icon: const Icon(Icons.open_in_new_rounded, size: 18), + ), + IconButton( + key: ValueKey( + 'assistant-focus-remove-${destination.name}', + ), + tooltip: appText('取消关注', 'Remove from focused panel'), + onPressed: () async { + await onRemoveFavorite(); + }, + icon: Icon(Icons.star_rounded, color: palette.accent), ), ], ), ), Divider(height: 1, color: palette.strokeSoft), Expanded( - child: ListView( - padding: const EdgeInsets.fromLTRB(10, 12, 10, 10), - children: [ - Text( - appText('已关注', 'Following'), - style: theme.textTheme.labelLarge?.copyWith( - color: palette.textMuted, - ), - ), - const SizedBox(height: 8), - if (favorites.isEmpty) - _AssistantFocusEmptyState( - message: appText( - '还没有关注入口。给左侧菜单点星标,或从下面添加。', - 'No focused entries yet. Star a menu item on the left or add one below.', - ), - ) - else - ...favorites.map( - (destination) => Padding( - padding: const EdgeInsets.only(bottom: 8), - child: _AssistantFocusTile( - destination: destination, - selected: controller.destination == destination, - onOpen: () => controller.navigateTo(destination), - onToggleFavorite: () async { - await controller.toggleAssistantNavigationDestination( - destination, - ); - }, - ), - ), - ), - const SizedBox(height: 10), - Text( - appText('添加入口', 'Add destinations'), - style: theme.textTheme.labelLarge?.copyWith( - color: palette.textMuted, - ), - ), - const SizedBox(height: 8), - if (available.isEmpty) - _AssistantFocusEmptyState( - message: appText( - '候选菜单都已经加入关注入口了。', - 'All available destinations are already pinned.', - ), - ) - else - Wrap( - spacing: 8, - runSpacing: 8, - children: available - .map( - (destination) => ActionChip( - key: ValueKey( - 'assistant-focus-add-${destination.name}', - ), - avatar: Icon(destination.icon, size: 16), - label: Text(destination.label), - onPressed: () async { - await controller - .toggleAssistantNavigationDestination( - destination, - ); - }, - ), - ) - .toList(growable: false), - ), - ], + child: SingleChildScrollView( + padding: const EdgeInsets.fromLTRB(14, 14, 14, 14), + child: _AssistantFocusPreview( + controller: controller, + destination: destination, + ), ), ), ], @@ -132,102 +363,495 @@ class AssistantFocusPanel extends StatelessWidget { } } -class _AssistantFocusTile extends StatelessWidget { - const _AssistantFocusTile({ +class _AssistantFocusPreview extends StatelessWidget { + const _AssistantFocusPreview({ + required this.controller, required this.destination, - required this.selected, - required this.onOpen, - required this.onToggleFavorite, }); + final AppController controller; final WorkspaceDestination destination; - final bool selected; - final VoidCallback onOpen; - final Future Function() onToggleFavorite; @override Widget build(BuildContext context) { - final theme = Theme.of(context); - final palette = context.palette; + return switch (destination) { + WorkspaceDestination.tasks => _TasksFocusPreview(controller: controller), + WorkspaceDestination.skills => _SkillsFocusPreview(controller: controller), + WorkspaceDestination.nodes => _NodesFocusPreview(controller: controller), + WorkspaceDestination.agents => _AgentsFocusPreview(controller: controller), + WorkspaceDestination.mcpServer => _McpFocusPreview(controller: controller), + WorkspaceDestination.clawHub => _ClawHubFocusPreview( + controller: controller, + ), + WorkspaceDestination.secrets => _SecretsFocusPreview( + controller: controller, + ), + WorkspaceDestination.aiGateway => _AiGatewayFocusPreview( + controller: controller, + ), + WorkspaceDestination.settings => _SettingsFocusPreview( + controller: controller, + ), + _ => const SizedBox.shrink(), + }; + } +} - return Material( - color: selected - ? palette.accentMuted.withValues(alpha: 0.5) - : Colors.transparent, - borderRadius: BorderRadius.circular(14), - child: InkWell( - key: ValueKey('assistant-focus-item-${destination.name}'), - borderRadius: BorderRadius.circular(14), - onTap: onOpen, - child: Container( - padding: const EdgeInsets.fromLTRB(12, 12, 10, 12), - decoration: BoxDecoration( - borderRadius: BorderRadius.circular(14), - border: Border.all( - color: selected ? palette.accent : palette.strokeSoft, +class _TasksFocusPreview extends StatelessWidget { + const _TasksFocusPreview({required this.controller}); + + final AppController controller; + + @override + Widget build(BuildContext context) { + final items = [ + ...controller.tasksController.running.take(2), + ...controller.tasksController.queue.take(2), + ...controller.tasksController.history.take(1), + ].take(4).toList(growable: false); + + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Wrap( + spacing: 8, + runSpacing: 8, + children: [ + _FocusPill( + label: appText( + '运行中 ${controller.tasksController.running.length}', + 'Running ${controller.tasksController.running.length}', + ), + ), + _FocusPill( + label: appText( + '队列 ${controller.tasksController.queue.length}', + 'Queue ${controller.tasksController.queue.length}', + ), + ), + _FocusPill( + label: appText( + '计划 ${controller.tasksController.scheduled.length}', + 'Scheduled ${controller.tasksController.scheduled.length}', + ), + ), + ], + ), + const SizedBox(height: 12), + if (items.isEmpty) + _PreviewEmptyState( + message: controller.connection.status == + RuntimeConnectionStatus.connected + ? appText('当前没有任务摘要。', 'No task summary yet.') + : appText('连接 Gateway 后这里会显示任务摘要。', 'Connect a gateway to load task summaries.'), + ) + else + ...items.map( + (item) => Padding( + padding: const EdgeInsets.only(bottom: 8), + child: _FocusListTile( + title: item.title, + subtitle: item.summary, + trailing: item.status, + ), ), ), - child: Row( - children: [ - Container( - width: 36, - height: 36, - decoration: BoxDecoration( - color: palette.surfaceSecondary, - borderRadius: BorderRadius.circular(12), - ), - child: Icon( - destination.icon, - size: 18, - color: selected ? palette.accent : palette.textSecondary, - ), + ], + ); + } +} + +class _SkillsFocusPreview extends StatelessWidget { + const _SkillsFocusPreview({required this.controller}); + + final AppController controller; + + @override + Widget build(BuildContext context) { + final items = controller.skills.take(4).toList(growable: false); + if (items.isEmpty) { + return _PreviewEmptyState( + message: controller.connection.status == RuntimeConnectionStatus.connected + ? appText('当前代理没有已加载技能。', 'No skills are loaded for the active agent.') + : appText('连接 Gateway 后可查看技能摘要。', 'Connect a gateway to inspect skills here.'), + ); + } + return Column( + children: items + .map( + (skill) => Padding( + padding: const EdgeInsets.only(bottom: 8), + child: _FocusListTile( + title: skill.name, + subtitle: skill.description, + trailing: skill.disabled + ? appText('已禁用', 'Disabled') + : appText('已启用', 'Enabled'), ), - const SizedBox(width: 10), - Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - destination.label, - maxLines: 1, - overflow: TextOverflow.ellipsis, - style: theme.textTheme.titleSmall?.copyWith( - fontWeight: FontWeight.w600, - ), - ), - const SizedBox(height: 3), - Text( - destination.description, - maxLines: 2, - overflow: TextOverflow.ellipsis, - style: theme.textTheme.bodySmall?.copyWith( - color: palette.textSecondary, - height: 1.25, - ), - ), - ], - ), + ), + ) + .toList(growable: false), + ); + } +} + +class _NodesFocusPreview extends StatelessWidget { + const _NodesFocusPreview({required this.controller}); + + final AppController controller; + + @override + Widget build(BuildContext context) { + final items = controller.instances.take(4).toList(growable: false); + if (items.isEmpty) { + return _PreviewEmptyState( + message: appText('当前没有节点可显示。', 'No nodes are available right now.'), + ); + } + return Column( + children: items + .map( + (instance) => Padding( + padding: const EdgeInsets.only(bottom: 8), + child: _FocusListTile( + title: instance.host?.trim().isNotEmpty == true + ? instance.host! + : instance.id, + subtitle: [ + instance.platform, + instance.deviceFamily, + instance.ip, + ].whereType().where((item) => item.trim().isNotEmpty).join(' · '), + trailing: instance.mode ?? appText('未知', 'Unknown'), ), - IconButton( - key: ValueKey( - 'assistant-focus-remove-${destination.name}', - ), - tooltip: appText('取消关注', 'Remove from focused panel'), - onPressed: () async { - await onToggleFavorite(); - }, - icon: Icon(Icons.star_rounded, color: palette.accent), + ), + ) + .toList(growable: false), + ); + } +} + +class _AgentsFocusPreview extends StatelessWidget { + const _AgentsFocusPreview({required this.controller}); + + final AppController controller; + + @override + Widget build(BuildContext context) { + final items = controller.agents.take(5).toList(growable: false); + if (items.isEmpty) { + return _PreviewEmptyState( + message: appText('当前没有代理摘要。', 'No agents are available right now.'), + ); + } + return Column( + children: items + .map( + (agent) => Padding( + padding: const EdgeInsets.only(bottom: 8), + child: _FocusListTile( + title: '${agent.emoji} ${agent.name}', + subtitle: agent.id, + trailing: agent.name == controller.activeAgentName + ? appText('当前', 'Active') + : agent.theme, ), - ], + ), + ) + .toList(growable: false), + ); + } +} + +class _McpFocusPreview extends StatelessWidget { + const _McpFocusPreview({required this.controller}); + + final AppController controller; + + @override + Widget build(BuildContext context) { + final items = controller.connectors.take(4).toList(growable: false); + if (items.isEmpty) { + return _PreviewEmptyState( + message: appText( + '当前没有 MCP 连接器。连接 Gateway 后这里会显示工具摘要。', + 'No MCP connectors yet. Connect a gateway to load tool summaries here.', + ), + ); + } + return Column( + children: items + .map( + (connector) => Padding( + padding: const EdgeInsets.only(bottom: 8), + child: _FocusListTile( + title: connector.label, + subtitle: connector.detailLabel, + trailing: connector.status, + ), + ), + ) + .toList(growable: false), + ); + } +} + +class _ClawHubFocusPreview extends StatelessWidget { + const _ClawHubFocusPreview({required this.controller}); + + final AppController controller; + + @override + Widget build(BuildContext context) { + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Wrap( + spacing: 8, + runSpacing: 8, + children: [ + _FocusPill( + label: appText( + '已加载技能 ${controller.skills.length}', + 'Loaded skills ${controller.skills.length}', + ), + ), + _FocusPill( + label: appText( + '关注入口 ${controller.assistantNavigationDestinations.length}', + 'Pinned ${controller.assistantNavigationDestinations.length}', + ), + ), + ], + ), + const SizedBox(height: 12), + _PreviewEmptyState( + message: appText( + 'ClawHub 适合放在侧板做快速搜索或安装入口;需要完整终端交互时,再打开全页。', + 'Use ClawHub in the side panel for quick access. Open the full page when you need the terminal workflow.', ), ), + ], + ); + } +} + +class _SecretsFocusPreview extends StatelessWidget { + const _SecretsFocusPreview({required this.controller}); + + final AppController controller; + + @override + Widget build(BuildContext context) { + final items = controller.secretReferences.take(4).toList(growable: false); + if (items.isEmpty) { + return _PreviewEmptyState( + message: appText( + '当前没有密钥引用摘要。', + 'No masked secret references are available yet.', + ), + ); + } + return Column( + children: items + .map( + (secret) => Padding( + padding: const EdgeInsets.only(bottom: 8), + child: _FocusListTile( + title: secret.name, + subtitle: '${secret.provider} · ${secret.module}', + trailing: secret.status, + ), + ), + ) + .toList(growable: false), + ); + } +} + +class _AiGatewayFocusPreview extends StatelessWidget { + const _AiGatewayFocusPreview({required this.controller}); + + final AppController controller; + + @override + Widget build(BuildContext context) { + final items = controller.models.take(4).toList(growable: false); + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Wrap( + spacing: 8, + runSpacing: 8, + children: [ + _FocusPill(label: controller.connection.status.label), + _FocusPill( + label: appText( + '模型 ${controller.models.length}', + 'Models ${controller.models.length}', + ), + ), + ], + ), + const SizedBox(height: 12), + if (items.isEmpty) + _PreviewEmptyState( + message: appText( + '当前没有 AI Gateway 模型摘要。', + 'No AI Gateway model summary is available yet.', + ), + ) + else + ...items.map( + (model) => Padding( + padding: const EdgeInsets.only(bottom: 8), + child: _FocusListTile( + title: model.name, + subtitle: model.provider, + trailing: model.id, + ), + ), + ), + ], + ); + } +} + +class _SettingsFocusPreview extends StatelessWidget { + const _SettingsFocusPreview({required this.controller}); + + final AppController controller; + + @override + Widget build(BuildContext context) { + final languageLabel = controller.appLanguage == AppLanguage.zh + ? appText('中文', 'Chinese') + : 'English'; + final themeLabel = switch (controller.themeMode) { + ThemeMode.dark => appText('深色', 'Dark'), + ThemeMode.light => appText('浅色', 'Light'), + ThemeMode.system => appText('跟随系统', 'System'), + }; + + return Column( + children: [ + _FocusListTile( + title: appText('语言', 'Language'), + subtitle: appText('当前界面语言', 'Current interface language'), + trailing: languageLabel, + ), + const SizedBox(height: 8), + _FocusListTile( + title: appText('主题', 'Theme'), + subtitle: appText('当前显示模式', 'Current display mode'), + trailing: themeLabel, + ), + const SizedBox(height: 8), + _FocusListTile( + title: appText('执行目标', 'Execution target'), + subtitle: appText('Assistant 默认运行位置', 'Default assistant execution target'), + trailing: controller.assistantExecutionTarget.label, + ), + const SizedBox(height: 8), + _FocusListTile( + title: appText('权限', 'Permissions'), + subtitle: appText('Assistant 默认权限级别', 'Default assistant permission level'), + trailing: controller.assistantPermissionLevel.label, + ), + ], + ); + } +} + +class _FocusListTile extends StatelessWidget { + const _FocusListTile({ + required this.title, + required this.subtitle, + required this.trailing, + }); + + final String title; + final String subtitle; + final String trailing; + + @override + Widget build(BuildContext context) { + final palette = context.palette; + final theme = Theme.of(context); + + return Container( + width: double.infinity, + padding: const EdgeInsets.fromLTRB(12, 12, 12, 12), + decoration: BoxDecoration( + color: palette.surfaceSecondary, + borderRadius: BorderRadius.circular(14), + border: Border.all(color: palette.strokeSoft), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + title, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: theme.textTheme.titleSmall?.copyWith( + fontWeight: FontWeight.w600, + ), + ), + const SizedBox(height: 4), + Text( + subtitle, + maxLines: 2, + overflow: TextOverflow.ellipsis, + style: theme.textTheme.bodySmall?.copyWith( + color: palette.textSecondary, + height: 1.3, + ), + ), + const SizedBox(height: 8), + Text( + trailing, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: theme.textTheme.labelLarge?.copyWith( + color: palette.textPrimary, + ), + ), + ], + ), + ); + } +} + +class _FocusPill extends StatelessWidget { + const _FocusPill({required this.label}); + + final String label; + + @override + Widget build(BuildContext context) { + final palette = context.palette; + final theme = Theme.of(context); + + return Container( + padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 6), + decoration: BoxDecoration( + color: palette.surfaceSecondary, + borderRadius: BorderRadius.circular(999), + border: Border.all(color: palette.strokeSoft), + ), + child: Text( + label, + style: theme.textTheme.labelLarge?.copyWith( + color: palette.textSecondary, + ), ), ); } } -class _AssistantFocusEmptyState extends StatelessWidget { - const _AssistantFocusEmptyState({required this.message}); +class _PreviewEmptyState extends StatelessWidget { + const _PreviewEmptyState({required this.message}); final String message; @@ -238,7 +862,7 @@ class _AssistantFocusEmptyState extends StatelessWidget { return Container( width: double.infinity, - padding: const EdgeInsets.all(12), + padding: const EdgeInsets.all(14), decoration: BoxDecoration( color: palette.surfaceSecondary, borderRadius: BorderRadius.circular(14), @@ -254,3 +878,64 @@ class _AssistantFocusEmptyState extends StatelessWidget { ); } } + +class _AssistantFocusEmptyState extends StatelessWidget { + const _AssistantFocusEmptyState({ + required this.message, + required this.available, + required this.onAdd, + }); + + final String message; + final List available; + final Future Function(WorkspaceDestination destination) onAdd; + + @override + Widget build(BuildContext context) { + final palette = context.palette; + final theme = Theme.of(context); + + return ListView( + padding: const EdgeInsets.fromLTRB(12, 12, 12, 12), + children: [ + Container( + width: double.infinity, + padding: const EdgeInsets.all(14), + decoration: BoxDecoration( + color: palette.surfaceSecondary, + borderRadius: BorderRadius.circular(14), + border: Border.all(color: palette.strokeSoft), + ), + child: Text( + message, + style: theme.textTheme.bodySmall?.copyWith( + color: palette.textSecondary, + height: 1.35, + ), + ), + ), + if (available.isNotEmpty) ...[ + const SizedBox(height: 12), + Wrap( + spacing: 8, + runSpacing: 8, + children: available + .map( + (destination) => ActionChip( + key: ValueKey( + 'assistant-focus-add-${destination.name}', + ), + avatar: Icon(destination.icon, size: 16), + label: Text(destination.label), + onPressed: () async { + await onAdd(destination); + }, + ), + ) + .toList(growable: false), + ), + ], + ], + ); + } +} diff --git a/lib/widgets/top_bar.dart b/lib/widgets/top_bar.dart index cfba13c7..b553d4e5 100644 --- a/lib/widgets/top_bar.dart +++ b/lib/widgets/top_bar.dart @@ -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 [], this.trailing, }); final String title; final String subtitle; + final List 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 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('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, + ); + } +} diff --git a/test/features/assistant_page_test.dart b/test/features/assistant_page_test.dart index bda7c364..f06bdf5a 100644 --- a/test/features/assistant_page_test.dart +++ b/test/features/assistant_page_test.dart @@ -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('workspace-breadcrumb-0'))); + await tester.pumpAndSettle(); + + final titleAfter = tester.widget( + find.byKey(const Key('assistant-conversation-title')), + ); + expect(titleAfter.data, '默认任务'); + expect(controller.currentSessionKey, 'main'); + }); } diff --git a/test/features/tasks_page_test.dart b/test/features/tasks_page_test.dart index 6f09126e..e749d10a 100644 --- a/test/features/tasks_page_test.dart +++ b/test/features/tasks_page_test.dart @@ -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('workspace-breadcrumb-0'))); + await tester.pumpAndSettle(); + + expect(controller.destination, WorkspaceDestination.assistant); + expect(controller.currentSessionKey, 'main'); + }); } diff --git a/test/widgets/assistant_focus_panel_test.dart b/test/widgets/assistant_focus_panel_test.dart deleted file mode 100644 index b184e59d..00000000 --- a/test/widgets/assistant_focus_panel_test.dart +++ /dev/null @@ -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.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('assistant-focus-item-tasks')), - findsOneWidget, - ); - - expect( - find.byKey(const ValueKey('assistant-focus-add-aiGateway')), - findsOneWidget, - ); - expect( - find.byKey(const ValueKey('assistant-focus-remove-tasks')), - findsOneWidget, - ); - }, - ); -}