feat: manage archived assistant tasks
This commit is contained in:
parent
91bf88ed99
commit
9fbbfe881e
@ -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
|
||||
|
||||
@ -512,6 +512,8 @@ class AppController extends ChangeNotifier {
|
||||
sessionsControllerInternal.sessions;
|
||||
List<GatewaySessionSummary> get assistantSessions =>
|
||||
assistantSessionsInternal();
|
||||
List<GatewaySessionSummary> get archivedAssistantSessions =>
|
||||
archivedAssistantSessionsInternal();
|
||||
List<GatewaySkillSummary> get skills => skillsControllerInternal.items;
|
||||
List<GatewayModelSummary> get models => modelsControllerInternal.items;
|
||||
List<GatewayCronJobSummary> get cronJobs => cronJobsControllerInternal.items;
|
||||
|
||||
@ -650,6 +650,24 @@ extension AppControllerDesktopThreadSessions on AppController {
|
||||
);
|
||||
return items;
|
||||
}
|
||||
|
||||
List<GatewaySessionSummary> archivedAssistantSessionsInternal() {
|
||||
final items = <GatewaySessionSummary>[];
|
||||
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(
|
||||
|
||||
@ -458,4 +458,44 @@ extension AppControllerDesktopWorkspaceExecution on AppController {
|
||||
recomputeTasksInternal();
|
||||
notifyIfActiveInternal();
|
||||
}
|
||||
|
||||
Future<void> 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();
|
||||
}
|
||||
}
|
||||
|
||||
@ -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<String, SettingsTab> settingsTabMappingsInternal =
|
||||
<String, SettingsTab>{UiFeatureKeys.settingsGateway: SettingsTab.gateway};
|
||||
<String, SettingsTab>{
|
||||
UiFeatureKeys.settingsGateway: SettingsTab.gateway,
|
||||
UiFeatureKeys.settingsArchivedTasks: SettingsTab.archivedTasks,
|
||||
};
|
||||
|
||||
bool isEnabledPath(String path) {
|
||||
final parts = path.split('.');
|
||||
|
||||
251
lib/features/settings/settings_archived_tasks_panel.dart
Normal file
251
lib/features/settings/settings_archived_tasks_panel.dart
Normal file
@ -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<GatewaySessionSummary> sessions;
|
||||
final Future<void> Function(String sessionKey) onRestore;
|
||||
final Future<void> 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<bool> _confirmDelete(
|
||||
BuildContext context,
|
||||
GatewaySessionSummary session,
|
||||
) async {
|
||||
final result = await showDialog<bool>(
|
||||
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<void> Function() onRestore;
|
||||
final Future<void> 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<String>(
|
||||
'settings-archived-task-restore-${session.key}',
|
||||
),
|
||||
onPressed: () async {
|
||||
await onRestore();
|
||||
},
|
||||
icon: const Icon(Icons.unarchive_outlined),
|
||||
label: Text(appText('解除归档', 'Restore')),
|
||||
),
|
||||
IconButton(
|
||||
key: ValueKey<String>(
|
||||
'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');
|
||||
}
|
||||
@ -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<Map<String, dynamic>> loadBridgeMetadataForSettingsAbout({
|
||||
required Uri bridgeEndpoint,
|
||||
@ -326,6 +328,14 @@ class _SettingsPageState extends State<SettingsPage> {
|
||||
});
|
||||
}
|
||||
|
||||
Future<void> _restoreArchivedTask(String sessionKey) async {
|
||||
await widget.controller.saveAssistantTaskArchived(sessionKey, false);
|
||||
}
|
||||
|
||||
Future<void> _deleteArchivedTask(String sessionKey) async {
|
||||
await widget.controller.deleteArchivedAssistantTask(sessionKey);
|
||||
}
|
||||
|
||||
Future<SettingsAboutSnapshot> _loadAboutSnapshot() async {
|
||||
final bridgeMetadata = await _loadBridgeMetadata();
|
||||
return SettingsAboutSnapshot(
|
||||
@ -368,13 +378,14 @@ class _SettingsPageState extends State<SettingsPage> {
|
||||
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<SettingsPage> {
|
||||
),
|
||||
),
|
||||
bodyChildren: <Widget>[
|
||||
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<SettingsPage> {
|
||||
}
|
||||
}
|
||||
|
||||
class _SettingsTabSelector extends StatelessWidget {
|
||||
const _SettingsTabSelector({
|
||||
required this.currentTab,
|
||||
required this.availableTabs,
|
||||
required this.onChanged,
|
||||
});
|
||||
|
||||
final SettingsTab currentTab;
|
||||
final List<SettingsTab> availableTabs;
|
||||
final ValueChanged<SettingsTab> onChanged;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final selectedTab = availableTabs.contains(currentTab)
|
||||
? currentTab
|
||||
: availableTabs.first;
|
||||
return Align(
|
||||
alignment: Alignment.centerLeft,
|
||||
child: SegmentedButton<SettingsTab>(
|
||||
key: const ValueKey('settings-tab-selector'),
|
||||
segments: [
|
||||
for (final tab in availableTabs)
|
||||
ButtonSegment<SettingsTab>(
|
||||
value: tab,
|
||||
icon: Icon(
|
||||
tab == SettingsTab.archivedTasks
|
||||
? Icons.inventory_2_outlined
|
||||
: Icons.hub_outlined,
|
||||
),
|
||||
label: Text(tab.label),
|
||||
),
|
||||
],
|
||||
selected: <SettingsTab>{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();
|
||||
}
|
||||
|
||||
@ -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'),
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@ -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 <String, String>{});
|
||||
final controller = AppController(
|
||||
environmentOverride: const <String, String>{},
|
||||
);
|
||||
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 <String, String>{});
|
||||
final controller = AppController(
|
||||
environmentOverride: const <String, String>{},
|
||||
);
|
||||
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,
|
||||
);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
111
test/features/settings/settings_archived_tasks_panel_test.dart
Normal file
111
test/features/settings/settings_archived_tasks_panel_test.dart
Normal file
@ -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 <GatewaySessionSummary>[],
|
||||
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 = <String>[];
|
||||
await tester.pumpWidget(
|
||||
_buildTestApp(
|
||||
child: SettingsArchivedTasksPanel(
|
||||
sessions: const <GatewaySessionSummary>[
|
||||
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)),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
107
test/runtime/assistant_archived_tasks_test.dart
Normal file
107
test/runtime/assistant_archived_tasks_test.dart
Normal file
@ -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: <String, String>{'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),
|
||||
<String>[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: <String, String>{'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),
|
||||
<String>[sessionKey],
|
||||
);
|
||||
|
||||
await controller.deleteArchivedAssistantTask(sessionKey);
|
||||
|
||||
expect(controller.archivedAssistantSessions, isEmpty);
|
||||
expect(
|
||||
controller.assistantSessions.map((item) => item.key),
|
||||
isNot(contains(sessionKey)),
|
||||
);
|
||||
expect(controller.hasAssistantTaskStateInternal(sessionKey), isFalse);
|
||||
});
|
||||
});
|
||||
}
|
||||
Loading…
Reference in New Issue
Block a user