feat: show sidebar task status chips

This commit is contained in:
Haitao Pan 2026-05-19 15:39:23 +08:00
parent 5ccf37bec9
commit 0e53058cd3
3 changed files with 204 additions and 0 deletions

View File

@ -56,6 +56,7 @@ class _AppShellState extends State<AppShell> {
.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<AppShell> {
),
isCurrent: sessionKey == currentSessionKey,
pending: controller.assistantSessionHasPendingRun(sessionKey),
lifecycleStatus: thread?.lifecycleState.status ?? '',
lastResultCode: thread?.lifecycleState.lastResultCode ?? '',
draft: sessionKey.startsWith('draft:'),
);
})

View File

@ -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<String>(
@ -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 '';

View File

@ -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>[
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>[
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<void> _pumpSidebar(
WidgetTester tester, {
required List<SidebarTaskItem> 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>[
AssistantExecutionTarget.gateway,
],
onArchiveTask: (_) async {},
),
),
),
),
);
await tester.pump();
}