Refresh desktop workspace shell

This commit is contained in:
Haitao Pan 2026-03-18 12:38:42 +08:00
parent 62e1c93c81
commit be63d69699
15 changed files with 1507 additions and 885 deletions

View File

@ -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,
);
});
}

View File

@ -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<String>('assistant-focus-open-page-settings')),
);
await settleIntegrationUi(tester);
await tester.tap(_textEither('集成', 'Integrations'));
await settleIntegrationUi(tester);
expect(find.text('OpenClaw Gateway'), findsOneWidget);
});

View File

@ -165,6 +165,25 @@ class AppController extends ChangeNotifier {
return (await _store.loadAiGatewayApiKey())?.trim() ?? '';
}
Future<void> 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<String> 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;

View File

@ -233,6 +233,8 @@ class _AppShellState extends State<AppShell> {
.isEmpty
? appText('账号', 'Account')
: controller.settings.accountWorkspace,
onOpenOnlineWorkspace:
controller.openOnlineWorkspace,
expandedWidthOverride:
sidebarState == AppSidebarState.expanded
? expandedSidebarWidth

View File

@ -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<AssistantPage> {
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<String, _AssistantTaskSeed> _taskSeeds =
@ -121,7 +119,7 @@ class _AssistantPageState extends State<AssistantPage> {
);
});
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<AssistantPage> {
: _activeSidePane;
final sidePanelContentWidth =
(threadRailWidth - _sideTabRailWidth - 6)
.clamp(
_sidePaneContentMinWidth,
threadRailWidth,
)
.clamp(_sidePaneContentMinWidth, threadRailWidth)
.toDouble();
return Row(
children: [
@ -199,24 +194,11 @@ class _AssistantPageState extends State<AssistantPage> {
},
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<AssistantPage> {
},
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<AssistantPage> {
}) {
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<AssistantPage> {
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<AssistantPage> {
onOpenGateway: _showConnectDialog,
onReconnectGateway: _connectFromSavedSettingsOrShowDialog,
onPickAttachments: _pickAttachments,
onFocusComposer: _focusComposer,
suggestions: _buildSuggestions(controller),
onSuggestionSelected: _applySuggestion,
onSend: _submitPrompt,
),
),
@ -609,6 +534,105 @@ class _AssistantPageState extends State<AssistantPage> {
});
}
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<void> _submitPrompt() async {
final controller = widget.controller;
final settings = controller.settings;
@ -1012,10 +1036,9 @@ class _AssistantPageState extends State<AssistantPage> {
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<String>(
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<String>(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<void> Function() onReconnectGateway;
final VoidCallback onPickAttachments;
final VoidCallback onFocusComposer;
final List<_AssistantSuggestion> suggestions;
final ValueChanged<_AssistantSuggestion> onSuggestionSelected;
final Future<void> 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<void> Function() onRefreshTasks;
final Future<void> Function() onCreateTask;
final VoidCallback onOpenTasks;
final VoidCallback onOpenSkills;
final Future<void> Function(String sessionKey) onSelectTask;
final Future<void> 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<void> Function() onReconnectGateway;
final VoidCallback onPickAttachments;
final List<_AssistantSuggestion> suggestions;
final ValueChanged<_AssistantSuggestion> onSuggestionSelected;
final Future<void> 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<String>(
'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;
}

View File

@ -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<DetailPanelData> onOpenDetail;
@override
Widget build(BuildContext context) {
final items = controller.skills;
State<SkillsPage> createState() => _SkillsPageState();
}
class _SkillsPageState extends State<SkillsPage> {
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<GatewaySkillSummary> 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<GatewaySkillSummary> skills;
final String? selectedSkillKey;
final ValueChanged<GatewaySkillSummary> 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<String> 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);

View File

@ -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<TasksPage> {
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<TasksPage> {
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<DerivedTaskItem> 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<DerivedTaskItem> items;
final String? selectedTaskId;
final ValueChanged<DerivedTaskItem> 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<String>('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;

View File

@ -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),
),
),
);
}

View File

@ -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,
),
),
],
),
);
}
}

View File

@ -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!),
),
],
],
),
),

View File

@ -41,13 +41,7 @@ class _SurfaceCardState extends State<SurfaceCard> {
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,

View File

@ -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<String>('workspace-breadcrumb-0')));
await tester.pumpAndSettle();
final titleAfter = tester.widget<Text>(
find.byKey(const Key('assistant-conversation-title')),
await tester.ensureVisible(
find.byKey(const ValueKey<String>('assistant-suggestion-幻灯片')),
);
expect(titleAfter.data, '默认任务');
expect(controller.currentSessionKey, 'main');
await tester.tap(
find.byKey(const ValueKey<String>('assistant-suggestion-幻灯片')),
);
await tester.pumpAndSettle();
expect(find.textContaining('帮我整理一份演示文稿'), findsOneWidget);
});
}

View File

@ -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);
});
}

View File

@ -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<String>('workspace-breadcrumb-0')));
await tester.pumpAndSettle();
expect(controller.destination, WorkspaceDestination.assistant);
expect(controller.currentSessionKey, 'main');
expect(find.text('任务列表'), findsOneWidget);
expect(find.text('选择左侧任务查看详情。'), findsOneWidget);
});
}

View File

@ -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);
});
}