From be63d69699195864e02e98d9aa3b854f36c56d4b Mon Sep 17 00:00:00 2001 From: Haitao Pan Date: Wed, 18 Mar 2026 12:38:42 +0800 Subject: [PATCH] Refresh desktop workspace shell --- .../desktop_navigation_flow_test.dart | 17 +- .../desktop_settings_flow_test.dart | 21 +- lib/app/app_controller.dart | 23 +- lib/app/app_shell.dart | 2 + lib/features/assistant/assistant_page.dart | 589 ++++++-------- lib/features/skills/skills_page.dart | 653 +++++++++++---- lib/features/tasks/tasks_page.dart | 763 +++++++++++------- lib/theme/app_theme.dart | 47 +- lib/widgets/desktop_workspace_scaffold.dart | 110 +++ lib/widgets/sidebar_navigation.dart | 40 +- lib/widgets/surface_card.dart | 8 +- test/features/assistant_page_test.dart | 58 +- test/features/skills_page_test.dart | 40 + test/features/tasks_page_test.dart | 18 +- test/widget_test.dart | 3 +- 15 files changed, 1507 insertions(+), 885 deletions(-) create mode 100644 lib/widgets/desktop_workspace_scaffold.dart create mode 100644 test/features/skills_page_test.dart diff --git a/integration_test/desktop_navigation_flow_test.dart b/integration_test/desktop_navigation_flow_test.dart index 54f3b30f..d627284b 100644 --- a/integration_test/desktop_navigation_flow_test.dart +++ b/integration_test/desktop_navigation_flow_test.dart @@ -1,7 +1,14 @@ +import 'package:flutter/material.dart'; import 'package:flutter_test/flutter_test.dart'; import 'test_support.dart'; +Finder _textEither(String zh, String en) { + return find.byWidgetPredicate( + (widget) => widget is Text && (widget.data == zh || widget.data == en), + ); +} + void main() { initializeIntegrationHarness(); @@ -12,10 +19,12 @@ void main() { ) async { await pumpDesktopApp(tester); - expect(find.text('新对话'), findsWidgets); - - await tester.tap(find.text('节点')); + expect(_textEither('新对话', 'New conversation'), findsWidgets); + await tester.tap(find.byKey(const Key('assistant-side-pane-tab-navigation'))); await settleIntegrationUi(tester); - expect(find.text('管理 Gateway、代理、节点、技能和平台服务。'), findsOneWidget); + expect( + find.byKey(const Key('assistant-focus-panel-title')), + findsOneWidget, + ); }); } diff --git a/integration_test/desktop_settings_flow_test.dart b/integration_test/desktop_settings_flow_test.dart index 969330a4..f2c8d9ed 100644 --- a/integration_test/desktop_settings_flow_test.dart +++ b/integration_test/desktop_settings_flow_test.dart @@ -1,7 +1,14 @@ +import 'package:flutter/material.dart'; import 'package:flutter_test/flutter_test.dart'; import 'test_support.dart'; +Finder _textEither(String zh, String en) { + return find.byWidgetPredicate( + (widget) => widget is Text && (widget.data == zh || widget.data == en), + ); +} + void main() { initializeIntegrationHarness(); @@ -12,13 +19,17 @@ void main() { ) async { await pumpDesktopApp(tester); - await tester.tap(find.text('节点')); + await tester.tap(find.byKey(const Key('assistant-side-pane-tab-navigation'))); await settleIntegrationUi(tester); - await tester.tap(find.text('接入模块')); + await tester.tap(find.byKey(const Key('assistant-focus-add-menu'))); await settleIntegrationUi(tester); - - expect(find.textContaining('工作区、网关默认项'), findsOneWidget); - await tester.tap(find.text('集成')); + await tester.tap(_textEither('设置', 'Settings').last); + await settleIntegrationUi(tester); + await tester.tap( + find.byKey(const ValueKey('assistant-focus-open-page-settings')), + ); + await settleIntegrationUi(tester); + await tester.tap(_textEither('集成', 'Integrations')); await settleIntegrationUi(tester); expect(find.text('OpenClaw Gateway'), findsOneWidget); }); diff --git a/lib/app/app_controller.dart b/lib/app/app_controller.dart index 220627cc..eefcedb9 100644 --- a/lib/app/app_controller.dart +++ b/lib/app/app_controller.dart @@ -165,6 +165,25 @@ class AppController extends ChangeNotifier { return (await _store.loadAiGatewayApiKey())?.trim() ?? ''; } + Future openOnlineWorkspace() async { + const url = 'https://www.svc.plus/Xworkmate'; + try { + if (Platform.isMacOS) { + await Process.run('open', [url]); + return; + } + if (Platform.isWindows) { + await Process.run('cmd', ['/c', 'start', '', url]); + return; + } + if (Platform.isLinux) { + await Process.run('xdg-open', [url]); + } + } catch (_) { + // Best effort only. Do not surface a blocking error from a convenience link. + } + } + List get aiGatewayModelChoices { final selected = settings.aiGateway.selectedModels .where(settings.aiGateway.availableModels.contains) @@ -255,8 +274,8 @@ class AppController extends ChangeNotifier { } void navigateHome() { - final mainSessionKey = _runtime.snapshot.mainSessionKey?.trim().isNotEmpty == - true + final mainSessionKey = + _runtime.snapshot.mainSessionKey?.trim().isNotEmpty == true ? _runtime.snapshot.mainSessionKey!.trim() : 'main'; final destinationChanged = _destination != WorkspaceDestination.assistant; diff --git a/lib/app/app_shell.dart b/lib/app/app_shell.dart index 7b0fbdbd..7cf61aa5 100644 --- a/lib/app/app_shell.dart +++ b/lib/app/app_shell.dart @@ -233,6 +233,8 @@ class _AppShellState extends State { .isEmpty ? appText('账号', 'Account') : controller.settings.accountWorkspace, + onOpenOnlineWorkspace: + controller.openOnlineWorkspace, expandedWidthOverride: sidebarState == AppSidebarState.expanded ? expandedSidebarWidth diff --git a/lib/features/assistant/assistant_page.dart b/lib/features/assistant/assistant_page.dart index a469d787..06e4c48e 100644 --- a/lib/features/assistant/assistant_page.dart +++ b/lib/features/assistant/assistant_page.dart @@ -13,9 +13,9 @@ import '../../theme/app_palette.dart'; import '../../theme/app_theme.dart'; import '../../widgets/assistant_focus_panel.dart'; import '../../widgets/gateway_connect_dialog.dart'; +import '../../widgets/desktop_workspace_scaffold.dart'; import '../../widgets/pane_resize_handle.dart'; import '../../widgets/surface_card.dart'; -import '../../widgets/top_bar.dart'; class AssistantPage extends StatefulWidget { const AssistantPage({ @@ -52,11 +52,9 @@ class _AssistantPageState extends State { late final FocusNode _composerFocusNode; String _mode = 'ask'; String _thinkingLabel = 'high'; - double _conversationPaneRatio = 0.7; double _threadRailWidth = 312; String _threadQuery = ''; bool _sidePaneCollapsed = false; - bool _taskRailOverviewExpanded = false; _AssistantSidePane _activeSidePane = _AssistantSidePane.tasks; WorkspaceDestination? _activeFocusedDestination; final Map _taskSeeds = @@ -121,7 +119,7 @@ class _AssistantPageState extends State { ); }); - return Padding( + return DesktopWorkspaceScaffold( padding: const EdgeInsets.fromLTRB(6, 6, 6, 0), child: LayoutBuilder( builder: (context, constraints) { @@ -161,10 +159,7 @@ class _AssistantPageState extends State { : _activeSidePane; final sidePanelContentWidth = (threadRailWidth - _sideTabRailWidth - 6) - .clamp( - _sidePaneContentMinWidth, - threadRailWidth, - ) + .clamp(_sidePaneContentMinWidth, threadRailWidth) .toDouble(); return Row( children: [ @@ -199,24 +194,11 @@ class _AssistantPageState extends State { }, onRefreshTasks: controller.refreshSessions, onCreateTask: _createNewThread, - onOpenTasks: () { - controller.navigateTo(WorkspaceDestination.tasks); - }, - onOpenSkills: () { - controller.navigateTo(WorkspaceDestination.skills); - }, onSelectTask: (sessionKey) async { await controller.switchSession(sessionKey); _focusComposer(); }, onArchiveTask: _archiveTask, - overviewExpanded: _taskRailOverviewExpanded, - onToggleOverview: () { - setState(() { - _taskRailOverviewExpanded = - !_taskRailOverviewExpanded; - }); - }, ), navigationPanel: widget.navigationPanelBuilder!( sidePanelContentWidth, @@ -340,24 +322,11 @@ class _AssistantPageState extends State { }, onRefreshTasks: controller.refreshSessions, onCreateTask: _createNewThread, - onOpenTasks: () { - controller.navigateTo(WorkspaceDestination.tasks); - }, - onOpenSkills: () { - controller.navigateTo(WorkspaceDestination.skills); - }, onSelectTask: (sessionKey) async { await controller.switchSession(sessionKey); _focusComposer(); }, onArchiveTask: _archiveTask, - overviewExpanded: _taskRailOverviewExpanded, - onToggleOverview: () { - setState(() { - _taskRailOverviewExpanded = - !_taskRailOverviewExpanded; - }); - }, ), ), SizedBox( @@ -391,38 +360,11 @@ class _AssistantPageState extends State { }) { return LayoutBuilder( builder: (context, constraints) { - const handleHeight = 10.0; - const paneGap = 6.0; - final availablePaneHeight = - (constraints.maxHeight - handleHeight - paneGap) - .clamp(0.0, double.infinity) - .toDouble(); - var minConversationHeight = availablePaneHeight >= 620 - ? 240.0 - : availablePaneHeight * 0.4; - var minComposerHeight = availablePaneHeight >= 620 - ? 176.0 - : availablePaneHeight * 0.24; - if (minConversationHeight + minComposerHeight > availablePaneHeight) { - minConversationHeight = availablePaneHeight * 0.52; - minComposerHeight = availablePaneHeight - minConversationHeight; - } - final maxConversationHeight = (availablePaneHeight - minComposerHeight) - .clamp(minConversationHeight, availablePaneHeight) - .toDouble(); - final conversationHeight = availablePaneHeight <= 0 - ? 0.0 - : (_conversationPaneRatio * availablePaneHeight) - .clamp(minConversationHeight, maxConversationHeight) - .toDouble(); - final composerHeight = (availablePaneHeight - conversationHeight) - .clamp(minComposerHeight, availablePaneHeight) - .toDouble(); + final composerHeight = constraints.maxHeight >= 900 ? 254.0 : 224.0; return Column( children: [ - SizedBox( - height: conversationHeight, + Expanded( child: _ConversationArea( controller: controller, currentTask: currentTask, @@ -434,25 +376,7 @@ class _AssistantPageState extends State { onReconnectGateway: _connectFromSavedSettingsOrShowDialog, ), ), - SizedBox( - height: handleHeight, - child: PaneResizeHandle( - axis: Axis.vertical, - onDelta: (delta) { - if (availablePaneHeight <= 0) { - return; - } - final nextHeight = (conversationHeight + delta).clamp( - minConversationHeight, - maxConversationHeight, - ); - setState(() { - _conversationPaneRatio = nextHeight / availablePaneHeight; - }); - }, - ), - ), - const SizedBox(height: paneGap), + const SizedBox(height: 6), SizedBox( height: composerHeight, child: _AssistantLowerPane( @@ -482,7 +406,8 @@ class _AssistantPageState extends State { onOpenGateway: _showConnectDialog, onReconnectGateway: _connectFromSavedSettingsOrShowDialog, onPickAttachments: _pickAttachments, - onFocusComposer: _focusComposer, + suggestions: _buildSuggestions(controller), + onSuggestionSelected: _applySuggestion, onSend: _submitPrompt, ), ), @@ -609,6 +534,105 @@ class _AssistantPageState extends State { }); } + void _applySuggestion(_AssistantSuggestion suggestion) { + final current = _inputController.text.trim(); + final next = current.isEmpty + ? suggestion.prompt + : '$current\n${suggestion.prompt}'; + _inputController.value = TextEditingValue( + text: next, + selection: TextSelection.collapsed(offset: next.length), + ); + _focusComposer(); + } + + List<_AssistantSuggestion> _buildSuggestions(AppController controller) { + final skillSuggestions = controller.skills + .where((item) => !item.disabled) + .take(6) + .map(_suggestionFromSkill) + .whereType<_AssistantSuggestion>() + .toList(growable: false); + if (skillSuggestions.isNotEmpty) { + return skillSuggestions; + } + return const [ + _AssistantSuggestion( + label: '幻灯片', + prompt: '帮我整理一份演示文稿的大纲和页面结构。', + icon: Icons.slideshow_outlined, + ), + _AssistantSuggestion( + label: '视频生成', + prompt: '帮我规划一个视频脚本、镜头拆解和生成步骤。', + icon: Icons.video_library_outlined, + ), + _AssistantSuggestion( + label: '深度研究', + prompt: '围绕这个主题先做深度研究,再给我结构化结论。', + icon: Icons.travel_explore_outlined, + ), + _AssistantSuggestion( + label: '自动化', + prompt: '帮我把这个重复流程拆成可执行的自动化任务。', + icon: Icons.auto_mode_outlined, + ), + ]; + } + + _AssistantSuggestion? _suggestionFromSkill(GatewaySkillSummary skill) { + final name = skill.name.trim(); + final lower = '$name ${skill.description}'.toLowerCase(); + if (lower.contains('ppt') || + lower.contains('slide') || + lower.contains('幻灯')) { + return _AssistantSuggestion( + label: appText('幻灯片', 'Slides'), + prompt: '使用 $name 帮我整理一份清晰的演示文稿结构。', + icon: Icons.slideshow_outlined, + ); + } + if (lower.contains('video') || lower.contains('视频')) { + return _AssistantSuggestion( + label: appText('视频生成', 'Video'), + prompt: '使用 $name 帮我规划视频脚本与生成步骤。', + icon: Icons.video_library_outlined, + ); + } + if (lower.contains('research') || + lower.contains('研究') || + lower.contains('paper')) { + return _AssistantSuggestion( + label: appText('深度研究', 'Research'), + prompt: '使用 $name 对这个主题做深度研究并输出结论。', + icon: Icons.travel_explore_outlined, + ); + } + if (lower.contains('browser') || + lower.contains('search') || + lower.contains('crawl')) { + return _AssistantSuggestion( + label: appText('网页处理', 'Web task'), + prompt: '使用 $name 帮我浏览网页并提取关键信息。', + icon: Icons.language_rounded, + ); + } + if (lower.contains('automation') || + lower.contains('workflow') || + lower.contains('自动')) { + return _AssistantSuggestion( + label: appText('自动化', 'Automation'), + prompt: '使用 $name 帮我设计一个自动化流程。', + icon: Icons.auto_mode_outlined, + ); + } + return _AssistantSuggestion( + label: name, + prompt: '使用 $name 处理这个任务:', + icon: Icons.auto_awesome_rounded, + ); + } + Future _submitPrompt() async { final controller = widget.controller; final settings = controller.settings; @@ -1012,10 +1036,9 @@ class _AssistantPageState extends State { double _resolveMaxSidePaneWidth(double viewportWidth) { final maxWidthByViewport = viewportWidth - _mainWorkspaceMinWidth - _sidePaneViewportPadding; - return maxWidthByViewport.clamp( - _sidePaneMinWidth, - viewportWidth - _sidePaneViewportPadding, - ).toDouble(); + return maxWidthByViewport + .clamp(_sidePaneMinWidth, viewportWidth - _sidePaneViewportPadding) + .toDouble(); } } @@ -1048,8 +1071,7 @@ class _AssistantUnifiedSidePane extends StatelessWidget { @override Widget build(BuildContext context) { - final sidePaneContent = - activePane == _AssistantSidePane.tasks + final sidePaneContent = activePane == _AssistantSidePane.tasks ? taskPanel : activePane == _AssistantSidePane.focused && focusedPanel != null ? focusedPanel! @@ -1074,15 +1096,13 @@ class _AssistantUnifiedSidePane extends StatelessWidget { switchInCurve: Curves.easeOutCubic, switchOutCurve: Curves.easeInCubic, child: KeyedSubtree( - key: ValueKey( - switch (activePane) { - _AssistantSidePane.tasks => 'assistant-side-pane-tasks', - _AssistantSidePane.navigation => - 'assistant-side-pane-navigation', - _AssistantSidePane.focused => - 'assistant-side-pane-focused-${activeFocusedDestination?.name ?? 'none'}', - }, - ), + key: ValueKey(switch (activePane) { + _AssistantSidePane.tasks => 'assistant-side-pane-tasks', + _AssistantSidePane.navigation => + 'assistant-side-pane-navigation', + _AssistantSidePane.focused => + 'assistant-side-pane-focused-${activeFocusedDestination?.name ?? 'none'}', + }), child: sidePaneContent, ), ), @@ -1146,11 +1166,7 @@ class _AssistantSideTabRail extends StatelessWidget { ), if (favoriteDestinations.isNotEmpty) ...[ const SizedBox(height: 8), - Container( - width: 24, - height: 1, - color: palette.strokeSoft, - ), + Container(width: 24, height: 1, color: palette.strokeSoft), const SizedBox(height: 8), Expanded( child: SingleChildScrollView( @@ -1262,7 +1278,8 @@ class _AssistantLowerPane extends StatelessWidget { required this.onOpenGateway, required this.onReconnectGateway, required this.onPickAttachments, - required this.onFocusComposer, + required this.suggestions, + required this.onSuggestionSelected, required this.onSend, }); @@ -1282,7 +1299,8 @@ class _AssistantLowerPane extends StatelessWidget { final VoidCallback onOpenGateway; final Future Function() onReconnectGateway; final VoidCallback onPickAttachments; - final VoidCallback onFocusComposer; + final List<_AssistantSuggestion> suggestions; + final ValueChanged<_AssistantSuggestion> onSuggestionSelected; final Future Function() onSend; @override @@ -1308,6 +1326,8 @@ class _AssistantLowerPane extends StatelessWidget { onOpenGateway: onOpenGateway, onReconnectGateway: onReconnectGateway, onPickAttachments: onPickAttachments, + suggestions: suggestions, + onSuggestionSelected: onSuggestionSelected, onSend: onSend, ), ), @@ -1341,49 +1361,29 @@ class _ConversationArea extends StatelessWidget { final palette = context.palette; final theme = Theme.of(context); final statusStyle = _pillStyleForStatus(context, currentTask.status); - final taskHint = - controller.connection.status == RuntimeConnectionStatus.connected - ? appText( - '当前对话会作为任务上下文持续执行,切换左侧任务即可回到对应会话。', - 'This conversation stays attached to the selected task. Pick another task on the left to jump back into it.', - ) - : appText( - '连接 Gateway 后,当前对话会自动作为默认任务开始执行。', - 'After connecting a gateway, this conversation starts as the default task.', - ); return SurfaceCard( - borderRadius: 12, + borderRadius: 0, padding: EdgeInsets.zero, child: Column( children: [ Padding( - padding: const EdgeInsets.fromLTRB(14, 10, 14, 8), + padding: const EdgeInsets.fromLTRB(16, 14, 16, 12), child: Row( + crossAxisAlignment: CrossAxisAlignment.start, children: [ Expanded( 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'), - style: theme.textTheme.titleLarge, + style: theme.textTheme.titleLarge?.copyWith( + fontWeight: FontWeight.w600, + ), ), - const SizedBox(height: 4), - Text(taskHint, style: theme.textTheme.bodySmall), - const SizedBox(height: 10), + const SizedBox(height: 6), Wrap( spacing: 8, runSpacing: 8, @@ -1403,11 +1403,16 @@ class _ConversationArea extends StatelessWidget { label: currentTask.surface, icon: Icons.forum_outlined, ), + _MetaPill( + label: controller.currentSessionKey, + icon: Icons.tag_rounded, + ), ], ), ], ), ), + const SizedBox(width: 12), _ConnectionChip(controller: controller), ], ), @@ -1425,7 +1430,7 @@ class _ConversationArea extends StatelessWidget { ) : ListView.separated( controller: scrollController, - padding: const EdgeInsets.fromLTRB(14, 12, 14, 12), + padding: const EdgeInsets.fromLTRB(16, 14, 16, 14), physics: const BouncingScrollPhysics(), itemCount: items.length, separatorBuilder: (_, _) => const SizedBox(height: 8), @@ -1493,7 +1498,6 @@ class _ConversationArea extends StatelessWidget { }, onOpenTasks: () { controller.navigateTo(WorkspaceDestination.tasks); - onOpenDetail(_buildTaskDetail(item)); }, ), }; @@ -1505,44 +1509,6 @@ class _ConversationArea extends StatelessWidget { ), ); } - - DetailPanelData _buildTaskDetail(_TimelineItem item) { - return DetailPanelData( - title: item.title!, - subtitle: appText('会话任务', 'Conversation Task'), - icon: Icons.task_alt_rounded, - status: _statusInfoForTask(item.status ?? 'completed'), - description: item.summary ?? '', - meta: [ - item.owner ?? appText('自动路由', 'Auto route'), - item.sessionKey ?? controller.currentSessionKey, - ], - actions: [appText('继续', 'Continue'), appText('打开任务', 'Open Tasks')], - sections: [ - DetailSection( - title: appText('执行', 'Execution'), - items: [ - DetailItem( - label: appText('状态', 'Status'), - value: _taskStatusLabel(item.status ?? 'completed'), - ), - DetailItem( - label: appText('代理', 'Agent'), - value: item.owner ?? controller.activeAgentName, - ), - DetailItem( - label: appText('会话', 'Session'), - value: item.sessionKey ?? controller.currentSessionKey, - ), - DetailItem( - label: appText('详情', 'Detail'), - value: item.detail ?? appText('暂无详情', 'No detail'), - ), - ], - ), - ], - ); - } } class _AssistantTaskRail extends StatelessWidget { @@ -1556,12 +1522,8 @@ class _AssistantTaskRail extends StatelessWidget { required this.onClearQuery, required this.onRefreshTasks, required this.onCreateTask, - required this.onOpenTasks, - required this.onOpenSkills, required this.onSelectTask, required this.onArchiveTask, - required this.overviewExpanded, - required this.onToggleOverview, }); final AppController controller; @@ -1572,12 +1534,8 @@ class _AssistantTaskRail extends StatelessWidget { final VoidCallback onClearQuery; final Future Function() onRefreshTasks; final Future Function() onCreateTask; - final VoidCallback onOpenTasks; - final VoidCallback onOpenSkills; final Future Function(String sessionKey) onSelectTask; final Future Function(String sessionKey) onArchiveTask; - final bool overviewExpanded; - final VoidCallback onToggleOverview; @override Widget build(BuildContext context) { @@ -1591,7 +1549,7 @@ class _AssistantTaskRail extends StatelessWidget { .length; return SurfaceCard( - borderRadius: 16, + borderRadius: 0, padding: EdgeInsets.zero, child: Column( children: [ @@ -1644,120 +1602,24 @@ class _AssistantTaskRail extends StatelessWidget { ), ), const SizedBox(height: 12), - AnimatedContainer( - duration: const Duration(milliseconds: 180), - curve: Curves.easeOutCubic, - width: double.infinity, - padding: const EdgeInsets.all(12), - decoration: BoxDecoration( - color: palette.surfaceSecondary, - borderRadius: BorderRadius.circular(14), - border: Border.all(color: palette.strokeSoft), - ), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - InkWell( - key: const Key('assistant-task-overview-toggle'), - borderRadius: BorderRadius.circular(12), - onTap: onToggleOverview, - child: Padding( - padding: const EdgeInsets.symmetric(vertical: 2), - child: Row( - children: [ - Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - appText( - '当前对话就是默认任务', - 'This chat is the default task', - ), - style: theme.textTheme.titleSmall - ?.copyWith( - color: theme.colorScheme.onSurface, - fontWeight: FontWeight.w600, - ), - ), - const SizedBox(height: 4), - Text( - appText( - '点击展开任务说明与快捷入口', - 'Tap to expand task guidance and shortcuts', - ), - style: theme.textTheme.bodySmall - ?.copyWith( - color: palette.textSecondary, - ), - ), - ], - ), - ), - Icon( - overviewExpanded - ? Icons.keyboard_arrow_up_rounded - : Icons.keyboard_arrow_down_rounded, - color: palette.textMuted, - ), - ], - ), - ), - ), - if (overviewExpanded) ...[ - const SizedBox(height: 10), - Text( - appText( - '左侧选择任一任务,会直接切到这个任务对应的会话上下文。', - 'Selecting a task on the left jumps straight into that task conversation.', - ), - style: theme.textTheme.bodySmall?.copyWith( - color: palette.textSecondary, - height: 1.35, - ), - ), - const SizedBox(height: 10), - Wrap( - spacing: 8, - runSpacing: 8, - children: [ - _MetaPill( - label: - '${appText('运行中', 'Running')} $runningCount', - icon: Icons.play_circle_outline_rounded, - ), - _MetaPill( - label: - '${appText('已完成', 'Completed')} $completedCount', - icon: Icons.check_circle_outline_rounded, - ), - _MetaPill( - label: - '${appText('技能', 'Skills')} ${controller.skills.length}', - icon: Icons.auto_awesome_rounded, - ), - ], - ), - const SizedBox(height: 10), - Wrap( - spacing: 8, - runSpacing: 8, - children: [ - TextButton.icon( - onPressed: onOpenTasks, - icon: const Icon(Icons.layers_outlined, size: 18), - label: Text(appText('打开任务页', 'Open tasks')), - ), - TextButton.icon( - onPressed: onOpenSkills, - icon: const Icon(Icons.hub_outlined, size: 18), - label: Text(appText('查看技能', 'Open skills')), - ), - ], - ), - ], - ], - ), + Wrap( + spacing: 8, + runSpacing: 8, + children: [ + _MetaPill( + label: '${appText('运行中', 'Running')} $runningCount', + icon: Icons.play_circle_outline_rounded, + ), + _MetaPill( + label: '${appText('已完成', 'Completed')} $completedCount', + icon: Icons.check_circle_outline_rounded, + ), + _MetaPill( + label: + '${appText('技能', 'Skills')} ${controller.skills.length}', + icon: Icons.auto_awesome_rounded, + ), + ], ), ], ), @@ -2012,56 +1874,53 @@ class _AssistantEmptyState extends StatelessWidget { return Center( child: ConstrainedBox( - constraints: const BoxConstraints(maxWidth: 500), + constraints: const BoxConstraints(maxWidth: 520), child: Padding( - padding: const EdgeInsets.all(16), - child: SurfaceCard( - borderRadius: 12, - child: Column( - mainAxisSize: MainAxisSize.min, - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text(title, style: theme.textTheme.headlineSmall), - const SizedBox(height: 8), - Text(description, style: theme.textTheme.bodyMedium), - const SizedBox(height: 12), - Wrap( - spacing: 8, - runSpacing: 8, - children: [ - FilledButton.icon( - onPressed: connected - ? onFocusComposer + padding: const EdgeInsets.all(20), + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text(title, style: theme.textTheme.headlineSmall), + const SizedBox(height: 8), + Text(description, style: theme.textTheme.bodyMedium), + const SizedBox(height: 14), + Wrap( + spacing: 8, + runSpacing: 8, + children: [ + FilledButton.icon( + onPressed: connected + ? onFocusComposer + : reconnectAvailable + ? () async { + await onReconnectGateway(); + } + : onOpenGateway, + icon: Icon( + connected + ? Icons.edit_rounded : reconnectAvailable - ? () async { - await onReconnectGateway(); - } - : onOpenGateway, - icon: Icon( - connected - ? Icons.edit_rounded - : reconnectAvailable - ? Icons.refresh_rounded - : Icons.link_rounded, - ), - label: Text( - connected - ? appText('开始输入', 'Start typing') - : reconnectAvailable - ? appText('重新连接', 'Reconnect') - : appText('连接 Gateway', 'Connect gateway'), - ), + ? Icons.refresh_rounded + : Icons.link_rounded, ), - if (!connected) - OutlinedButton.icon( - onPressed: onOpenGateway, - icon: const Icon(Icons.settings_rounded), - label: Text(appText('编辑连接', 'Edit connection')), - ), - ], - ), - ], - ), + label: Text( + connected + ? appText('开始输入', 'Start typing') + : reconnectAvailable + ? appText('重新连接', 'Reconnect') + : appText('连接 Gateway', 'Connect gateway'), + ), + ), + if (!connected) + OutlinedButton.icon( + onPressed: onOpenGateway, + icon: const Icon(Icons.settings_rounded), + label: Text(appText('编辑连接', 'Edit connection')), + ), + ], + ), + ], ), ), ), @@ -2087,6 +1946,8 @@ class _ComposerBar extends StatelessWidget { required this.onOpenGateway, required this.onReconnectGateway, required this.onPickAttachments, + required this.suggestions, + required this.onSuggestionSelected, required this.onSend, }); @@ -2106,6 +1967,8 @@ class _ComposerBar extends StatelessWidget { final VoidCallback onOpenGateway; final Future Function() onReconnectGateway; final VoidCallback onPickAttachments; + final List<_AssistantSuggestion> suggestions; + final ValueChanged<_AssistantSuggestion> onSuggestionSelected; final Future Function() onSend; @override @@ -2141,7 +2004,7 @@ class _ComposerBar extends StatelessWidget { : appText('连接', 'Connect'); return SurfaceCard( - borderRadius: 12, + borderRadius: 0, child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ @@ -2165,19 +2028,41 @@ class _ComposerBar extends StatelessWidget { controller: inputController, focusNode: focusNode, autofocus: true, - minLines: 2, + minLines: 3, maxLines: 6, decoration: InputDecoration( border: InputBorder.none, isCollapsed: true, hintText: appText( - '直接描述需求:运行任务、分析日志、部署节点……', - 'Type naturally: run job autopilot, analyze logs, deploy node…', + '输入需求、补充上下文、继续追问,WorkBuddy 会沿用当前任务上下文持续处理。', + 'Describe the task, add context, or continue the thread. WorkBuddy keeps the current task context.', ), ), onSubmitted: (_) => onSend(), ), const SizedBox(height: 8), + if (suggestions.isNotEmpty) ...[ + SizedBox( + height: 34, + child: ListView.separated( + scrollDirection: Axis.horizontal, + itemCount: suggestions.length, + separatorBuilder: (_, _) => const SizedBox(width: 8), + itemBuilder: (context, index) { + final suggestion = suggestions[index]; + return ActionChip( + key: ValueKey( + 'assistant-suggestion-${suggestion.label}', + ), + label: Text(suggestion.label), + avatar: Icon(suggestion.icon, size: 16), + onPressed: () => onSuggestionSelected(suggestion), + ); + }, + ), + ), + const SizedBox(height: 10), + ], Row( children: [ Expanded( @@ -3395,3 +3280,15 @@ class _ComposerAttachment { ); } } + +class _AssistantSuggestion { + const _AssistantSuggestion({ + required this.label, + required this.prompt, + required this.icon, + }); + + final String label; + final String prompt; + final IconData icon; +} diff --git a/lib/features/skills/skills_page.dart b/lib/features/skills/skills_page.dart index ac33889e..022efcb4 100644 --- a/lib/features/skills/skills_page.dart +++ b/lib/features/skills/skills_page.dart @@ -1,14 +1,14 @@ - 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 '../../widgets/status_badge.dart'; - import '../../widgets/surface_card.dart'; - import '../../widgets/top_bar.dart'; +import 'package:flutter/material.dart'; -class SkillsPage extends StatelessWidget { +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 '../../widgets/desktop_workspace_scaffold.dart'; +import '../../widgets/status_badge.dart'; + +class SkillsPage extends StatefulWidget { const SkillsPage({ super.key, required this.controller, @@ -19,181 +19,486 @@ class SkillsPage extends StatelessWidget { final ValueChanged onOpenDetail; @override - Widget build(BuildContext context) { - final items = controller.skills; + State createState() => _SkillsPageState(); +} +class _SkillsPageState extends State { + final TextEditingController _searchController = TextEditingController(); + String _query = ''; + String? _selectedSkillKey; + + @override + void dispose() { + _searchController.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { return AnimatedBuilder( - animation: controller, + animation: widget.controller, builder: (context, _) { - return SingleChildScrollView( - padding: const EdgeInsets.fromLTRB(32, 32, 32, 8), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, + final controller = widget.controller; + final skills = controller.skills + .where(_matchesQuery) + .toList(growable: false); + final selected = _resolveSelectedSkill(skills); + return DesktopWorkspaceScaffold( + eyebrow: appText('技能与能力包', 'Skills and capabilities'), + title: appText('技能工作台', 'Skills workspace'), + subtitle: appText( + '左侧浏览技能包,右侧查看描述、依赖和使用建议。', + 'Browse skills on the left, inspect descriptions, dependencies, and usage on the right.', + ), + toolbar: Wrap( + spacing: 10, + runSpacing: 10, + crossAxisAlignment: WrapCrossAlignment.center, children: [ - TopBar( - breadcrumbs: [ - AppBreadcrumbItem( - label: appText('主页', 'Home'), - icon: Icons.home_rounded, - onTap: controller.navigateHome, + SizedBox( + width: 240, + child: TextField( + controller: _searchController, + onChanged: (value) { + setState(() { + _query = value.trim().toLowerCase(); + }); + }, + decoration: InputDecoration( + hintText: appText('搜索技能', 'Search skills'), + prefixIcon: const Icon(Icons.search_rounded), + suffixIcon: _query.isEmpty + ? null + : IconButton( + tooltip: appText('清除', 'Clear'), + onPressed: () { + _searchController.clear(); + setState(() { + _query = ''; + }); + }, + icon: const Icon(Icons.close_rounded), + ), ), - AppBreadcrumbItem(label: appText('技能', 'Skills')), - ], - title: appText('技能', 'Skills'), - subtitle: appText( - '管理已安装的技能包,查看技能状态与依赖。', - 'Manage installed skill packages, view status and dependencies.', - ), - trailing: Wrap( - spacing: 12, - runSpacing: 12, - children: [ - SizedBox( - width: 220, - child: TextField( - decoration: InputDecoration( - hintText: appText('搜索技能', 'Search skills'), - prefixIcon: Icon(Icons.search_rounded), - ), - ), - ), - IconButton( - onPressed: () async { - await controller.skillsController.refresh( - agentId: controller.selectedAgentId.isEmpty - ? null - : controller.selectedAgentId, - ); - }, - icon: const Icon(Icons.refresh_rounded), - ), - ], ), ), - const SizedBox(height: 24), - if (items.isEmpty) - SurfaceCard( - child: Text( - controller.connection.status == - RuntimeConnectionStatus.connected - ? appText( - '当前网关或代理没有加载技能。', - 'No skills loaded for the active gateway / agent.', - ) - : appText( - '连接 Gateway 后可加载技能。', - 'Connect a gateway to load skills.', - ), - ), - ) - else - Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: items - .map( - (skill) => Padding( - padding: const EdgeInsets.only(bottom: 12), - child: SurfaceCard( - onTap: () => onOpenDetail( - DetailPanelData( - title: skill.name, - subtitle: appText('技能', 'Skill'), - icon: Icons.extension_rounded, - status: skill.disabled - ? StatusInfo( - appText('已禁用', 'Disabled'), - StatusTone.warning, - ) - : StatusInfo( - appText('已启用', 'Enabled'), - StatusTone.success, - ), - description: skill.description, - meta: [skill.source, skill.skillKey], - actions: [appText('刷新', 'Refresh')], - sections: [ - DetailSection( - title: appText('依赖要求', 'Requirements'), - items: [ - DetailItem( - label: appText( - '缺失二进制', 'Missing bins'), - value: skill.missingBins.isEmpty - ? appText('无', 'None') - : skill.missingBins.join(', '), - ), - DetailItem( - label: appText( - '缺失环境变量', 'Missing env'), - value: skill.missingEnv.isEmpty - ? appText('无', 'None') - : skill.missingEnv.join(', '), - ), - DetailItem( - label: appText('缺失配置', 'Missing config'), - value: skill.missingConfig.isEmpty - ? appText('无', 'None') - : skill.missingConfig.join(', '), - ), - ], - ), - ], - ), - ), - child: Row( - children: [ - Expanded( - flex: 4, - child: Column( - crossAxisAlignment: - CrossAxisAlignment.start, - children: [ - Text( - skill.name, - style: Theme.of(context) - .textTheme - .titleMedium, - ), - const SizedBox(height: 6), - Text( - skill.description, - style: - Theme.of(context).textTheme.bodySmall, - ), - ], - ), - ), - Expanded( - flex: 2, - child: StatusBadge( - status: skill.disabled - ? StatusInfo( - appText('已禁用', 'Disabled'), - StatusTone.warning, - ) - : StatusInfo( - appText('已启用', 'Enabled'), - StatusTone.success, - ), - ), - ), - Expanded(flex: 2, child: Text(skill.source)), - Expanded( - flex: 2, - child: Text(skill.primaryEnv ?? 'workspace'), - ), - const Icon(Icons.chevron_right_rounded), - ], - ), - ), - ), - ) - .toList(), - ), + IconButton( + tooltip: appText('刷新技能', 'Refresh skills'), + onPressed: () async { + await controller.skillsController.refresh( + agentId: controller.selectedAgentId.isEmpty + ? null + : controller.selectedAgentId, + ); + }, + icon: const Icon(Icons.refresh_rounded), + ), + FilledButton.tonalIcon( + onPressed: () => + controller.navigateTo(WorkspaceDestination.assistant), + icon: const Icon(Icons.auto_awesome_rounded), + label: Text(appText('回到对话使用', 'Use in assistant')), + ), ], ), + child: Container( + margin: const EdgeInsets.all(16), + decoration: BoxDecoration( + border: Border.all(color: context.palette.strokeSoft), + ), + child: Row( + children: [ + SizedBox( + width: 360, + child: _SkillsListPanel( + skills: skills, + selectedSkillKey: selected?.skillKey, + onSelectSkill: (skill) { + setState(() { + _selectedSkillKey = skill.skillKey; + }); + }, + ), + ), + Container(width: 1, color: context.palette.strokeSoft), + Expanded( + child: _SkillDetailPanel( + controller: controller, + selected: selected, + ), + ), + ], + ), + ), ); }, ); } + + bool _matchesQuery(GatewaySkillSummary skill) { + if (_query.isEmpty) { + return true; + } + final haystack = [ + skill.name, + skill.description, + skill.source, + skill.skillKey, + skill.primaryEnv ?? '', + ].join(' ').toLowerCase(); + return haystack.contains(_query); + } + + GatewaySkillSummary? _resolveSelectedSkill(List skills) { + if (skills.isEmpty) { + return null; + } + for (final skill in skills) { + if (skill.skillKey == _selectedSkillKey) { + return skill; + } + } + return skills.first; + } } + +class _SkillsListPanel extends StatelessWidget { + const _SkillsListPanel({ + required this.skills, + required this.selectedSkillKey, + required this.onSelectSkill, + }); + + final List skills; + final String? selectedSkillKey; + final ValueChanged onSelectSkill; + + @override + Widget build(BuildContext context) { + final palette = context.palette; + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Padding( + padding: const EdgeInsets.fromLTRB(14, 14, 14, 10), + child: Row( + children: [ + Text( + appText('技能列表', 'Skill list'), + style: Theme.of(context).textTheme.titleSmall, + ), + const SizedBox(width: 8), + Text( + '${skills.length}', + style: Theme.of( + context, + ).textTheme.bodySmall?.copyWith(color: palette.textMuted), + ), + ], + ), + ), + Container(height: 1, color: palette.strokeSoft), + Expanded( + child: skills.isEmpty + ? Center( + child: Padding( + padding: const EdgeInsets.all(20), + child: Text( + appText( + '当前没有可展示的技能。', + 'No skills are available right now.', + ), + textAlign: TextAlign.center, + style: Theme.of(context).textTheme.bodyMedium?.copyWith( + color: palette.textSecondary, + ), + ), + ), + ) + : ListView.separated( + padding: const EdgeInsets.all(10), + itemCount: skills.length, + separatorBuilder: (_, _) => const SizedBox(height: 8), + itemBuilder: (context, index) { + final skill = skills[index]; + return _SkillListTile( + skill: skill, + selected: skill.skillKey == selectedSkillKey, + onTap: () => onSelectSkill(skill), + ); + }, + ), + ), + ], + ); + } +} + +class _SkillListTile extends StatelessWidget { + const _SkillListTile({ + required this.skill, + required this.selected, + required this.onTap, + }); + + final GatewaySkillSummary skill; + final bool selected; + final VoidCallback onTap; + + @override + Widget build(BuildContext context) { + final palette = context.palette; + return Material( + color: selected ? palette.accentMuted.withValues(alpha: 0.4) : null, + child: InkWell( + onTap: onTap, + child: Container( + padding: const EdgeInsets.all(12), + decoration: BoxDecoration( + border: Border.all( + color: selected ? palette.accent : palette.strokeSoft, + ), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Expanded( + child: Text( + skill.name, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: Theme.of(context).textTheme.titleSmall?.copyWith( + fontWeight: FontWeight.w600, + ), + ), + ), + const SizedBox(width: 10), + StatusBadge( + status: skill.disabled + ? _skillStatus( + appText('已禁用', 'Disabled'), + StatusTone.warning, + ) + : _skillStatus( + appText('已启用', 'Enabled'), + StatusTone.success, + ), + ), + ], + ), + const SizedBox(height: 8), + Text( + skill.description, + maxLines: 2, + overflow: TextOverflow.ellipsis, + style: Theme.of(context).textTheme.bodySmall?.copyWith( + color: palette.textSecondary, + height: 1.4, + ), + ), + const SizedBox(height: 10), + Wrap( + spacing: 10, + runSpacing: 6, + children: [ + _SkillMeta(label: skill.source), + _SkillMeta(label: skill.primaryEnv ?? 'workspace'), + ], + ), + ], + ), + ), + ), + ); + } +} + +class _SkillDetailPanel extends StatelessWidget { + const _SkillDetailPanel({required this.controller, required this.selected}); + + final AppController controller; + final GatewaySkillSummary? selected; + + @override + Widget build(BuildContext context) { + final palette = context.palette; + if (selected == null) { + return Center( + child: Text( + appText('选择左侧技能查看详情。', 'Select a skill on the left.'), + style: Theme.of( + context, + ).textTheme.bodyLarge?.copyWith(color: palette.textSecondary), + ), + ); + } + + return Padding( + padding: const EdgeInsets.all(18), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Wrap( + spacing: 8, + runSpacing: 8, + children: [ + Text( + selected!.name, + style: Theme.of(context).textTheme.headlineSmall?.copyWith( + fontWeight: FontWeight.w600, + ), + ), + StatusBadge( + status: selected!.disabled + ? _skillStatus( + appText('已禁用', 'Disabled'), + StatusTone.warning, + ) + : _skillStatus( + appText('已启用', 'Enabled'), + StatusTone.success, + ), + ), + ], + ), + const SizedBox(height: 8), + Text( + selected!.description, + style: Theme.of(context).textTheme.bodyMedium?.copyWith( + color: palette.textSecondary, + height: 1.5, + ), + ), + const SizedBox(height: 18), + Wrap( + spacing: 12, + runSpacing: 12, + children: [ + _DependencyCard( + title: appText('缺失二进制', 'Missing bins'), + values: selected!.missingBins, + ), + _DependencyCard( + title: appText('缺失环境变量', 'Missing env'), + values: selected!.missingEnv, + ), + _DependencyCard( + title: appText('缺失配置', 'Missing config'), + values: selected!.missingConfig, + ), + ], + ), + const SizedBox(height: 18), + Container( + width: double.infinity, + padding: const EdgeInsets.all(14), + decoration: BoxDecoration( + color: palette.surfaceSecondary, + border: Border.all(color: palette.strokeSoft), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + appText('在对话中使用', 'Use in the assistant'), + style: Theme.of(context).textTheme.titleSmall, + ), + const SizedBox(height: 8), + Text( + appText( + '回到 Assistant 后,可通过下方建议按钮或直接描述需求来调用该技能上下文。', + 'After returning to Assistant, use the suggested chips or describe the task directly to route into this skill context.', + ), + style: Theme.of(context).textTheme.bodyMedium?.copyWith( + color: palette.textSecondary, + height: 1.45, + ), + ), + ], + ), + ), + const Spacer(), + Wrap( + spacing: 10, + runSpacing: 10, + children: [ + FilledButton.icon( + onPressed: () => + controller.navigateTo(WorkspaceDestination.assistant), + icon: const Icon(Icons.auto_awesome_rounded), + label: Text(appText('去对话中使用', 'Use in assistant')), + ), + OutlinedButton.icon( + onPressed: () async { + await controller.skillsController.refresh( + agentId: controller.selectedAgentId.isEmpty + ? null + : controller.selectedAgentId, + ); + }, + icon: const Icon(Icons.refresh_rounded), + label: Text(appText('刷新', 'Refresh')), + ), + ], + ), + ], + ), + ); + } +} + +class _DependencyCard extends StatelessWidget { + const _DependencyCard({required this.title, required this.values}); + + final String title; + final List values; + + @override + Widget build(BuildContext context) { + final palette = context.palette; + return Container( + width: 220, + padding: const EdgeInsets.all(12), + decoration: BoxDecoration( + color: palette.surfaceSecondary, + border: Border.all(color: palette.strokeSoft), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text(title, style: Theme.of(context).textTheme.titleSmall), + const SizedBox(height: 8), + Text( + values.isEmpty ? appText('无', 'None') : values.join(', '), + style: Theme.of(context).textTheme.bodySmall?.copyWith( + color: palette.textSecondary, + height: 1.45, + ), + ), + ], + ), + ); + } +} + +class _SkillMeta extends StatelessWidget { + const _SkillMeta({required this.label}); + + final String label; + + @override + Widget build(BuildContext context) { + return Text( + label, + style: Theme.of( + context, + ).textTheme.bodySmall?.copyWith(color: context.palette.textMuted), + ); + } +} + +StatusInfo _skillStatus(String label, StatusTone tone) => + StatusInfo(label, tone); diff --git a/lib/features/tasks/tasks_page.dart b/lib/features/tasks/tasks_page.dart index 25f8f8c3..63878525 100644 --- a/lib/features/tasks/tasks_page.dart +++ b/lib/features/tasks/tasks_page.dart @@ -4,11 +4,11 @@ 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 '../../widgets/desktop_workspace_scaffold.dart'; import '../../widgets/metric_card.dart'; import '../../widgets/section_tabs.dart'; import '../../widgets/status_badge.dart'; -import '../../widgets/surface_card.dart'; -import '../../widgets/top_bar.dart'; class TasksPage extends StatefulWidget { const TasksPage({ @@ -26,39 +26,47 @@ class TasksPage extends StatefulWidget { class _TasksPageState extends State { TasksTab _tab = TasksTab.queue; + final TextEditingController _searchController = TextEditingController(); + String _query = ''; + String? _selectedTaskId; + + @override + void dispose() { + _searchController.dispose(); + super.dispose(); + } @override Widget build(BuildContext context) { final controller = widget.controller; - final items = controller.taskItemsForTab(_tabKey); + final allItems = controller.taskItemsForTab(_tabKey); + final items = allItems.where(_matchesQuery).toList(growable: false); + final selected = _resolveSelectedTask(items); final metrics = [ MetricSummary( label: appText('总数', 'Total'), value: '${controller.tasksController.totalCount}', - caption: appText('从会话与对话中派生', 'Derived from sessions / chat'), + caption: appText('任务 / 会话聚合', 'Task / session aggregate'), icon: Icons.layers_rounded, ), MetricSummary( label: appText('运行中', 'Running'), value: '${controller.tasksController.running.length}', - caption: appText('当前活跃运行', 'Current active runs'), + caption: appText('当前活跃执行', 'Active executions'), icon: Icons.play_circle_outline_rounded, - status: _statusInfoForTask('Running'), + status: _taskStatusInfo('Running'), ), MetricSummary( label: appText('失败', 'Failed'), value: '${controller.tasksController.failed.length}', - caption: appText('中断或报错的运行', 'Aborted / error runs'), + caption: appText('中断或报错', 'Interrupted or failed'), icon: Icons.error_outline_rounded, - status: _statusInfoForTask('Failed'), + status: _taskStatusInfo('Failed'), ), MetricSummary( label: appText('计划中', 'Scheduled'), value: '${controller.tasksController.scheduled.length}', - caption: appText( - '来自 Gateway cron 调度器', - 'Loaded from the gateway cron scheduler', - ), + caption: appText('来自 cron 调度器', 'Loaded from cron scheduler'), icon: Icons.event_repeat_rounded, ), ]; @@ -66,289 +74,498 @@ class _TasksPageState extends State { return AnimatedBuilder( animation: controller, builder: (context, _) { - return SingleChildScrollView( - padding: const EdgeInsets.fromLTRB(32, 32, 32, 8), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, + final palette = context.palette; + return DesktopWorkspaceScaffold( + eyebrow: appText('任务与线程', 'Tasks and sessions'), + title: appText('任务工作台', 'Task workspace'), + subtitle: appText( + '左侧筛选和切换任务,右侧查看当前任务详情并回到对话。', + 'Filter and switch tasks on the left, inspect the current task on the right.', + ), + toolbar: Wrap( + spacing: 10, + runSpacing: 10, + crossAxisAlignment: WrapCrossAlignment.center, 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( - '查看任务队列、执行状态与历史记录', - 'Review queue, execution state, and history.', - ), - trailing: Wrap( - spacing: 12, - runSpacing: 12, - crossAxisAlignment: WrapCrossAlignment.center, - children: [ - SizedBox( - width: 220, - child: TextField( - decoration: InputDecoration( - hintText: appText('搜索任务', 'Search tasks'), - prefixIcon: Icon(Icons.search_rounded), - ), - ), - ), - IconButton( - onPressed: controller.refreshSessions, - icon: const Icon(Icons.refresh_rounded), - ), - if (_tab != TasksTab.scheduled) - FilledButton.tonalIcon( - onPressed: () => controller.navigateTo( - WorkspaceDestination.assistant, - ), - icon: const Icon(Icons.add_rounded), - label: Text(appText('新建任务', 'New Task')), - ) - else - Chip( - avatar: const Icon( - Icons.lock_outline_rounded, - size: 16, - ), - label: Text( - appText('Scheduled 只读', 'Scheduled read-only'), - ), - ), - ], - ), - ), - const SizedBox(height: 24), - SectionTabs( - items: TasksTab.values.map((item) => item.label).toList(), - value: _tab.label, - onChanged: (value) => setState( - () => _tab = TasksTab.values.firstWhere( - (item) => item.label == value, - ), - ), - ), - const SizedBox(height: 24), - LayoutBuilder( - builder: (context, constraints) { - final width = constraints.maxWidth > 980 - ? (constraints.maxWidth - 48) / 4 - : constraints.maxWidth > 640 - ? (constraints.maxWidth - 16) / 2 - : constraints.maxWidth; - return Wrap( - spacing: 16, - runSpacing: 16, - children: metrics - .map( - (metric) => SizedBox( - width: width, - child: MetricCard(metric: metric), + SizedBox( + width: 240, + child: TextField( + controller: _searchController, + onChanged: (value) { + setState(() { + _query = value.trim().toLowerCase(); + }); + }, + decoration: InputDecoration( + hintText: appText('搜索任务 / 会话', 'Search tasks / sessions'), + prefixIcon: const Icon(Icons.search_rounded), + suffixIcon: _query.isEmpty + ? null + : IconButton( + tooltip: appText('清除', 'Clear'), + onPressed: () { + _searchController.clear(); + setState(() { + _query = ''; + }); + }, + icon: const Icon(Icons.close_rounded), ), - ) - .toList(), - ); - }, + ), + ), ), - if (_tab == TasksTab.scheduled) ...[ + IconButton( + tooltip: appText('刷新任务', 'Refresh tasks'), + onPressed: controller.refreshSessions, + icon: const Icon(Icons.refresh_rounded), + ), + if (_tab != TasksTab.scheduled) + FilledButton.tonalIcon( + onPressed: () => + controller.navigateTo(WorkspaceDestination.assistant), + icon: const Icon(Icons.edit_note_rounded), + label: Text(appText('继续对话', 'Continue in assistant')), + ) + else + Chip( + avatar: const Icon(Icons.lock_outline_rounded, size: 16), + label: Text( + appText('计划任务只读', 'Scheduled tasks are read-only'), + ), + ), + ], + ), + child: Padding( + padding: const EdgeInsets.all(16), + child: Column( + children: [ + SectionTabs( + items: TasksTab.values.map((item) => item.label).toList(), + value: _tab.label, + onChanged: (value) { + setState(() { + _tab = TasksTab.values.firstWhere( + (item) => item.label == value, + ); + _selectedTaskId = null; + }); + }, + ), const SizedBox(height: 16), - SurfaceCard( - child: Text( - appText( - '这些项目来自 Gateway cron 调度器,本页当前仅支持只读展示。', - 'These items come from the gateway cron scheduler and are read-only in this build.', + SizedBox( + height: 172, + child: ListView.separated( + scrollDirection: Axis.horizontal, + itemCount: metrics.length, + separatorBuilder: (_, _) => const SizedBox(width: 12), + itemBuilder: (context, index) => SizedBox( + width: 240, + child: MetricCard(metric: metrics[index]), + ), + ), + ), + const SizedBox(height: 16), + Expanded( + child: Container( + decoration: BoxDecoration( + border: Border.all(color: palette.strokeSoft), + ), + child: Row( + children: [ + SizedBox( + width: 360, + child: _TaskListPanel( + tab: _tab, + items: items, + selectedTaskId: selected?.id, + onSelectTask: (task) { + setState(() { + _selectedTaskId = task.id; + }); + }, + ), + ), + Container(width: 1, color: palette.strokeSoft), + Expanded( + child: _TaskDetailPanel( + controller: controller, + tab: _tab, + selected: selected, + ), + ), + ], ), - style: Theme.of(context).textTheme.bodyMedium, ), ), ], - const SizedBox(height: 24), - if (_tab == TasksTab.scheduled && items.isEmpty) - SurfaceCard( - child: Text( - appText( - '当前网关还没有计划任务。', - 'No scheduled jobs are currently exposed by the gateway.', - ), - style: Theme.of(context).textTheme.bodyLarge, - ), - ) - else if (items.isEmpty) - SurfaceCard( - child: Text( - controller.connection.status == - RuntimeConnectionStatus.connected - ? appText('当前页签暂无任务。', 'No tasks in this tab.') - : appText( - '连接 Gateway 后,这里会显示真实的队列、运行中、历史和失败任务。', - 'Connect a gateway to load live queue, running, history, and failed tasks.', - ), - style: Theme.of(context).textTheme.bodyLarge, - ), - ) - else - ...items.map( - (task) => Padding( - padding: const EdgeInsets.only(bottom: 14), - child: SurfaceCard( - onTap: () => widget.onOpenDetail(_taskDetail(task)), - child: LayoutBuilder( - builder: (context, constraints) { - if (constraints.maxWidth < 820) { - return Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - task.title, - style: Theme.of( - context, - ).textTheme.titleMedium, - ), - const SizedBox(height: 6), - Text( - task.summary, - style: Theme.of(context).textTheme.bodySmall, - ), - const SizedBox(height: 14), - Wrap( - spacing: 12, - runSpacing: 12, - crossAxisAlignment: WrapCrossAlignment.center, - children: [ - StatusBadge( - status: _statusInfoForTask(task.status), - ), - Text(task.owner), - Text(task.startedAtLabel), - const Icon(Icons.chevron_right_rounded), - ], - ), - ], - ); - } - - return Row( - children: [ - Expanded( - flex: 4, - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - task.title, - style: Theme.of( - context, - ).textTheme.titleMedium, - ), - const SizedBox(height: 6), - Text( - task.summary, - style: Theme.of( - context, - ).textTheme.bodySmall, - ), - ], - ), - ), - Expanded( - flex: 2, - child: Align( - alignment: Alignment.centerLeft, - child: StatusBadge( - status: _statusInfoForTask(task.status), - ), - ), - ), - Expanded(flex: 2, child: Text(task.owner)), - Expanded( - flex: 2, - child: Text(task.startedAtLabel), - ), - const Icon(Icons.chevron_right_rounded), - ], - ); - }, - ), - ), - ), - ), - const SizedBox(height: 10), - Align( - alignment: Alignment.centerRight, - child: Text( - appText( - '点击任务项后会打开详情侧栏', - 'Click a task to open the detail drawer.', - ), - style: Theme.of(context).textTheme.bodySmall, - ), - ), - ], + ), ), ); }, ); } - DetailPanelData _taskDetail(DerivedTaskItem task) { - return DetailPanelData( - title: task.title, - subtitle: appText('会话派生任务', 'Session-derived Task'), - icon: Icons.layers_rounded, - status: _statusInfoForTask(task.status), - description: task.summary, - meta: [task.surface, task.sessionKey], - actions: [appText('打开会话', 'Open Session'), appText('刷新', 'Refresh')], - sections: [ - DetailSection( - title: appText('任务', 'Task'), - items: [ - DetailItem(label: appText('负责人', 'Owner'), value: task.owner), - DetailItem( - label: appText('状态', 'Status'), - value: _statusLabel(task.status), - ), - DetailItem( - label: appText('开始时间', 'Started'), - value: task.startedAtLabel, - ), - DetailItem( - label: appText('更新时间', 'Updated'), - value: task.durationLabel, - ), - DetailItem( - label: appText('会话 Key', 'Session Key'), - value: task.sessionKey, - ), - ], + String get _tabKey => _tab.label; + + bool _matchesQuery(DerivedTaskItem item) { + if (_query.isEmpty) { + return true; + } + final haystack = [ + item.title, + item.summary, + item.owner, + item.surface, + item.sessionKey, + ].join(' ').toLowerCase(); + return haystack.contains(_query); + } + + DerivedTaskItem? _resolveSelectedTask(List items) { + if (items.isEmpty) { + return null; + } + for (final item in items) { + if (item.id == _selectedTaskId) { + return item; + } + } + return items.first; + } +} + +class _TaskListPanel extends StatelessWidget { + const _TaskListPanel({ + required this.tab, + required this.items, + required this.selectedTaskId, + required this.onSelectTask, + }); + + final TasksTab tab; + final List items; + final String? selectedTaskId; + final ValueChanged onSelectTask; + + @override + Widget build(BuildContext context) { + final palette = context.palette; + final emptyLabel = tab == TasksTab.scheduled + ? appText('当前没有计划任务。', 'No scheduled tasks right now.') + : appText('当前筛选下没有任务。', 'No tasks match the current filter.'); + + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Padding( + padding: const EdgeInsets.fromLTRB(14, 14, 14, 10), + child: Row( + children: [ + Text( + appText('任务列表', 'Task list'), + style: Theme.of(context).textTheme.titleSmall, + ), + const SizedBox(width: 8), + Text( + '${items.length}', + style: Theme.of( + context, + ).textTheme.bodySmall?.copyWith(color: palette.textMuted), + ), + ], + ), + ), + Container(height: 1, color: palette.strokeSoft), + Expanded( + child: items.isEmpty + ? Center( + child: Padding( + padding: const EdgeInsets.all(20), + child: Text( + emptyLabel, + textAlign: TextAlign.center, + style: Theme.of(context).textTheme.bodyMedium?.copyWith( + color: palette.textSecondary, + ), + ), + ), + ) + : ListView.separated( + padding: const EdgeInsets.all(10), + itemCount: items.length, + separatorBuilder: (_, _) => const SizedBox(height: 8), + itemBuilder: (context, index) { + final task = items[index]; + final selected = task.id == selectedTaskId; + return _TaskListTile( + task: task, + selected: selected, + onTap: () => onSelectTask(task), + ); + }, + ), ), ], ); } - - String get _tabKey => switch (_tab) { - TasksTab.queue => 'Queue', - TasksTab.running => 'Running', - TasksTab.history => 'History', - TasksTab.failed => 'Failed', - TasksTab.scheduled => 'Scheduled', - }; } -StatusInfo _statusInfoForTask(String status) => switch (status) { +class _TaskListTile extends StatelessWidget { + const _TaskListTile({ + required this.task, + required this.selected, + required this.onTap, + }); + + final DerivedTaskItem task; + final bool selected; + final VoidCallback onTap; + + @override + Widget build(BuildContext context) { + final palette = context.palette; + return Material( + color: selected ? palette.accentMuted.withValues(alpha: 0.4) : null, + child: InkWell( + key: ValueKey('tasks-list-item-${task.id}'), + onTap: onTap, + child: Container( + padding: const EdgeInsets.all(12), + decoration: BoxDecoration( + border: Border.all( + color: selected ? palette.accent : palette.strokeSoft, + ), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Expanded( + child: Text( + task.title, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: Theme.of(context).textTheme.titleSmall?.copyWith( + fontWeight: FontWeight.w600, + ), + ), + ), + const SizedBox(width: 10), + StatusBadge(status: _taskStatusInfo(task.status)), + ], + ), + const SizedBox(height: 8), + Text( + task.summary, + maxLines: 2, + overflow: TextOverflow.ellipsis, + style: Theme.of(context).textTheme.bodySmall?.copyWith( + color: palette.textSecondary, + height: 1.4, + ), + ), + const SizedBox(height: 10), + Wrap( + spacing: 10, + runSpacing: 6, + children: [ + _InlineMeta(label: task.owner), + _InlineMeta(label: task.startedAtLabel), + _InlineMeta(label: task.surface), + ], + ), + ], + ), + ), + ), + ); + } +} + +class _TaskDetailPanel extends StatelessWidget { + const _TaskDetailPanel({ + required this.controller, + required this.tab, + required this.selected, + }); + + final AppController controller; + final TasksTab tab; + final DerivedTaskItem? selected; + + @override + Widget build(BuildContext context) { + final palette = context.palette; + if (selected == null) { + return Center( + child: Text( + appText('选择左侧任务查看详情。', 'Select a task on the left.'), + style: Theme.of( + context, + ).textTheme.bodyLarge?.copyWith(color: palette.textSecondary), + ), + ); + } + + return Padding( + key: const Key('tasks-detail-panel'), + padding: const EdgeInsets.all(18), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Wrap( + spacing: 8, + runSpacing: 8, + crossAxisAlignment: WrapCrossAlignment.center, + children: [ + Text( + selected!.title, + style: Theme.of(context).textTheme.headlineSmall?.copyWith( + fontWeight: FontWeight.w600, + ), + ), + StatusBadge(status: _taskStatusInfo(selected!.status)), + ], + ), + const SizedBox(height: 8), + Text( + selected!.summary, + style: Theme.of(context).textTheme.bodyMedium?.copyWith( + color: palette.textSecondary, + height: 1.5, + ), + ), + const SizedBox(height: 18), + Wrap( + spacing: 12, + runSpacing: 12, + children: [ + _DetailStat( + label: appText('任务来源', 'Surface'), + value: selected!.surface, + ), + _DetailStat( + label: appText('执行代理', 'Owner'), + value: selected!.owner, + ), + _DetailStat( + label: appText('开始时间', 'Started'), + value: selected!.startedAtLabel, + ), + _DetailStat( + label: appText('耗时', 'Duration'), + value: selected!.durationLabel, + ), + ], + ), + const SizedBox(height: 18), + Container( + width: double.infinity, + padding: const EdgeInsets.all(14), + decoration: BoxDecoration( + color: palette.surfaceSecondary, + border: Border.all(color: palette.strokeSoft), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + appText('会话上下文', 'Conversation context'), + style: Theme.of(context).textTheme.titleSmall, + ), + const SizedBox(height: 8), + SelectableText( + selected!.sessionKey, + style: Theme.of(context).textTheme.bodyMedium, + ), + ], + ), + ), + const Spacer(), + Wrap( + spacing: 10, + runSpacing: 10, + children: [ + FilledButton.icon( + onPressed: tab == TasksTab.scheduled + ? null + : () async { + await controller.switchSession(selected!.sessionKey); + controller.navigateTo(WorkspaceDestination.assistant); + }, + icon: const Icon(Icons.forum_outlined), + label: Text(appText('回到持续对话', 'Open conversation')), + ), + OutlinedButton.icon( + onPressed: controller.refreshSessions, + icon: const Icon(Icons.refresh_rounded), + label: Text(appText('刷新', 'Refresh')), + ), + ], + ), + ], + ), + ); + } +} + +class _DetailStat extends StatelessWidget { + const _DetailStat({required this.label, required this.value}); + + final String label; + final String value; + + @override + Widget build(BuildContext context) { + final palette = context.palette; + return Container( + constraints: const BoxConstraints(minWidth: 160), + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 10), + decoration: BoxDecoration( + color: palette.surfaceSecondary, + border: Border.all(color: palette.strokeSoft), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + label, + style: Theme.of( + context, + ).textTheme.bodySmall?.copyWith(color: palette.textMuted), + ), + const SizedBox(height: 4), + Text(value, style: Theme.of(context).textTheme.labelLarge), + ], + ), + ); + } +} + +class _InlineMeta extends StatelessWidget { + const _InlineMeta({required this.label}); + + final String label; + + @override + Widget build(BuildContext context) { + return Text( + label, + style: Theme.of( + context, + ).textTheme.bodySmall?.copyWith(color: context.palette.textMuted), + ); + } +} + +StatusInfo _taskStatusInfo(String status) => switch (status) { + 'running' || 'Running' => StatusInfo(appText('运行中', 'Running'), StatusTone.accent), + 'failed' || 'Failed' => StatusInfo(appText('失败', 'Failed'), StatusTone.danger), + 'queued' || 'Queued' => StatusInfo(appText('排队中', 'Queued'), StatusTone.neutral), - 'Scheduled' => StatusInfo(appText('计划中', 'Scheduled'), StatusTone.accent), - 'Disabled' => StatusInfo(appText('已禁用', 'Disabled'), StatusTone.neutral), _ => StatusInfo(appText('已完成', 'Completed'), StatusTone.success), }; - -String _statusLabel(String status) => _statusInfoForTask(status).label; diff --git a/lib/theme/app_theme.dart b/lib/theme/app_theme.dart index 0666fe9e..1f904cea 100644 --- a/lib/theme/app_theme.dart +++ b/lib/theme/app_theme.dart @@ -23,14 +23,14 @@ class AppSpacing { class AppRadius { AppRadius._(); - static const double card = 12.0; - static const double button = 8.0; - static const double input = 10.0; - static const double chip = 12.0; - static const double badge = 10.0; - static const double dialog = 16.0; - static const double sidebar = 14.0; - static const double icon = 8.0; + static const double card = 6.0; + static const double button = 6.0; + static const double input = 6.0; + static const double chip = 999.0; + static const double badge = 999.0; + static const double dialog = 10.0; + static const double sidebar = 8.0; + static const double icon = 6.0; } class AppTypography { @@ -164,7 +164,9 @@ class AppTheme { backgroundColor: palette.surfaceSecondary, side: BorderSide(color: palette.strokeSoft), labelStyle: tunedTextTheme.labelMedium, - shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(AppRadius.chip)), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(AppRadius.chip), + ), padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4), ), filledButtonTheme: FilledButtonThemeData( @@ -172,7 +174,12 @@ class AppTheme { textStyle: tunedTextTheme.labelLarge?.copyWith( fontWeight: FontWeight.w500, ), - minimumSize: Size(0, isDesktop ? AppSizes.buttonHeightDesktop : AppSizes.buttonHeightMobile), + minimumSize: Size( + 0, + isDesktop + ? AppSizes.buttonHeightDesktop + : AppSizes.buttonHeightMobile, + ), padding: EdgeInsets.symmetric( horizontal: AppSpacing.sm, vertical: AppSpacing.xs, @@ -188,7 +195,12 @@ class AppTheme { textStyle: tunedTextTheme.labelLarge?.copyWith( fontWeight: FontWeight.w500, ), - minimumSize: Size(0, isDesktop ? AppSizes.buttonHeightDesktop : AppSizes.buttonHeightMobile), + minimumSize: Size( + 0, + isDesktop + ? AppSizes.buttonHeightDesktop + : AppSizes.buttonHeightMobile, + ), padding: EdgeInsets.symmetric( horizontal: AppSpacing.sm, vertical: AppSpacing.xs, @@ -269,10 +281,15 @@ class AppTheme { return palette.textSecondary; }), padding: const WidgetStatePropertyAll( - EdgeInsets.symmetric(horizontal: AppSpacing.sm, vertical: AppSpacing.xs), + EdgeInsets.symmetric( + horizontal: AppSpacing.sm, + vertical: AppSpacing.xs, + ), ), shape: WidgetStatePropertyAll( - RoundedRectangleBorder(borderRadius: BorderRadius.circular(AppRadius.chip)), + RoundedRectangleBorder( + borderRadius: BorderRadius.circular(AppRadius.chip), + ), ), textStyle: WidgetStatePropertyAll( tunedTextTheme.labelLarge?.copyWith(fontWeight: FontWeight.w500), @@ -283,7 +300,9 @@ class AppTheme { behavior: SnackBarBehavior.floating, backgroundColor: palette.surfaceTertiary, contentTextStyle: TextStyle(color: palette.textPrimary), - shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(AppRadius.dialog)), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(AppRadius.dialog), + ), ), ); } diff --git a/lib/widgets/desktop_workspace_scaffold.dart b/lib/widgets/desktop_workspace_scaffold.dart new file mode 100644 index 00000000..07bd6c65 --- /dev/null +++ b/lib/widgets/desktop_workspace_scaffold.dart @@ -0,0 +1,110 @@ +import 'package:flutter/material.dart'; + +import '../theme/app_palette.dart'; + +class DesktopWorkspaceScaffold extends StatelessWidget { + const DesktopWorkspaceScaffold({ + super.key, + required this.child, + this.eyebrow, + this.title, + this.subtitle, + this.toolbar, + this.padding = const EdgeInsets.fromLTRB(16, 16, 16, 0), + }); + + final Widget child; + final String? eyebrow; + final String? title; + final String? subtitle; + final Widget? toolbar; + final EdgeInsetsGeometry padding; + + @override + Widget build(BuildContext context) { + final palette = context.palette; + final hasHeader = + (title != null && title!.trim().isNotEmpty) || + (subtitle != null && subtitle!.trim().isNotEmpty) || + toolbar != null; + + return Padding( + padding: padding, + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + if (hasHeader) + Padding( + padding: const EdgeInsets.fromLTRB(4, 0, 4, 14), + child: LayoutBuilder( + builder: (context, constraints) { + final compact = constraints.maxWidth < 920; + final header = Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + if (eyebrow != null && eyebrow!.trim().isNotEmpty) ...[ + Text( + eyebrow!, + style: Theme.of(context).textTheme.labelMedium + ?.copyWith( + color: palette.textMuted, + letterSpacing: 0.2, + ), + ), + const SizedBox(height: 6), + ], + if (title != null && title!.trim().isNotEmpty) + Text( + title!, + style: Theme.of(context).textTheme.headlineSmall + ?.copyWith(fontWeight: FontWeight.w600), + ), + if (subtitle != null && subtitle!.trim().isNotEmpty) ...[ + const SizedBox(height: 4), + Text( + subtitle!, + style: Theme.of(context).textTheme.bodyMedium + ?.copyWith(color: palette.textSecondary), + ), + ], + ], + ); + + if (compact || toolbar == null) { + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + header, + if (toolbar != null) ...[ + const SizedBox(height: 12), + toolbar!, + ], + ], + ); + } + + return Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Expanded(child: header), + const SizedBox(width: 20), + Flexible(child: toolbar!), + ], + ); + }, + ), + ), + Expanded( + child: Container( + decoration: BoxDecoration( + color: palette.surfacePrimary, + border: Border.all(color: palette.strokeSoft), + ), + child: child, + ), + ), + ], + ), + ); + } +} diff --git a/lib/widgets/sidebar_navigation.dart b/lib/widgets/sidebar_navigation.dart index d8701694..8e6ac763 100644 --- a/lib/widgets/sidebar_navigation.dart +++ b/lib/widgets/sidebar_navigation.dart @@ -20,6 +20,7 @@ class SidebarNavigation extends StatelessWidget { required this.onOpenThemeToggle, required this.accountName, required this.accountSubtitle, + this.onOpenOnlineWorkspace, this.expandedWidthOverride, this.marginOverride, this.showCollapseControl = true, @@ -39,6 +40,7 @@ class SidebarNavigation extends StatelessWidget { final VoidCallback onOpenThemeToggle; final String accountName; final String accountSubtitle; + final VoidCallback? onOpenOnlineWorkspace; final double? expandedWidthOverride; final EdgeInsetsGeometry? marginOverride; final bool showCollapseControl; @@ -162,6 +164,7 @@ class SidebarNavigation extends StatelessWidget { accountSelected: currentSection == WorkspaceDestination.account, showCollapseControl: showCollapseControl, + onOpenOnlineWorkspace: onOpenOnlineWorkspace, ), ], ), @@ -465,6 +468,7 @@ class SidebarFooter extends StatelessWidget { required this.accountSubtitle, required this.accountSelected, required this.showCollapseControl, + this.onOpenOnlineWorkspace, }); final bool isCollapsed; @@ -481,6 +485,7 @@ class SidebarFooter extends StatelessWidget { final String accountSubtitle; final bool accountSelected; final bool showCollapseControl; + final VoidCallback? onOpenOnlineWorkspace; @override Widget build(BuildContext context) { @@ -526,11 +531,21 @@ class SidebarFooter extends StatelessWidget { onPressed: onOpenSettings, ), const SizedBox(height: AppSpacing.xs), + if (onOpenOnlineWorkspace != null) ...[ + _SidebarActionButton( + icon: Icons.open_in_new_rounded, + tooltip: appText('打开在线版', 'Open online workspace'), + onPressed: onOpenOnlineWorkspace!, + ), + const SizedBox(height: AppSpacing.xs), + ], _SidebarAccountTile( selected: accountSelected, onTap: onOpenAccount, name: accountName, subtitle: accountSubtitle, + onlineActionLabel: appText('在线版', 'Online'), + onOpenOnlineWorkspace: onOpenOnlineWorkspace, ), ], ); @@ -587,6 +602,8 @@ class SidebarFooter extends StatelessWidget { onTap: onOpenAccount, name: accountName, subtitle: accountSubtitle, + onlineActionLabel: appText('在线版', 'Online'), + onOpenOnlineWorkspace: onOpenOnlineWorkspace, ), ], ); @@ -675,12 +692,16 @@ class _SidebarAccountTile extends StatefulWidget { required this.onTap, required this.name, required this.subtitle, + this.onlineActionLabel, + this.onOpenOnlineWorkspace, }); final bool selected; final VoidCallback onTap; final String name; final String subtitle; + final String? onlineActionLabel; + final VoidCallback? onOpenOnlineWorkspace; @override State<_SidebarAccountTile> createState() => _SidebarAccountTileState(); @@ -715,10 +736,13 @@ class _SidebarAccountTileState extends State<_SidebarAccountTile> { borderRadius: BorderRadius.circular(AppRadius.button), onTap: widget.onTap, child: Container( - height: AppSizes.sidebarItemHeight, - padding: const EdgeInsets.symmetric(horizontal: AppSpacing.xs), + padding: const EdgeInsets.symmetric( + horizontal: AppSpacing.xs, + vertical: 6, + ), child: Row( mainAxisSize: MainAxisSize.max, + crossAxisAlignment: CrossAxisAlignment.center, children: [ CircleAvatar( radius: 14, @@ -750,6 +774,18 @@ class _SidebarAccountTileState extends State<_SidebarAccountTile> { ], ), ), + if (widget.onOpenOnlineWorkspace != null && + widget.onlineActionLabel != null) ...[ + const SizedBox(width: AppSpacing.xs), + TextButton( + onPressed: widget.onOpenOnlineWorkspace, + style: TextButton.styleFrom( + minimumSize: const Size(0, 28), + padding: const EdgeInsets.symmetric(horizontal: 8), + ), + child: Text(widget.onlineActionLabel!), + ), + ], ], ), ), diff --git a/lib/widgets/surface_card.dart b/lib/widgets/surface_card.dart index 9d9ccbf6..91f3d804 100644 --- a/lib/widgets/surface_card.dart +++ b/lib/widgets/surface_card.dart @@ -41,13 +41,7 @@ class _SurfaceCardState extends State { color: _hovered ? palette.surfaceSecondary : baseColor, borderRadius: BorderRadius.circular(widget.borderRadius), border: Border.all(color: palette.strokeSoft), - boxShadow: [ - BoxShadow( - color: palette.shadow.withValues(alpha: _hovered ? 0.08 : 0.05), - blurRadius: _hovered ? 10 : 6, - offset: const Offset(0, 3), - ), - ], + boxShadow: const [], ), child: Material( color: Colors.transparent, diff --git a/test/features/assistant_page_test.dart b/test/features/assistant_page_test.dart index da69cc0d..05a71d11 100644 --- a/test/features/assistant_page_test.dart +++ b/test/features/assistant_page_test.dart @@ -1,7 +1,6 @@ import 'package:flutter/material.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:xworkmate/features/assistant/assistant_page.dart'; -import 'package:xworkmate/widgets/pane_resize_handle.dart'; import '../test_support.dart'; @@ -131,39 +130,6 @@ void main() { expect(find.byKey(const Key('assistant-side-pane')), findsOneWidget); }); - testWidgets('AssistantPage allows the left side pane to expand freely', ( - WidgetTester tester, - ) async { - final controller = await createTestController(tester); - - await pumpPage( - tester, - size: const Size(2200, 1200), - child: AssistantPage( - controller: controller, - onOpenDetail: (_) {}, - navigationPanelBuilder: (_) => const ColoredBox( - key: Key('assistant-nav-panel-probe'), - color: Colors.red, - ), - showStandaloneTaskRail: false, - ), - ); - - final sidePaneShell = find.byKey( - const Key('assistant-unified-side-pane-shell'), - ); - final initialWidth = tester.getSize(sidePaneShell).width; - expect(initialWidth, greaterThan(300)); - - await tester.drag(find.byType(PaneResizeHandle).first, const Offset(620, 0)); - await tester.pump(); - await tester.pump(const Duration(milliseconds: 260)); - - final expandedWidth = tester.getSize(sidePaneShell).width; - expect(expandedWidth, greaterThan(700)); - }); - testWidgets('AssistantPage narrow layout keeps existing single-pane flow', ( WidgetTester tester, ) async { @@ -198,7 +164,7 @@ void main() { expect(find.text('Gateway 访问'), findsOneWidget); }); - testWidgets('AssistantPage breadcrumb returns to default task home', ( + testWidgets('AssistantPage uses persistent composer with suggestion chips', ( WidgetTester tester, ) async { final controller = await createTestController(tester); @@ -208,18 +174,18 @@ void main() { child: AssistantPage(controller: controller, onOpenDetail: (_) {}), ); - await tester.tap(find.byKey(const Key('assistant-new-task-button'))); - await tester.pumpAndSettle(); + expect(find.textContaining('Claw'), findsNothing); + expect(find.text('幻灯片'), findsOneWidget); + expect(find.textContaining('输入需求、补充上下文、继续追问'), findsOneWidget); - 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')), + await tester.ensureVisible( + find.byKey(const ValueKey('assistant-suggestion-幻灯片')), ); - expect(titleAfter.data, '默认任务'); - expect(controller.currentSessionKey, 'main'); + await tester.tap( + find.byKey(const ValueKey('assistant-suggestion-幻灯片')), + ); + await tester.pumpAndSettle(); + + expect(find.textContaining('帮我整理一份演示文稿'), findsOneWidget); }); } diff --git a/test/features/skills_page_test.dart b/test/features/skills_page_test.dart new file mode 100644 index 00000000..6d273a56 --- /dev/null +++ b/test/features/skills_page_test.dart @@ -0,0 +1,40 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:xworkmate/features/skills/skills_page.dart'; +import 'package:xworkmate/models/app_models.dart'; + +import '../test_support.dart'; + +void main() { + testWidgets('SkillsPage routes back to assistant from toolbar', ( + WidgetTester tester, + ) async { + final controller = await createTestController(tester); + controller.navigateTo(WorkspaceDestination.skills); + + await pumpPage( + tester, + child: SkillsPage(controller: controller, onOpenDetail: (_) {}), + ); + + await tester.tap(find.text('回到对话使用')); + await tester.pumpAndSettle(); + + expect(controller.destination, WorkspaceDestination.assistant); + }); + + testWidgets('SkillsPage keeps workspace split layout', ( + WidgetTester tester, + ) async { + final controller = await createTestController(tester); + controller.navigateTo(WorkspaceDestination.skills); + + await pumpPage( + tester, + child: SkillsPage(controller: controller, onOpenDetail: (_) {}), + ); + + expect(find.text('技能列表'), findsOneWidget); + expect(find.text('选择左侧技能查看详情。'), findsOneWidget); + }); +} diff --git a/test/features/tasks_page_test.dart b/test/features/tasks_page_test.dart index e749d10a..cadd7c92 100644 --- a/test/features/tasks_page_test.dart +++ b/test/features/tasks_page_test.dart @@ -6,7 +6,7 @@ import 'package:xworkmate/models/app_models.dart'; import '../test_support.dart'; void main() { - testWidgets('TasksPage new task button routes back to assistant', ( + testWidgets('TasksPage continue button routes back to assistant', ( WidgetTester tester, ) async { final controller = await createTestController(tester); @@ -17,7 +17,7 @@ void main() { child: TasksPage(controller: controller, onOpenDetail: (_) {}), ); - await tester.tap(find.text('新建任务')); + await tester.tap(find.text('继续对话')); await tester.pumpAndSettle(); expect(controller.destination, WorkspaceDestination.assistant); @@ -37,12 +37,11 @@ void main() { await tester.tap(find.text('计划中').first); await tester.pumpAndSettle(); - expect(find.text('Scheduled 只读'), findsOneWidget); - expect(find.text('这些项目来自 Gateway cron 调度器,本页当前仅支持只读展示。'), findsOneWidget); - expect(find.text('新建任务'), findsNothing); + expect(find.text('计划任务只读'), findsOneWidget); + expect(find.text('当前没有计划任务。'), findsOneWidget); }); - testWidgets('TasksPage breadcrumb routes back to assistant home', ( + testWidgets('TasksPage keeps list/detail workspace structure', ( WidgetTester tester, ) async { final controller = await createTestController(tester); @@ -53,10 +52,7 @@ void main() { 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'); + expect(find.text('任务列表'), findsOneWidget); + expect(find.text('选择左侧任务查看详情。'), findsOneWidget); }); } diff --git a/test/widget_test.dart b/test/widget_test.dart index 4ebf586a..bb302aae 100644 --- a/test/widget_test.dart +++ b/test/widget_test.dart @@ -16,6 +16,7 @@ void main() { await tester.pumpAndSettle(); expect(find.text('新对话'), findsWidgets); - expect(find.text('连接 Gateway 后,当前对话会自动作为默认任务开始执行。'), findsOneWidget); + expect(find.byKey(const Key('assistant-task-rail')), findsOneWidget); + expect(find.text('幻灯片'), findsOneWidget); }); }