Refresh desktop workspace shell
This commit is contained in:
parent
62e1c93c81
commit
be63d69699
@ -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,
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
@ -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);
|
||||
});
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -233,6 +233,8 @@ class _AppShellState extends State<AppShell> {
|
||||
.isEmpty
|
||||
? appText('账号', 'Account')
|
||||
: controller.settings.accountWorkspace,
|
||||
onOpenOnlineWorkspace:
|
||||
controller.openOnlineWorkspace,
|
||||
expandedWidthOverride:
|
||||
sidebarState == AppSidebarState.expanded
|
||||
? expandedSidebarWidth
|
||||
|
||||
@ -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;
|
||||
}
|
||||
|
||||
@ -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);
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
110
lib/widgets/desktop_workspace_scaffold.dart
Normal file
110
lib/widgets/desktop_workspace_scaffold.dart
Normal 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,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@ -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!),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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);
|
||||
});
|
||||
}
|
||||
|
||||
40
test/features/skills_page_test.dart
Normal file
40
test/features/skills_page_test.dart
Normal 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);
|
||||
});
|
||||
}
|
||||
@ -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);
|
||||
});
|
||||
}
|
||||
|
||||
@ -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);
|
||||
});
|
||||
}
|
||||
|
||||
Loading…
Reference in New Issue
Block a user