From 9fbbfe881ee874c8a6d0fb1c2f114da68eb4ddd5 Mon Sep 17 00:00:00 2001 From: Haitao Pan Date: Wed, 20 May 2026 14:49:22 +0800 Subject: [PATCH] feat: manage archived assistant tasks --- config/feature_flags.yaml | 18 ++ lib/app/app_controller_desktop_core.dart | 2 + ...pp_controller_desktop_thread_sessions.dart | 18 ++ ...ontroller_desktop_workspace_execution.dart | 40 +++ lib/app/ui_feature_manifest_core.dart | 6 +- .../settings_archived_tasks_panel.dart | 251 ++++++++++++++++++ lib/features/settings/settings_page_core.dart | 156 ++++++++--- lib/models/app_models.dart | 3 +- test/features/app/app_shell_surface_test.dart | 17 +- .../settings_archived_tasks_panel_test.dart | 111 ++++++++ .../assistant_archived_tasks_test.dart | 107 ++++++++ 11 files changed, 685 insertions(+), 44 deletions(-) create mode 100644 lib/features/settings/settings_archived_tasks_panel.dart create mode 100644 test/features/settings/settings_archived_tasks_panel_test.dart create mode 100644 test/runtime/assistant_archived_tasks_test.dart diff --git a/config/feature_flags.yaml b/config/feature_flags.yaml index 1e84d6fe..162c2991 100644 --- a/config/feature_flags.yaml +++ b/config/feature_flags.yaml @@ -61,6 +61,12 @@ mobile: build_modes: [debug, profile, release] description: Mobile bridge and integration settings ui_surface: settings_page + archived_tasks: + enabled: true + release_tier: stable + build_modes: [debug, profile, release] + description: Mobile archived task management + ui_surface: settings_page account_access: enabled: true release_tier: stable @@ -150,6 +156,12 @@ desktop: build_modes: [debug, profile, release] description: Desktop bridge and integration settings ui_surface: settings_page + archived_tasks: + enabled: true + release_tier: stable + build_modes: [debug, profile, release] + description: Desktop archived task management + ui_surface: settings_page account_access: enabled: true release_tier: stable @@ -239,6 +251,12 @@ web: build_modes: [debug, profile, release] description: Web bridge and integration settings ui_surface: settings_page + archived_tasks: + enabled: true + release_tier: stable + build_modes: [debug, profile, release] + description: Web archived task management + ui_surface: settings_page account_access: enabled: true release_tier: stable diff --git a/lib/app/app_controller_desktop_core.dart b/lib/app/app_controller_desktop_core.dart index dcf789d8..668057f9 100644 --- a/lib/app/app_controller_desktop_core.dart +++ b/lib/app/app_controller_desktop_core.dart @@ -512,6 +512,8 @@ class AppController extends ChangeNotifier { sessionsControllerInternal.sessions; List get assistantSessions => assistantSessionsInternal(); + List get archivedAssistantSessions => + archivedAssistantSessionsInternal(); List get skills => skillsControllerInternal.items; List get models => modelsControllerInternal.items; List get cronJobs => cronJobsControllerInternal.items; diff --git a/lib/app/app_controller_desktop_thread_sessions.dart b/lib/app/app_controller_desktop_thread_sessions.dart index 19bc1d37..1c20dcfd 100644 --- a/lib/app/app_controller_desktop_thread_sessions.dart +++ b/lib/app/app_controller_desktop_thread_sessions.dart @@ -650,6 +650,24 @@ extension AppControllerDesktopThreadSessions on AppController { ); return items; } + + List archivedAssistantSessionsInternal() { + final items = []; + for (final record in assistantThreadRecordsInternal.values) { + final sessionKey = normalizedAssistantSessionKeyInternal( + record.sessionKey, + ); + if (!isAppOwnedAssistantSessionKeyInternal(sessionKey) || + !record.archived) { + continue; + } + items.add(assistantSessionSummaryForInternal(sessionKey, record: record)); + } + items.sort((left, right) { + return (right.updatedAtMs ?? 0).compareTo(left.updatedAtMs ?? 0); + }); + return items; + } } AssistantExecutionTarget resolveAssistantExecutionTargetFromRecordForTest( diff --git a/lib/app/app_controller_desktop_workspace_execution.dart b/lib/app/app_controller_desktop_workspace_execution.dart index d53c4aa5..09e15afe 100644 --- a/lib/app/app_controller_desktop_workspace_execution.dart +++ b/lib/app/app_controller_desktop_workspace_execution.dart @@ -458,4 +458,44 @@ extension AppControllerDesktopWorkspaceExecution on AppController { recomputeTasksInternal(); notifyIfActiveInternal(); } + + Future deleteArchivedAssistantTask(String sessionKey) async { + final normalizedSessionKey = normalizedAssistantSessionKeyInternal( + sessionKey, + ); + if (normalizedSessionKey.isEmpty || + !isAppOwnedAssistantSessionKeyInternal(normalizedSessionKey)) { + return; + } + final record = assistantThreadRecordsInternal[normalizedSessionKey]; + if (record == null || !record.archived) { + return; + } + final wasCurrent = matchesSessionKey( + sessionsControllerInternal.currentSessionKey, + normalizedSessionKey, + ); + taskThreadRepositoryInternal.removeWhere( + (key, _) => + normalizedAssistantSessionKeyInternal(key) == normalizedSessionKey, + ); + assistantThreadMessagesInternal.remove(normalizedSessionKey); + localSessionMessagesInternal.remove(normalizedSessionKey); + aiGatewayStreamingTextBySessionInternal.remove(normalizedSessionKey); + aiGatewayPendingSessionKeysInternal.remove(normalizedSessionKey); + aiGatewayAbortedSessionKeysInternal.remove(normalizedSessionKey); + assistantThreadTurnQueuesInternal.remove(normalizedSessionKey); + openClawGatewayQueuedTurnsBySessionInternal.remove(normalizedSessionKey); + openClawGatewayQueuedTurnsInternal.removeWhere( + (turn) => + normalizedAssistantSessionKeyInternal(turn.sessionKey) == + normalizedSessionKey, + ); + recomputeTasksInternal(); + if (wasCurrent) { + await ensureActiveAssistantThreadInternal(); + } + await flushAssistantThreadPersistenceInternal(); + notifyIfActiveInternal(); + } } diff --git a/lib/app/ui_feature_manifest_core.dart b/lib/app/ui_feature_manifest_core.dart index 129c26f9..329a1b39 100644 --- a/lib/app/ui_feature_manifest_core.dart +++ b/lib/app/ui_feature_manifest_core.dart @@ -46,6 +46,7 @@ abstract final class UiFeatureKeys { static const assistantLocalRuntime = 'assistant.local_runtime'; static const settingsGateway = 'settings.gateway'; + static const settingsArchivedTasks = 'settings.archived_tasks'; static const settingsAccountAccess = 'settings.account_access'; static const settingsVaultServer = 'settings.vault_server'; static const settingsExperimentalCanvas = 'settings.experimental_canvas'; @@ -362,7 +363,10 @@ class UiFeatureAccess { }; static const Map settingsTabMappingsInternal = - {UiFeatureKeys.settingsGateway: SettingsTab.gateway}; + { + UiFeatureKeys.settingsGateway: SettingsTab.gateway, + UiFeatureKeys.settingsArchivedTasks: SettingsTab.archivedTasks, + }; bool isEnabledPath(String path) { final parts = path.split('.'); diff --git a/lib/features/settings/settings_archived_tasks_panel.dart b/lib/features/settings/settings_archived_tasks_panel.dart new file mode 100644 index 00000000..60b05be0 --- /dev/null +++ b/lib/features/settings/settings_archived_tasks_panel.dart @@ -0,0 +1,251 @@ +import 'package:flutter/material.dart'; + +import '../../i18n/app_language.dart'; +import '../../runtime/runtime_models.dart'; +import '../../theme/app_palette.dart'; + +class SettingsArchivedTasksPanel extends StatelessWidget { + const SettingsArchivedTasksPanel({ + super.key, + required this.sessions, + required this.onRestore, + required this.onDelete, + }); + + final List sessions; + final Future Function(String sessionKey) onRestore; + final Future Function(String sessionKey) onDelete; + + @override + Widget build(BuildContext context) { + final palette = context.palette; + final theme = Theme.of(context); + return Column( + key: const ValueKey('settings-archived-tasks-panel'), + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Row( + children: [ + Icon(Icons.inventory_2_outlined, color: palette.textSecondary), + const SizedBox(width: 10), + Expanded( + child: Text( + appText('归档任务管理', 'Archived task management'), + style: theme.textTheme.titleMedium?.copyWith( + fontWeight: FontWeight.w700, + ), + ), + ), + Text( + appText('${sessions.length} 条', '${sessions.length} items'), + style: theme.textTheme.bodySmall?.copyWith( + color: palette.textMuted, + fontWeight: FontWeight.w600, + ), + ), + ], + ), + const SizedBox(height: 12), + if (sessions.isEmpty) + _ArchivedTasksEmptyState() + else + Column( + children: [ + for (final session in sessions) ...[ + _ArchivedTaskTile( + session: session, + onRestore: () => onRestore(session.key), + onDelete: () async { + final confirmed = await _confirmDelete(context, session); + if (confirmed) { + await onDelete(session.key); + } + }, + ), + if (session != sessions.last) + Divider(height: 1, color: palette.strokeSoft), + ], + ], + ), + ], + ); + } + + Future _confirmDelete( + BuildContext context, + GatewaySessionSummary session, + ) async { + final result = await showDialog( + context: context, + builder: (context) => AlertDialog( + title: Text(appText('彻底删除归档记录', 'Delete archived record')), + content: Text( + appText( + '将从 XWorkmate 中删除「${session.label}」的任务记录和消息状态。此操作不会删除本地线程工作目录里的文件。', + 'This removes "${session.label}" from XWorkmate task records and message state. Files in the local thread workspace are not deleted.', + ), + ), + actions: [ + TextButton( + onPressed: () => Navigator.of(context).pop(false), + child: Text(appText('取消', 'Cancel')), + ), + FilledButton.tonalIcon( + key: const ValueKey('settings-archived-task-confirm-delete'), + onPressed: () => Navigator.of(context).pop(true), + icon: const Icon(Icons.delete_outline_rounded), + label: Text(appText('删除记录', 'Delete record')), + ), + ], + ), + ); + return result ?? false; + } +} + +class _ArchivedTasksEmptyState extends StatelessWidget { + @override + Widget build(BuildContext context) { + final palette = context.palette; + final theme = Theme.of(context); + return Container( + key: const ValueKey('settings-archived-tasks-empty'), + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 24), + decoration: BoxDecoration( + color: palette.surfaceSecondary, + borderRadius: BorderRadius.circular(8), + border: Border.all(color: palette.strokeSoft), + ), + child: Column( + children: [ + Icon(Icons.archive_outlined, size: 28, color: palette.textMuted), + const SizedBox(height: 8), + Text( + appText('暂无归档任务', 'No archived tasks'), + style: theme.textTheme.titleSmall?.copyWith( + fontWeight: FontWeight.w700, + ), + ), + ], + ), + ); + } +} + +class _ArchivedTaskTile extends StatelessWidget { + const _ArchivedTaskTile({ + required this.session, + required this.onRestore, + required this.onDelete, + }); + + final GatewaySessionSummary session; + final Future Function() onRestore; + final Future Function() onDelete; + + @override + Widget build(BuildContext context) { + final palette = context.palette; + final theme = Theme.of(context); + final preview = session.lastMessagePreview?.trim() ?? ''; + return Padding( + padding: const EdgeInsets.symmetric(vertical: 12), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Container( + width: 34, + height: 34, + decoration: BoxDecoration( + color: palette.surfaceSecondary, + borderRadius: BorderRadius.circular(8), + border: Border.all(color: palette.strokeSoft), + ), + child: Icon( + Icons.archive_outlined, + color: palette.textSecondary, + size: 18, + ), + ), + const SizedBox(width: 12), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + session.label, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: theme.textTheme.titleSmall?.copyWith( + fontWeight: FontWeight.w700, + ), + ), + if (preview.isNotEmpty) ...[ + const SizedBox(height: 3), + Text( + preview, + maxLines: 2, + overflow: TextOverflow.ellipsis, + style: theme.textTheme.bodySmall?.copyWith( + color: palette.textMuted, + ), + ), + ], + const SizedBox(height: 4), + Text( + _archivedTaskUpdatedAtLabel(session.updatedAtMs), + style: theme.textTheme.bodySmall?.copyWith( + color: palette.textMuted, + ), + ), + ], + ), + ), + const SizedBox(width: 12), + Wrap( + spacing: 8, + runSpacing: 8, + alignment: WrapAlignment.end, + children: [ + FilledButton.tonalIcon( + key: ValueKey( + 'settings-archived-task-restore-${session.key}', + ), + onPressed: () async { + await onRestore(); + }, + icon: const Icon(Icons.unarchive_outlined), + label: Text(appText('解除归档', 'Restore')), + ), + IconButton( + key: ValueKey( + 'settings-archived-task-delete-${session.key}', + ), + tooltip: appText('彻底删除归档记录', 'Delete archived record'), + onPressed: () async { + await onDelete(); + }, + icon: const Icon(Icons.delete_outline_rounded), + ), + ], + ), + ], + ), + ); + } +} + +String _archivedTaskUpdatedAtLabel(double? updatedAtMs) { + if (updatedAtMs == null) { + return appText('无更新时间', 'No update time'); + } + final timestamp = DateTime.fromMillisecondsSinceEpoch(updatedAtMs.round()); + final date = + '${timestamp.year.toString().padLeft(4, '0')}-' + '${timestamp.month.toString().padLeft(2, '0')}-' + '${timestamp.day.toString().padLeft(2, '0')}'; + final time = + '${timestamp.hour.toString().padLeft(2, '0')}:' + '${timestamp.minute.toString().padLeft(2, '0')}'; + return appText('归档前更新于 $date $time', 'Updated before archive $date $time'); +} diff --git a/lib/features/settings/settings_page_core.dart b/lib/features/settings/settings_page_core.dart index 2324ffaf..62ba5ebc 100644 --- a/lib/features/settings/settings_page_core.dart +++ b/lib/features/settings/settings_page_core.dart @@ -6,6 +6,7 @@ import 'package:flutter/material.dart'; import '../../app/app_controller.dart'; import '../../app/app_metadata.dart'; +import '../../app/ui_feature_manifest.dart'; import '../../app/workspace_navigation.dart'; import '../../i18n/app_language.dart'; import '../../models/app_models.dart'; @@ -15,6 +16,7 @@ import '../../widgets/settings_page_shell.dart'; import '../../widgets/surface_card.dart'; import 'settings_account_panel.dart'; import 'settings_about_panel.dart'; +import 'settings_archived_tasks_panel.dart'; Future> loadBridgeMetadataForSettingsAbout({ required Uri bridgeEndpoint, @@ -326,6 +328,14 @@ class _SettingsPageState extends State { }); } + Future _restoreArchivedTask(String sessionKey) async { + await widget.controller.saveAssistantTaskArchived(sessionKey, false); + } + + Future _deleteArchivedTask(String sessionKey) async { + await widget.controller.deleteArchivedAssistantTask(sessionKey); + } + Future _loadAboutSnapshot() async { final bridgeMetadata = await _loadBridgeMetadata(); return SettingsAboutSnapshot( @@ -368,13 +378,14 @@ class _SettingsPageState extends State { final accountMfaRequired = controller.settingsController.accountMfaRequired; final accountSession = controller.settingsController.accountSession; + final currentTab = controller.settingsTab; + final availableTabs = controller + .featuresFor(resolveUiFeaturePlatformFromContext(context)) + .availableSettingsTabs; return SettingsPageBodyShell( padding: const EdgeInsets.fromLTRB(24, 24, 24, 0), - breadcrumbs: buildSettingsBreadcrumbs( - controller, - tab: SettingsTab.gateway, - ), + breadcrumbs: buildSettingsBreadcrumbs(controller, tab: currentTab), title: appText('设置', 'Settings'), subtitle: appText( '配置 XWorkmate 工作区、网关默认项、界面与诊断选项', @@ -391,44 +402,62 @@ class _SettingsPageState extends State { ), ), bodyChildren: [ - SurfaceCard( - key: const ValueKey('settings-account-panel-card'), - child: SettingsAccountPanel( - settings: currentSettings, - accountSession: accountSession, - accountState: accountState, - accountBusy: accountBusy, - accountStatus: accountStatus, - accountSignedIn: accountSignedIn, - accountMfaRequired: accountMfaRequired, - accountBaseUrlController: _accountBaseUrlController, - accountIdentifierController: _accountIdentifierController, - accountPasswordController: _accountPasswordController, - accountMfaCodeController: _accountMfaCodeController, - bridgeUrlController: _bridgeUrlController, - bridgeTokenController: _bridgeTokenController, - onSaveAccountProfile: ({required bool isManualBridge}) => - _persistAccountProfileSettings( - widget.controller.settings, - isManualBridge: isManualBridge, - ), - onLogin: () => _loginAccount(widget.controller.settings), - onVerifyMfa: () => - _verifyAccountMfa(widget.controller.settings), - onCancelMfa: _cancelAccountMfa, - onSync: () => _syncAccount(widget.controller.settings), - onLogout: _logoutAccount, + if (availableTabs.length > 1) ...[ + _SettingsTabSelector( + currentTab: currentTab, + availableTabs: availableTabs, + onChanged: (tab) => controller.openSettings(tab: tab), ), - ), - const SizedBox(height: 24), - SurfaceCard( - key: const ValueKey('settings-about-panel-card'), - child: SettingsAboutPanel( - snapshot: _aboutSnapshot, - busy: _aboutBusy, - onRefresh: _refreshAboutSnapshot, + const SizedBox(height: 18), + ], + if (currentTab == SettingsTab.gateway) ...[ + SurfaceCard( + key: const ValueKey('settings-account-panel-card'), + child: SettingsAccountPanel( + settings: currentSettings, + accountSession: accountSession, + accountState: accountState, + accountBusy: accountBusy, + accountStatus: accountStatus, + accountSignedIn: accountSignedIn, + accountMfaRequired: accountMfaRequired, + accountBaseUrlController: _accountBaseUrlController, + accountIdentifierController: _accountIdentifierController, + accountPasswordController: _accountPasswordController, + accountMfaCodeController: _accountMfaCodeController, + bridgeUrlController: _bridgeUrlController, + bridgeTokenController: _bridgeTokenController, + onSaveAccountProfile: ({required bool isManualBridge}) => + _persistAccountProfileSettings( + widget.controller.settings, + isManualBridge: isManualBridge, + ), + onLogin: () => _loginAccount(widget.controller.settings), + onVerifyMfa: () => + _verifyAccountMfa(widget.controller.settings), + onCancelMfa: _cancelAccountMfa, + onSync: () => _syncAccount(widget.controller.settings), + onLogout: _logoutAccount, + ), + ), + const SizedBox(height: 24), + SurfaceCard( + key: const ValueKey('settings-about-panel-card'), + child: SettingsAboutPanel( + snapshot: _aboutSnapshot, + busy: _aboutBusy, + onRefresh: _refreshAboutSnapshot, + ), + ), + ] else if (currentTab == SettingsTab.archivedTasks) + SurfaceCard( + key: const ValueKey('settings-archived-tasks-panel-card'), + child: SettingsArchivedTasksPanel( + sessions: controller.archivedAssistantSessions, + onRestore: _restoreArchivedTask, + onDelete: _deleteArchivedTask, + ), ), - ), ], ); }, @@ -436,6 +465,53 @@ class _SettingsPageState extends State { } } +class _SettingsTabSelector extends StatelessWidget { + const _SettingsTabSelector({ + required this.currentTab, + required this.availableTabs, + required this.onChanged, + }); + + final SettingsTab currentTab; + final List availableTabs; + final ValueChanged onChanged; + + @override + Widget build(BuildContext context) { + final selectedTab = availableTabs.contains(currentTab) + ? currentTab + : availableTabs.first; + return Align( + alignment: Alignment.centerLeft, + child: SegmentedButton( + key: const ValueKey('settings-tab-selector'), + segments: [ + for (final tab in availableTabs) + ButtonSegment( + value: tab, + icon: Icon( + tab == SettingsTab.archivedTasks + ? Icons.inventory_2_outlined + : Icons.hub_outlined, + ), + label: Text(tab.label), + ), + ], + selected: {selectedTab}, + onSelectionChanged: (selection) { + if (selection.isEmpty) { + return; + } + final next = selection.first; + if (next != selectedTab) { + onChanged(next); + } + }, + ), + ); + } +} + String _stringValue(Object? value) { return value == null ? '' : value.toString().trim(); } diff --git a/lib/models/app_models.dart b/lib/models/app_models.dart index 304474e3..8e650367 100644 --- a/lib/models/app_models.dart +++ b/lib/models/app_models.dart @@ -149,11 +149,12 @@ extension AssistantModeCopy on AssistantMode { }; } -enum SettingsTab { gateway } +enum SettingsTab { gateway, archivedTasks } extension SettingsTabCopy on SettingsTab { String get label => switch (this) { SettingsTab.gateway => appText('集成', 'Integrations'), + SettingsTab.archivedTasks => appText('归档任务', 'Archived tasks'), }; } diff --git a/test/features/app/app_shell_surface_test.dart b/test/features/app/app_shell_surface_test.dart index 39535df0..a0551ef7 100644 --- a/test/features/app/app_shell_surface_test.dart +++ b/test/features/app/app_shell_surface_test.dart @@ -2,6 +2,7 @@ import 'package:flutter/material.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:xworkmate/app/app_controller.dart'; import 'package:xworkmate/app/app_shell_desktop.dart'; +import 'package:xworkmate/models/app_models.dart'; import 'package:xworkmate/theme/app_theme.dart'; void main() { @@ -14,7 +15,9 @@ void main() { addTearDown(tester.view.resetPhysicalSize); addTearDown(tester.view.resetDevicePixelRatio); - final controller = AppController(environmentOverride: const {}); + final controller = AppController( + environmentOverride: const {}, + ); addTearDown(controller.dispose); await tester.pumpWidget( @@ -40,7 +43,9 @@ void main() { addTearDown(tester.view.resetPhysicalSize); addTearDown(tester.view.resetDevicePixelRatio); - final controller = AppController(environmentOverride: const {}); + final controller = AppController( + environmentOverride: const {}, + ); addTearDown(controller.dispose); await tester.pumpWidget( @@ -67,6 +72,14 @@ void main() { find.byKey(const Key('settings-account-panel-card')), findsOneWidget, ); + + controller.openSettings(tab: SettingsTab.archivedTasks); + await tester.pump(); + + expect( + find.byKey(const Key('settings-archived-tasks-panel-card')), + findsOneWidget, + ); }); }); } diff --git a/test/features/settings/settings_archived_tasks_panel_test.dart b/test/features/settings/settings_archived_tasks_panel_test.dart new file mode 100644 index 00000000..2200420c --- /dev/null +++ b/test/features/settings/settings_archived_tasks_panel_test.dart @@ -0,0 +1,111 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:xworkmate/features/settings/settings_archived_tasks_panel.dart'; +import 'package:xworkmate/i18n/app_language.dart'; +import 'package:xworkmate/runtime/runtime_models.dart'; +import 'package:xworkmate/theme/app_theme.dart'; +import 'package:xworkmate/widgets/surface_card.dart'; + +void main() { + setUp(() { + setActiveAppLanguage(AppLanguage.zh); + }); + + testWidgets('shows empty state when no archived tasks exist', (tester) async { + await tester.pumpWidget( + _buildTestApp( + child: SettingsArchivedTasksPanel( + sessions: const [], + onRestore: (_) async {}, + onDelete: (_) async {}, + ), + ), + ); + + expect( + find.byKey(const ValueKey('settings-archived-tasks-empty')), + findsOneWidget, + ); + expect(find.text('暂无归档任务'), findsOneWidget); + }); + + testWidgets('restores and confirms deletion for archived task records', ( + tester, + ) async { + final calls = []; + await tester.pumpWidget( + _buildTestApp( + child: SettingsArchivedTasksPanel( + sessions: const [ + GatewaySessionSummary( + key: 'draft:archived-task', + kind: 'assistant', + displayName: '导出 PDF', + surface: 'Assistant', + subject: null, + room: null, + space: null, + updatedAtMs: 1779178980000, + sessionId: 'draft:archived-task', + systemSent: false, + abortedLastRun: false, + thinkingLevel: null, + verboseLevel: null, + inputTokens: null, + outputTokens: null, + totalTokens: null, + model: null, + contextTokens: null, + derivedTitle: '导出 PDF', + lastMessagePreview: '输出为PDF文件', + ), + ], + onRestore: (sessionKey) async { + calls.add('restore:$sessionKey'); + }, + onDelete: (sessionKey) async { + calls.add('delete:$sessionKey'); + }, + ), + ), + ); + + expect(find.text('导出 PDF'), findsOneWidget); + expect(find.text('输出为PDF文件'), findsOneWidget); + + await tester.tap( + find.byKey( + const ValueKey('settings-archived-task-restore-draft:archived-task'), + ), + ); + await tester.pump(); + + expect(calls, contains('restore:draft:archived-task')); + + await tester.tap( + find.byKey( + const ValueKey('settings-archived-task-delete-draft:archived-task'), + ), + ); + await tester.pumpAndSettle(); + expect(find.text('彻底删除归档记录'), findsOneWidget); + + await tester.tap( + find.byKey(const ValueKey('settings-archived-task-confirm-delete')), + ); + await tester.pumpAndSettle(); + + expect(calls, contains('delete:draft:archived-task')); + }); +} + +Widget _buildTestApp({required Widget child}) { + return MaterialApp( + theme: AppTheme.light(), + home: Scaffold( + body: Center( + child: SizedBox(width: 720, child: SurfaceCard(child: child)), + ), + ), + ); +} diff --git a/test/runtime/assistant_archived_tasks_test.dart b/test/runtime/assistant_archived_tasks_test.dart new file mode 100644 index 00000000..6bce1891 --- /dev/null +++ b/test/runtime/assistant_archived_tasks_test.dart @@ -0,0 +1,107 @@ +import 'dart:io'; + +import 'package:flutter_test/flutter_test.dart'; +import 'package:xworkmate/app/app_controller.dart'; +import 'package:xworkmate/runtime/runtime_models.dart'; + +void main() { + group('assistant archived tasks', () { + test( + 'lists archived tasks separately and restores them to active sessions', + () async { + final home = await Directory.systemTemp.createTemp( + 'xworkmate-archived-task-home-', + ); + addTearDown(() async { + if (await home.exists()) { + await home.delete(recursive: true); + } + }); + final controller = AppController( + environmentOverride: {'HOME': home.path}, + ); + addTearDown(controller.dispose); + + const sessionKey = 'draft:archived-task'; + controller.initializeAssistantThreadContext( + sessionKey, + title: '导出 PDF', + executionTarget: AssistantExecutionTarget.gateway, + messageViewMode: AssistantMessageViewMode.rendered, + ); + controller.appendAssistantThreadMessageInternal( + sessionKey, + const GatewayChatMessage( + id: 'm-1', + role: 'user', + text: '输出为PDF文件', + timestampMs: 1779178980000, + toolCallId: null, + toolName: null, + stopReason: null, + pending: false, + error: false, + ), + ); + + await controller.saveAssistantTaskArchived(sessionKey, true); + + expect( + controller.assistantSessions.map((item) => item.key), + isNot(contains(sessionKey)), + ); + expect( + controller.archivedAssistantSessions.map((item) => item.key), + [sessionKey], + ); + expect(controller.archivedAssistantSessions.single.label, '导出 PDF'); + + await controller.saveAssistantTaskArchived(sessionKey, false); + + expect(controller.archivedAssistantSessions, isEmpty); + expect( + controller.assistantSessions.map((item) => item.key), + contains(sessionKey), + ); + }, + ); + + test('deletes only archived task records from controller state', () async { + final home = await Directory.systemTemp.createTemp( + 'xworkmate-delete-archived-task-home-', + ); + addTearDown(() async { + if (await home.exists()) { + await home.delete(recursive: true); + } + }); + final controller = AppController( + environmentOverride: {'HOME': home.path}, + ); + addTearDown(controller.dispose); + + const sessionKey = 'draft:delete-archived-task'; + controller.initializeAssistantThreadContext( + sessionKey, + title: '待删除任务', + executionTarget: AssistantExecutionTarget.gateway, + messageViewMode: AssistantMessageViewMode.rendered, + ); + await controller.saveAssistantTaskArchived(sessionKey, true); + + expect( + controller.archivedAssistantSessions.map((item) => item.key), + [sessionKey], + ); + + await controller.deleteArchivedAssistantTask(sessionKey); + + expect(controller.archivedAssistantSessions, isEmpty); + expect( + controller.assistantSessions.map((item) => item.key), + isNot(contains(sessionKey)), + ); + expect(controller.hasAssistantTaskStateInternal(sessionKey), isFalse); + }); + }); +}