From 61fa0d760b5499d375ecb8a06f85514da071e373 Mon Sep 17 00:00:00 2001 From: Haitao Pan Date: Tue, 19 May 2026 15:39:23 +0800 Subject: [PATCH] feat: show sidebar task status chips --- lib/app/app_shell_desktop.dart | 3 + .../sidebar_navigation_task_section.dart | 71 ++++++++++ .../sidebar_navigation_task_status_test.dart | 130 ++++++++++++++++++ 3 files changed, 204 insertions(+) create mode 100644 test/features/app/sidebar_navigation_task_status_test.dart diff --git a/lib/app/app_shell_desktop.dart b/lib/app/app_shell_desktop.dart index f2872a04..cd8323e8 100644 --- a/lib/app/app_shell_desktop.dart +++ b/lib/app/app_shell_desktop.dart @@ -56,6 +56,7 @@ class _AppShellState extends State { .map((session) { final sessionKey = session.key.trim(); final preview = session.lastMessagePreview?.trim() ?? ''; + final thread = controller.taskThreadForSessionInternal(sessionKey); return SidebarTaskItem( sessionKey: sessionKey, title: session.label.trim().isEmpty @@ -68,6 +69,8 @@ class _AppShellState extends State { ), isCurrent: sessionKey == currentSessionKey, pending: controller.assistantSessionHasPendingRun(sessionKey), + lifecycleStatus: thread?.lifecycleState.status ?? '', + lastResultCode: thread?.lifecycleState.lastResultCode ?? '', draft: sessionKey.startsWith('draft:'), ); }) diff --git a/lib/widgets/sidebar_navigation_task_section.dart b/lib/widgets/sidebar_navigation_task_section.dart index 0e48d333..18765569 100644 --- a/lib/widgets/sidebar_navigation_task_section.dart +++ b/lib/widgets/sidebar_navigation_task_section.dart @@ -9,6 +9,8 @@ class SidebarTaskItem { required this.executionTarget, required this.isCurrent, required this.pending, + this.lifecycleStatus = '', + this.lastResultCode = '', this.draft = false, }); @@ -19,6 +21,8 @@ class SidebarTaskItem { final AssistantExecutionTarget executionTarget; final bool isCurrent; final bool pending; + final String lifecycleStatus; + final String lastResultCode; final bool draft; } @@ -502,6 +506,7 @@ class _SidebarTaskTile extends StatelessWidget { Widget build(BuildContext context) { final palette = context.palette; final theme = Theme.of(context); + final statusInfo = _sidebarTaskStatusInfo(item); return Material( color: item.isCurrent ? palette.surfacePrimary : Colors.transparent, borderRadius: BorderRadius.circular(8), @@ -595,6 +600,11 @@ class _SidebarTaskTile extends StatelessWidget { color: palette.textMuted, ), ), + if (statusInfo != null) + Padding( + padding: const EdgeInsets.only(top: 4), + child: _SidebarTaskStatusChip(status: statusInfo), + ), if (onArchive != null) IconButton( key: ValueKey( @@ -622,6 +632,67 @@ class _SidebarTaskTile extends StatelessWidget { } } +class _SidebarTaskStatusChip extends StatelessWidget { + const _SidebarTaskStatusChip({required this.status}); + + final StatusInfo status; + + @override + Widget build(BuildContext context) { + final palette = context.palette; + final colors = switch (status.tone) { + StatusTone.accent => (palette.accentMuted, palette.accent), + StatusTone.warning => (palette.surfacePrimary, palette.warning), + StatusTone.success => (palette.surfacePrimary, palette.success), + StatusTone.danger => (palette.surfacePrimary, palette.danger), + StatusTone.neutral => (palette.surfacePrimary, palette.textMuted), + }; + return Container( + key: const Key('workspace-sidebar-task-status-chip'), + constraints: const BoxConstraints(minHeight: 18), + padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2), + decoration: BoxDecoration( + color: colors.$1, + borderRadius: BorderRadius.circular(999), + border: Border.all(color: colors.$2.withValues(alpha: 0.22)), + ), + child: Text( + status.label, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: Theme.of(context).textTheme.labelSmall?.copyWith( + color: colors.$2, + fontWeight: FontWeight.w700, + ), + ), + ); + } +} + +StatusInfo? _sidebarTaskStatusInfo(SidebarTaskItem item) { + if (item.draft && !item.pending) { + return null; + } + final lifecycleStatus = item.lifecycleStatus.trim().toLowerCase(); + final lastResultCode = item.lastResultCode.trim(); + final normalizedResultCode = lastResultCode.toLowerCase(); + if (lifecycleStatus == 'queued' || normalizedResultCode == 'queued') { + return StatusInfo(appText('Pending', 'Pending'), StatusTone.warning); + } + if (item.pending || + lifecycleStatus == 'running' || + normalizedResultCode == 'running') { + return StatusInfo(appText('运行', 'Running'), StatusTone.accent); + } + if (lifecycleStatus == 'ready' && + lastResultCode.isNotEmpty && + normalizedResultCode != 'queued' && + normalizedResultCode != 'running') { + return StatusInfo(appText('结束', 'Done'), StatusTone.neutral); + } + return null; +} + String _sidebarTaskUpdatedAtLabel(double? updatedAtMs) { if (updatedAtMs == null) { return ''; diff --git a/test/features/app/sidebar_navigation_task_status_test.dart b/test/features/app/sidebar_navigation_task_status_test.dart new file mode 100644 index 00000000..40b5c810 --- /dev/null +++ b/test/features/app/sidebar_navigation_task_status_test.dart @@ -0,0 +1,130 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:xworkmate/i18n/app_language.dart'; +import 'package:xworkmate/models/app_models.dart'; +import 'package:xworkmate/runtime/runtime_models.dart'; +import 'package:xworkmate/theme/app_theme.dart'; +import 'package:xworkmate/widgets/sidebar_navigation.dart'; + +void main() { + setUp(() { + setActiveAppLanguage(AppLanguage.zh); + }); + + testWidgets('sidebar task list renders lifecycle status chips', ( + tester, + ) async { + await _pumpSidebar( + tester, + items: const [ + SidebarTaskItem( + sessionKey: 'running-task', + title: '运行任务', + preview: '正在执行', + updatedAtMs: 1, + executionTarget: AssistantExecutionTarget.gateway, + isCurrent: false, + pending: true, + lifecycleStatus: 'running', + lastResultCode: 'running', + ), + SidebarTaskItem( + sessionKey: 'queued-task', + title: '排队任务', + preview: '等待执行', + updatedAtMs: 1, + executionTarget: AssistantExecutionTarget.gateway, + isCurrent: false, + pending: false, + lifecycleStatus: 'queued', + lastResultCode: 'queued', + ), + SidebarTaskItem( + sessionKey: 'finished-task', + title: '结束任务', + preview: '已完成', + updatedAtMs: 1, + executionTarget: AssistantExecutionTarget.gateway, + isCurrent: false, + pending: false, + lifecycleStatus: 'ready', + lastResultCode: 'success', + ), + ], + ); + + expect(find.text('运行'), findsOneWidget); + expect(find.text('Pending'), findsOneWidget); + expect(find.text('结束'), findsOneWidget); + expect( + find.byKey(const Key('workspace-sidebar-task-archive-running-task')), + findsNothing, + ); + expect( + find.byKey(const Key('workspace-sidebar-task-archive-finished-task')), + findsOneWidget, + ); + }); + + testWidgets('sidebar omits status chip for an idle draft task', ( + tester, + ) async { + await _pumpSidebar( + tester, + items: const [ + SidebarTaskItem( + sessionKey: 'draft:idle-task', + title: '新对话', + preview: '', + updatedAtMs: 1, + executionTarget: AssistantExecutionTarget.gateway, + isCurrent: true, + pending: false, + draft: true, + ), + ], + ); + + expect( + find.byKey(const Key('workspace-sidebar-task-status-chip')), + findsNothing, + ); + }); +} + +Future _pumpSidebar( + WidgetTester tester, { + required List items, +}) async { + await tester.pumpWidget( + MaterialApp( + theme: AppTheme.light(), + home: Material( + child: SizedBox( + width: 360, + height: 720, + child: SidebarNavigation( + currentSection: WorkspaceDestination.assistant, + sidebarState: AppSidebarState.expanded, + appLanguage: AppLanguage.zh, + themeMode: ThemeMode.light, + onSectionChanged: (_) {}, + onToggleLanguage: () {}, + onCycleSidebarState: () {}, + onExpandFromCollapsed: () {}, + onOpenAccount: () {}, + onOpenThemeToggle: () {}, + accountName: '本地操作员', + accountSubtitle: '账号', + taskItems: items, + visibleExecutionTargets: const [ + AssistantExecutionTarget.gateway, + ], + onArchiveTask: (_) async {}, + ), + ), + ), + ), + ); + await tester.pump(); +}