From e97146dc4ef4f3d5dd8c262c4f0ec199035ba0a8 Mon Sep 17 00:00:00 2001 From: Haitao Pan Date: Sat, 28 Mar 2026 19:15:30 +0800 Subject: [PATCH] refactor(assistant): split task rail closure from assistant_page_main --- .../assistant/assistant_page_components.dart | 622 +++++++++++++++++- .../assistant/assistant_page_main.dart | 622 ------------------ test/quality/wave1_file_size_guard_test.dart | 4 +- 3 files changed, 623 insertions(+), 625 deletions(-) diff --git a/lib/features/assistant/assistant_page_components.dart b/lib/features/assistant/assistant_page_components.dart index 4b591bdd..0fc62d4f 100644 --- a/lib/features/assistant/assistant_page_components.dart +++ b/lib/features/assistant/assistant_page_components.dart @@ -37,4 +37,624 @@ import 'assistant_page_composer_skill_picker.dart'; import 'assistant_page_composer_clipboard.dart'; import 'assistant_page_components_core.dart'; -// Keep this file as a lightweight anchor for compatibility. +class AssistantTaskRailInternal extends StatefulWidget { + const AssistantTaskRailInternal({ + super.key, + required this.controller, + required this.tasks, + required this.query, + required this.searchController, + required this.onQueryChanged, + required this.onClearQuery, + required this.onRefreshTasks, + required this.onCreateTask, + required this.onSelectTask, + required this.onArchiveTask, + required this.onRenameTask, + }); + + final AppController controller; + final List tasks; + final String query; + final TextEditingController searchController; + final ValueChanged onQueryChanged; + final VoidCallback onClearQuery; + final Future Function() onRefreshTasks; + final Future Function() onCreateTask; + final Future Function(String sessionKey) onSelectTask; + final Future Function(String sessionKey) onArchiveTask; + final Future Function(AssistantTaskEntryInternal entry) onRenameTask; + + @override + State createState() => + AssistantTaskRailStateInternal(); +} + +class AssistantTaskRailStateInternal extends State { + final Set expandedGroupsInternal = + {}; + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + final palette = context.palette; + final tasks = widget.tasks; + final groupedTasks = groupTasksForRailInternal(tasks); + final runningCount = tasks + .where((task) => normalizedTaskStatusInternal(task.status) == 'running') + .length; + final openCount = tasks + .where((task) => normalizedTaskStatusInternal(task.status) == 'open') + .length; + + return SurfaceCard( + borderRadius: 0, + padding: EdgeInsets.zero, + tone: SurfaceCardTone.chrome, + child: Column( + children: [ + Padding( + padding: const EdgeInsets.fromLTRB(8, 8, 8, 6), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Expanded( + child: TextField( + key: const Key('assistant-task-search'), + controller: widget.searchController, + onChanged: widget.onQueryChanged, + decoration: InputDecoration( + hintText: appText('搜索任务', 'Search tasks'), + prefixIcon: const Icon(Icons.search_rounded), + suffixIcon: widget.query.isEmpty + ? null + : IconButton( + tooltip: appText('清除搜索', 'Clear search'), + onPressed: widget.onClearQuery, + icon: const Icon(Icons.close_rounded), + ), + ), + ), + ), + const SizedBox(width: 6), + IconButton( + key: const Key('assistant-task-refresh'), + tooltip: appText('刷新任务', 'Refresh tasks'), + onPressed: () async { + await widget.onRefreshTasks(); + }, + icon: const Icon(Icons.refresh_rounded), + ), + ], + ), + const SizedBox(height: 6), + SizedBox( + width: double.infinity, + child: FilledButton.tonalIcon( + key: const Key('assistant-new-task-button'), + onPressed: () async { + await widget.onCreateTask(); + }, + icon: const Icon(Icons.edit_note_rounded), + label: Text(appText('新对话', 'New conversation')), + style: FilledButton.styleFrom( + minimumSize: const Size(0, 32), + padding: const EdgeInsets.symmetric( + horizontal: 10, + vertical: 6, + ), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(8), + ), + ), + ), + ), + const SizedBox(height: 6), + Wrap( + spacing: 6, + runSpacing: 6, + children: [ + MetaPillInternal( + label: '${appText('运行中', 'Running')} $runningCount', + icon: Icons.play_circle_outline_rounded, + ), + MetaPillInternal( + label: '${appText('当前', 'Open')} $openCount', + icon: Icons.forum_outlined, + ), + MetaPillInternal( + label: + '${appText('技能', 'Skills')} ${widget.controller.currentAssistantSkillCount}', + icon: Icons.auto_awesome_rounded, + ), + ], + ), + ], + ), + ), + Divider(height: 1, color: palette.strokeSoft), + Padding( + padding: const EdgeInsets.fromLTRB(8, 6, 8, 4), + child: Row( + children: [ + Text( + appText('任务列表', 'Task list'), + style: theme.textTheme.titleSmall, + ), + const SizedBox(width: 6), + Text( + '${tasks.length}', + style: theme.textTheme.bodySmall?.copyWith( + color: palette.textMuted, + ), + ), + ], + ), + ), + Expanded( + child: ListView.separated( + padding: const EdgeInsets.fromLTRB(4, 0, 4, 4), + itemCount: groupedTasks.length, + separatorBuilder: (_, _) => const SizedBox(height: 8), + itemBuilder: (context, index) { + final group = groupedTasks[index]; + final expanded = expandedGroupsInternal.contains( + group.executionTarget, + ); + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + AssistantTaskGroupHeaderInternal( + executionTarget: group.executionTarget, + count: group.items.length, + expanded: expanded, + onTap: () { + setState(() { + if (expanded) { + expandedGroupsInternal.remove( + group.executionTarget, + ); + } else { + expandedGroupsInternal.add(group.executionTarget); + } + }); + }, + ), + if (expanded) ...[ + const SizedBox(height: 4), + if (group.items.isEmpty) + Padding( + padding: const EdgeInsets.fromLTRB(28, 0, 8, 4), + child: Text( + appText('当前分组没有任务。', 'No tasks in this group.'), + style: theme.textTheme.bodySmall?.copyWith( + color: palette.textMuted, + ), + ), + ), + for ( + var itemIndex = 0; + itemIndex < group.items.length; + itemIndex++ + ) ...[ + if (itemIndex > 0) const SizedBox(height: 4), + AssistantTaskTileInternal( + entry: group.items[itemIndex], + archiveEnabled: + normalizedTaskStatusInternal( + group.items[itemIndex].status, + ) != + 'running', + onTap: () async { + await widget.onSelectTask( + group.items[itemIndex].sessionKey, + ); + }, + onRename: () async { + await widget.onRenameTask(group.items[itemIndex]); + }, + onArchive: () async { + await widget.onArchiveTask( + group.items[itemIndex].sessionKey, + ); + }, + ), + ], + ], + ], + ); + }, + ), + ), + ], + ), + ); + } +} + +List groupTasksForRailInternal( + List tasks, +) { + final grouped = >{ + for (final target in AssistantExecutionTarget.values) + target: [], + }; + for (final task in tasks) { + grouped[task.executionTarget]!.add(task); + } + return AssistantExecutionTarget.values + .map( + (target) => AssistantTaskGroupInternal( + executionTarget: target, + items: grouped[target]!, + ), + ) + .toList(growable: false); +} + +class AssistantTaskTileInternal extends StatelessWidget { + const AssistantTaskTileInternal({ + super.key, + required this.entry, + required this.archiveEnabled, + required this.onTap, + required this.onRename, + required this.onArchive, + }); + + final AssistantTaskEntryInternal entry; + final bool archiveEnabled; + final VoidCallback onTap; + final VoidCallback onRename; + final VoidCallback onArchive; + + @override + Widget build(BuildContext context) { + final palette = context.palette; + final theme = Theme.of(context); + final statusStyle = pillStyleForStatusInternal(context, entry.status); + + return Material( + color: entry.isCurrent ? palette.surfacePrimary : Colors.transparent, + borderRadius: BorderRadius.circular(8), + child: InkWell( + key: ValueKey('assistant-task-item-${entry.sessionKey}'), + borderRadius: BorderRadius.circular(8), + onTap: onTap, + onLongPress: onRename, + onSecondaryTap: onRename, + child: Container( + padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 7), + decoration: BoxDecoration( + color: entry.isCurrent + ? palette.surfaceSecondary + : Colors.transparent, + borderRadius: BorderRadius.circular(8), + border: Border.all( + color: entry.isCurrent ? palette.strokeSoft : Colors.transparent, + ), + ), + child: Row( + children: [ + Container( + width: 24, + height: 24, + decoration: BoxDecoration( + color: statusStyle.backgroundColor, + borderRadius: BorderRadius.circular(6), + ), + child: Icon( + entry.draft + ? Icons.edit_note_rounded + : normalizedTaskStatusInternal(entry.status) == 'running' + ? Icons.play_arrow_rounded + : Icons.task_alt_rounded, + size: 15, + color: statusStyle.foregroundColor, + ), + ), + const SizedBox(width: 8), + Expanded( + child: Text( + entry.title, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: theme.textTheme.titleSmall?.copyWith( + color: theme.colorScheme.onSurface, + fontWeight: entry.isCurrent + ? FontWeight.w600 + : FontWeight.w500, + ), + ), + ), + const SizedBox(width: 8), + Text( + entry.updatedAtLabel, + style: theme.textTheme.bodySmall?.copyWith( + color: palette.textMuted, + ), + ), + const SizedBox(width: 2), + IconButton( + key: ValueKey( + 'assistant-task-archive-${entry.sessionKey}', + ), + tooltip: appText('归档任务', 'Archive task'), + visualDensity: VisualDensity.compact, + splashRadius: 12, + onPressed: archiveEnabled ? onArchive : null, + icon: Icon( + Icons.archive_outlined, + size: 18, + color: palette.textMuted, + ), + ), + ], + ), + ), + ), + ); + } +} + +class AssistantTaskGroupHeaderInternal extends StatelessWidget { + const AssistantTaskGroupHeaderInternal({ + super.key, + required this.executionTarget, + required this.count, + required this.expanded, + required this.onTap, + }); + + final AssistantExecutionTarget executionTarget; + final int count; + final bool expanded; + final VoidCallback onTap; + + @override + Widget build(BuildContext context) { + final palette = context.palette; + final theme = Theme.of(context); + return Material( + color: Colors.transparent, + child: InkWell( + key: ValueKey('assistant-task-group-${executionTarget.name}'), + borderRadius: BorderRadius.circular(8), + onTap: onTap, + child: Padding( + padding: const EdgeInsets.fromLTRB(4, 4, 4, 2), + child: Row( + children: [ + Icon( + expanded + ? Icons.keyboard_arrow_down_rounded + : Icons.keyboard_arrow_right_rounded, + size: 16, + color: palette.textMuted, + ), + const SizedBox(width: 4), + Icon(executionTarget.icon, size: 14, color: palette.textMuted), + const SizedBox(width: 6), + Flexible( + child: Text( + executionTarget.label, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: theme.textTheme.labelMedium?.copyWith( + color: palette.textSecondary, + fontWeight: FontWeight.w600, + ), + ), + ), + const SizedBox(width: 6), + Text( + '$count', + style: theme.textTheme.bodySmall?.copyWith( + color: palette.textMuted, + ), + ), + ], + ), + ), + ), + ); + } +} + +class AssistantEmptyStateInternal extends StatelessWidget { + const AssistantEmptyStateInternal({ + super.key, + required this.controller, + required this.onFocusComposer, + required this.onOpenGateway, + required this.onOpenAiGatewaySettings, + required this.onReconnectGateway, + }); + + final AppController controller; + final VoidCallback onFocusComposer; + final VoidCallback onOpenGateway; + final VoidCallback onOpenAiGatewaySettings; + final Future Function() onReconnectGateway; + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + final connectionState = controller.currentAssistantConnectionState; + final singleAgent = connectionState.isSingleAgent; + final connected = connectionState.connected; + final singleAgentFallback = controller.currentSingleAgentUsesAiChatFallback; + final singleAgentNeedsAiGateway = + controller.currentSingleAgentNeedsAiGatewayConfiguration; + final singleAgentSuggestsAuto = + controller.currentSingleAgentShouldSuggestAutoSwitch; + final providerLabel = controller.currentSingleAgentProvider.label; + final reconnectAvailable = controller.canQuickConnectGateway; + final title = singleAgent + ? connected + ? appText('开始单机智能体任务', 'Start a single-agent task') + : singleAgentNeedsAiGateway + ? appText('先配置 LLM API', 'Configure LLM API first') + : appText('先准备外部 Agent', 'Prepare the external Agent first') + : connected + ? appText('开始对话或运行任务', 'Start a chat or run a task') + : connectionState.status == RuntimeConnectionStatus.error + ? appText('Gateway 连接失败', 'Gateway connection failed') + : appText('先连接 Gateway', 'Connect a gateway first'); + final description = singleAgent + ? connected + ? (singleAgentFallback + ? appText( + '当前没有可用的外部 Agent ACP 连接,这个线程已降级到 AI Chat fallback,不会建立 OpenClaw Gateway 会话。', + 'No external Agent ACP connection is available for this thread, so it is running in AI Chat fallback without opening an OpenClaw Gateway session.', + ) + : appText( + '当前模式使用单机智能体处理当前任务,不会建立 OpenClaw Gateway 会话。', + 'This mode uses a single agent for the current task and does not open an OpenClaw Gateway session.', + )) + : singleAgentSuggestsAuto + ? appText( + '当前线程固定为 $providerLabel,但它在这台设备上不可用。检测到其他外部 Agent ACP 端点时不会自动切换,可在工具栏里改成 Auto。', + 'This thread is pinned to $providerLabel, but it is unavailable on this device. XWorkmate will not switch to another external Agent ACP endpoint automatically. Change the provider to Auto in the toolbar.', + ) + : singleAgentNeedsAiGateway + ? appText( + '请先在 设置 -> 集成 中配置 LLM API Endpoint、LLM API Token 和默认模型,然后以单机智能体模式继续当前任务。', + 'Set the LLM API Endpoint, LLM API Token, and default model in Settings -> Integrations, then continue this task in Single Agent mode.', + ) + : appText( + '当前线程的外部 Agent ACP 连接尚未就绪。请先配置 $providerLabel 对应端点,或切换到 Auto。', + 'The external Agent ACP connection for this thread is not ready yet. Configure the endpoint for $providerLabel first, or switch to Auto.', + ) + : connected + ? appText( + '输入需求后即可开始执行,结果会回到当前会话并同步到任务页。', + 'Type a request to start execution. Results return to this session and the Tasks page.', + ) + : connectionState.pairingRequired + ? appText( + '当前设备还没通过 Gateway 配对审批。请先在已授权设备上批准该 pairing request,再重新连接。', + 'This device has not been approved yet. Approve the pairing request from an authorized device, then reconnect.', + ) + : connectionState.gatewayTokenMissing + ? appText( + '首次连接需要共享 Token;配对完成后可继续使用本机的 device token。', + 'The first connection requires a shared token; after pairing, this device can continue with its device token.', + ) + : (connectionState.lastError?.trim().isNotEmpty == true + ? connectionState.lastError!.trim() + : appText( + '连接后可直接对话、创建任务,并在当前会话查看结果。', + 'After connecting, you can chat, create tasks, and read results in this session.', + )); + + return Align( + alignment: Alignment.topCenter, + child: SingleChildScrollView( + padding: const EdgeInsets.all(8), + child: ConstrainedBox( + constraints: const BoxConstraints(maxWidth: 420), + child: Container( + key: const Key('assistant-empty-state-card'), + padding: const EdgeInsets.all(12), + decoration: BoxDecoration( + color: context.palette.surfacePrimary.withValues(alpha: 0.92), + borderRadius: BorderRadius.circular(8), + border: Border.all(color: context.palette.strokeSoft), + ), + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text(title, style: theme.textTheme.titleMedium), + const SizedBox(height: 6), + Text(description, style: theme.textTheme.bodyMedium), + const SizedBox(height: 8), + Wrap( + spacing: 6, + runSpacing: 6, + children: [ + FilledButton.icon( + onPressed: connected + ? onFocusComposer + : singleAgent + ? singleAgentNeedsAiGateway + ? onOpenAiGatewaySettings + : onFocusComposer + : reconnectAvailable + ? () async { + await onReconnectGateway(); + } + : onOpenGateway, + icon: Icon( + connected + ? Icons.edit_rounded + : singleAgent + ? singleAgentNeedsAiGateway + ? Icons.tune_rounded + : Icons.smart_toy_outlined + : reconnectAvailable + ? Icons.refresh_rounded + : Icons.link_rounded, + ), + label: Text( + connected + ? appText('开始输入', 'Start typing') + : singleAgent + ? singleAgentNeedsAiGateway + ? appText('打开配置中心', 'Open settings') + : appText('查看线程工具栏', 'Open toolbar') + : reconnectAvailable + ? appText('重新连接', 'Reconnect') + : appText('连接 Gateway', 'Connect gateway'), + ), + style: FilledButton.styleFrom( + minimumSize: const Size(0, 28), + padding: const EdgeInsets.symmetric( + horizontal: 10, + vertical: 6, + ), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(8), + ), + ), + ), + if (!connected && + (!singleAgent || singleAgentNeedsAiGateway)) + OutlinedButton.icon( + onPressed: singleAgent + ? onOpenAiGatewaySettings + : onOpenGateway, + icon: Icon( + singleAgent + ? Icons.hub_outlined + : Icons.settings_rounded, + ), + label: Text( + singleAgent + ? appText('打开设置中心', 'Open settings') + : appText('编辑连接', 'Edit connection'), + ), + style: OutlinedButton.styleFrom( + minimumSize: const Size(0, 28), + padding: const EdgeInsets.symmetric( + horizontal: 10, + vertical: 6, + ), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(8), + ), + ), + ), + ], + ), + ], + ), + ), + ), + ), + ); + } +} diff --git a/lib/features/assistant/assistant_page_main.dart b/lib/features/assistant/assistant_page_main.dart index 5ee1c0b5..5c71ed31 100644 --- a/lib/features/assistant/assistant_page_main.dart +++ b/lib/features/assistant/assistant_page_main.dart @@ -2094,625 +2094,3 @@ class ConversationAreaInternal extends StatelessWidget { ); } } - -class AssistantTaskRailInternal extends StatefulWidget { - const AssistantTaskRailInternal({ - super.key, - required this.controller, - required this.tasks, - required this.query, - required this.searchController, - required this.onQueryChanged, - required this.onClearQuery, - required this.onRefreshTasks, - required this.onCreateTask, - required this.onSelectTask, - required this.onArchiveTask, - required this.onRenameTask, - }); - - final AppController controller; - final List tasks; - final String query; - final TextEditingController searchController; - final ValueChanged onQueryChanged; - final VoidCallback onClearQuery; - final Future Function() onRefreshTasks; - final Future Function() onCreateTask; - final Future Function(String sessionKey) onSelectTask; - final Future Function(String sessionKey) onArchiveTask; - final Future Function(AssistantTaskEntryInternal entry) onRenameTask; - - @override - State createState() => - AssistantTaskRailStateInternal(); -} - -class AssistantTaskRailStateInternal extends State { - final Set expandedGroupsInternal = - {}; - - @override - Widget build(BuildContext context) { - final theme = Theme.of(context); - final palette = context.palette; - final tasks = widget.tasks; - final groupedTasks = groupTasksForRailInternal(tasks); - final runningCount = tasks - .where((task) => normalizedTaskStatusInternal(task.status) == 'running') - .length; - final openCount = tasks - .where((task) => normalizedTaskStatusInternal(task.status) == 'open') - .length; - - return SurfaceCard( - borderRadius: 0, - padding: EdgeInsets.zero, - tone: SurfaceCardTone.chrome, - child: Column( - children: [ - Padding( - padding: const EdgeInsets.fromLTRB(8, 8, 8, 6), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Row( - children: [ - Expanded( - child: TextField( - key: const Key('assistant-task-search'), - controller: widget.searchController, - onChanged: widget.onQueryChanged, - decoration: InputDecoration( - hintText: appText('搜索任务', 'Search tasks'), - prefixIcon: const Icon(Icons.search_rounded), - suffixIcon: widget.query.isEmpty - ? null - : IconButton( - tooltip: appText('清除搜索', 'Clear search'), - onPressed: widget.onClearQuery, - icon: const Icon(Icons.close_rounded), - ), - ), - ), - ), - const SizedBox(width: 6), - IconButton( - key: const Key('assistant-task-refresh'), - tooltip: appText('刷新任务', 'Refresh tasks'), - onPressed: () async { - await widget.onRefreshTasks(); - }, - icon: const Icon(Icons.refresh_rounded), - ), - ], - ), - const SizedBox(height: 6), - SizedBox( - width: double.infinity, - child: FilledButton.tonalIcon( - key: const Key('assistant-new-task-button'), - onPressed: () async { - await widget.onCreateTask(); - }, - icon: const Icon(Icons.edit_note_rounded), - label: Text(appText('新对话', 'New conversation')), - style: FilledButton.styleFrom( - minimumSize: const Size(0, 32), - padding: const EdgeInsets.symmetric( - horizontal: 10, - vertical: 6, - ), - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(8), - ), - ), - ), - ), - const SizedBox(height: 6), - Wrap( - spacing: 6, - runSpacing: 6, - children: [ - MetaPillInternal( - label: '${appText('运行中', 'Running')} $runningCount', - icon: Icons.play_circle_outline_rounded, - ), - MetaPillInternal( - label: '${appText('当前', 'Open')} $openCount', - icon: Icons.forum_outlined, - ), - MetaPillInternal( - label: - '${appText('技能', 'Skills')} ${widget.controller.currentAssistantSkillCount}', - icon: Icons.auto_awesome_rounded, - ), - ], - ), - ], - ), - ), - Divider(height: 1, color: palette.strokeSoft), - Padding( - padding: const EdgeInsets.fromLTRB(8, 6, 8, 4), - child: Row( - children: [ - Text( - appText('任务列表', 'Task list'), - style: theme.textTheme.titleSmall, - ), - const SizedBox(width: 6), - Text( - '${tasks.length}', - style: theme.textTheme.bodySmall?.copyWith( - color: palette.textMuted, - ), - ), - ], - ), - ), - Expanded( - child: ListView.separated( - padding: const EdgeInsets.fromLTRB(4, 0, 4, 4), - itemCount: groupedTasks.length, - separatorBuilder: (_, _) => const SizedBox(height: 8), - itemBuilder: (context, index) { - final group = groupedTasks[index]; - final expanded = expandedGroupsInternal.contains( - group.executionTarget, - ); - return Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - AssistantTaskGroupHeaderInternal( - executionTarget: group.executionTarget, - count: group.items.length, - expanded: expanded, - onTap: () { - setState(() { - if (expanded) { - expandedGroupsInternal.remove( - group.executionTarget, - ); - } else { - expandedGroupsInternal.add(group.executionTarget); - } - }); - }, - ), - if (expanded) ...[ - const SizedBox(height: 4), - if (group.items.isEmpty) - Padding( - padding: const EdgeInsets.fromLTRB(28, 0, 8, 4), - child: Text( - appText('当前分组没有任务。', 'No tasks in this group.'), - style: theme.textTheme.bodySmall?.copyWith( - color: palette.textMuted, - ), - ), - ), - for ( - var itemIndex = 0; - itemIndex < group.items.length; - itemIndex++ - ) ...[ - if (itemIndex > 0) const SizedBox(height: 4), - AssistantTaskTileInternal( - entry: group.items[itemIndex], - archiveEnabled: - normalizedTaskStatusInternal( - group.items[itemIndex].status, - ) != - 'running', - onTap: () async { - await widget.onSelectTask( - group.items[itemIndex].sessionKey, - ); - }, - onRename: () async { - await widget.onRenameTask(group.items[itemIndex]); - }, - onArchive: () async { - await widget.onArchiveTask( - group.items[itemIndex].sessionKey, - ); - }, - ), - ], - ], - ], - ); - }, - ), - ), - ], - ), - ); - } -} - -List groupTasksForRailInternal( - List tasks, -) { - final grouped = >{ - for (final target in AssistantExecutionTarget.values) - target: [], - }; - for (final task in tasks) { - grouped[task.executionTarget]!.add(task); - } - return AssistantExecutionTarget.values - .map( - (target) => AssistantTaskGroupInternal( - executionTarget: target, - items: grouped[target]!, - ), - ) - .toList(growable: false); -} - -class AssistantTaskTileInternal extends StatelessWidget { - const AssistantTaskTileInternal({ - super.key, - required this.entry, - required this.archiveEnabled, - required this.onTap, - required this.onRename, - required this.onArchive, - }); - - final AssistantTaskEntryInternal entry; - final bool archiveEnabled; - final VoidCallback onTap; - final VoidCallback onRename; - final VoidCallback onArchive; - - @override - Widget build(BuildContext context) { - final palette = context.palette; - final theme = Theme.of(context); - final statusStyle = pillStyleForStatusInternal(context, entry.status); - - return Material( - color: entry.isCurrent ? palette.surfacePrimary : Colors.transparent, - borderRadius: BorderRadius.circular(8), - child: InkWell( - key: ValueKey('assistant-task-item-${entry.sessionKey}'), - borderRadius: BorderRadius.circular(8), - onTap: onTap, - onLongPress: onRename, - onSecondaryTap: onRename, - child: Container( - padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 7), - decoration: BoxDecoration( - color: entry.isCurrent - ? palette.surfaceSecondary - : Colors.transparent, - borderRadius: BorderRadius.circular(8), - border: Border.all( - color: entry.isCurrent ? palette.strokeSoft : Colors.transparent, - ), - ), - child: Row( - children: [ - Container( - width: 24, - height: 24, - decoration: BoxDecoration( - color: statusStyle.backgroundColor, - borderRadius: BorderRadius.circular(6), - ), - child: Icon( - entry.draft - ? Icons.edit_note_rounded - : normalizedTaskStatusInternal(entry.status) == 'running' - ? Icons.play_arrow_rounded - : Icons.task_alt_rounded, - size: 15, - color: statusStyle.foregroundColor, - ), - ), - const SizedBox(width: 8), - Expanded( - child: Text( - entry.title, - maxLines: 1, - overflow: TextOverflow.ellipsis, - style: theme.textTheme.titleSmall?.copyWith( - color: theme.colorScheme.onSurface, - fontWeight: entry.isCurrent - ? FontWeight.w600 - : FontWeight.w500, - ), - ), - ), - const SizedBox(width: 8), - Text( - entry.updatedAtLabel, - style: theme.textTheme.bodySmall?.copyWith( - color: palette.textMuted, - ), - ), - const SizedBox(width: 2), - IconButton( - key: ValueKey( - 'assistant-task-archive-${entry.sessionKey}', - ), - tooltip: appText('归档任务', 'Archive task'), - visualDensity: VisualDensity.compact, - splashRadius: 12, - onPressed: archiveEnabled ? onArchive : null, - icon: Icon( - Icons.archive_outlined, - size: 18, - color: palette.textMuted, - ), - ), - ], - ), - ), - ), - ); - } -} - -class AssistantTaskGroupHeaderInternal extends StatelessWidget { - const AssistantTaskGroupHeaderInternal({ - super.key, - required this.executionTarget, - required this.count, - required this.expanded, - required this.onTap, - }); - - final AssistantExecutionTarget executionTarget; - final int count; - final bool expanded; - final VoidCallback onTap; - - @override - Widget build(BuildContext context) { - final palette = context.palette; - final theme = Theme.of(context); - return Material( - color: Colors.transparent, - child: InkWell( - key: ValueKey('assistant-task-group-${executionTarget.name}'), - borderRadius: BorderRadius.circular(8), - onTap: onTap, - child: Padding( - padding: const EdgeInsets.fromLTRB(4, 4, 4, 2), - child: Row( - children: [ - Icon( - expanded - ? Icons.keyboard_arrow_down_rounded - : Icons.keyboard_arrow_right_rounded, - size: 16, - color: palette.textMuted, - ), - const SizedBox(width: 4), - Icon(executionTarget.icon, size: 14, color: palette.textMuted), - const SizedBox(width: 6), - Flexible( - child: Text( - executionTarget.label, - maxLines: 1, - overflow: TextOverflow.ellipsis, - style: theme.textTheme.labelMedium?.copyWith( - color: palette.textSecondary, - fontWeight: FontWeight.w600, - ), - ), - ), - const SizedBox(width: 6), - Text( - '$count', - style: theme.textTheme.bodySmall?.copyWith( - color: palette.textMuted, - ), - ), - ], - ), - ), - ), - ); - } -} - -class AssistantEmptyStateInternal extends StatelessWidget { - const AssistantEmptyStateInternal({ - super.key, - required this.controller, - required this.onFocusComposer, - required this.onOpenGateway, - required this.onOpenAiGatewaySettings, - required this.onReconnectGateway, - }); - - final AppController controller; - final VoidCallback onFocusComposer; - final VoidCallback onOpenGateway; - final VoidCallback onOpenAiGatewaySettings; - final Future Function() onReconnectGateway; - - @override - Widget build(BuildContext context) { - final theme = Theme.of(context); - final connectionState = controller.currentAssistantConnectionState; - final singleAgent = connectionState.isSingleAgent; - final connected = connectionState.connected; - final singleAgentFallback = controller.currentSingleAgentUsesAiChatFallback; - final singleAgentNeedsAiGateway = - controller.currentSingleAgentNeedsAiGatewayConfiguration; - final singleAgentSuggestsAuto = - controller.currentSingleAgentShouldSuggestAutoSwitch; - final providerLabel = controller.currentSingleAgentProvider.label; - final reconnectAvailable = controller.canQuickConnectGateway; - final title = singleAgent - ? connected - ? appText('开始单机智能体任务', 'Start a single-agent task') - : singleAgentNeedsAiGateway - ? appText('先配置 LLM API', 'Configure LLM API first') - : appText('先准备外部 Agent', 'Prepare the external Agent first') - : connected - ? appText('开始对话或运行任务', 'Start a chat or run a task') - : connectionState.status == RuntimeConnectionStatus.error - ? appText('Gateway 连接失败', 'Gateway connection failed') - : appText('先连接 Gateway', 'Connect a gateway first'); - final description = singleAgent - ? connected - ? (singleAgentFallback - ? appText( - '当前没有可用的外部 Agent ACP 连接,这个线程已降级到 AI Chat fallback,不会建立 OpenClaw Gateway 会话。', - 'No external Agent ACP connection is available for this thread, so it is running in AI Chat fallback without opening an OpenClaw Gateway session.', - ) - : appText( - '当前模式使用单机智能体处理当前任务,不会建立 OpenClaw Gateway 会话。', - 'This mode uses a single agent for the current task and does not open an OpenClaw Gateway session.', - )) - : singleAgentSuggestsAuto - ? appText( - '当前线程固定为 $providerLabel,但它在这台设备上不可用。检测到其他外部 Agent ACP 端点时不会自动切换,可在工具栏里改成 Auto。', - 'This thread is pinned to $providerLabel, but it is unavailable on this device. XWorkmate will not switch to another external Agent ACP endpoint automatically. Change the provider to Auto in the toolbar.', - ) - : singleAgentNeedsAiGateway - ? appText( - '请先在 设置 -> 集成 中配置 LLM API Endpoint、LLM API Token 和默认模型,然后以单机智能体模式继续当前任务。', - 'Set the LLM API Endpoint, LLM API Token, and default model in Settings -> Integrations, then continue this task in Single Agent mode.', - ) - : appText( - '当前线程的外部 Agent ACP 连接尚未就绪。请先配置 $providerLabel 对应端点,或切换到 Auto。', - 'The external Agent ACP connection for this thread is not ready yet. Configure the endpoint for $providerLabel first, or switch to Auto.', - ) - : connected - ? appText( - '输入需求后即可开始执行,结果会回到当前会话并同步到任务页。', - 'Type a request to start execution. Results return to this session and the Tasks page.', - ) - : connectionState.pairingRequired - ? appText( - '当前设备还没通过 Gateway 配对审批。请先在已授权设备上批准该 pairing request,再重新连接。', - 'This device has not been approved yet. Approve the pairing request from an authorized device, then reconnect.', - ) - : connectionState.gatewayTokenMissing - ? appText( - '首次连接需要共享 Token;配对完成后可继续使用本机的 device token。', - 'The first connection requires a shared token; after pairing, this device can continue with its device token.', - ) - : (connectionState.lastError?.trim().isNotEmpty == true - ? connectionState.lastError!.trim() - : appText( - '连接后可直接对话、创建任务,并在当前会话查看结果。', - 'After connecting, you can chat, create tasks, and read results in this session.', - )); - - return Align( - alignment: Alignment.topCenter, - child: SingleChildScrollView( - padding: const EdgeInsets.all(8), - child: ConstrainedBox( - constraints: const BoxConstraints(maxWidth: 420), - child: Container( - key: const Key('assistant-empty-state-card'), - padding: const EdgeInsets.all(12), - decoration: BoxDecoration( - color: context.palette.surfacePrimary.withValues(alpha: 0.92), - borderRadius: BorderRadius.circular(8), - border: Border.all(color: context.palette.strokeSoft), - ), - child: Column( - mainAxisSize: MainAxisSize.min, - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text(title, style: theme.textTheme.titleMedium), - const SizedBox(height: 6), - Text(description, style: theme.textTheme.bodyMedium), - const SizedBox(height: 8), - Wrap( - spacing: 6, - runSpacing: 6, - children: [ - FilledButton.icon( - onPressed: connected - ? onFocusComposer - : singleAgent - ? singleAgentNeedsAiGateway - ? onOpenAiGatewaySettings - : onFocusComposer - : reconnectAvailable - ? () async { - await onReconnectGateway(); - } - : onOpenGateway, - icon: Icon( - connected - ? Icons.edit_rounded - : singleAgent - ? singleAgentNeedsAiGateway - ? Icons.tune_rounded - : Icons.smart_toy_outlined - : reconnectAvailable - ? Icons.refresh_rounded - : Icons.link_rounded, - ), - label: Text( - connected - ? appText('开始输入', 'Start typing') - : singleAgent - ? singleAgentNeedsAiGateway - ? appText('打开配置中心', 'Open settings') - : appText('查看线程工具栏', 'Open toolbar') - : reconnectAvailable - ? appText('重新连接', 'Reconnect') - : appText('连接 Gateway', 'Connect gateway'), - ), - style: FilledButton.styleFrom( - minimumSize: const Size(0, 28), - padding: const EdgeInsets.symmetric( - horizontal: 10, - vertical: 6, - ), - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(8), - ), - ), - ), - if (!connected && - (!singleAgent || singleAgentNeedsAiGateway)) - OutlinedButton.icon( - onPressed: singleAgent - ? onOpenAiGatewaySettings - : onOpenGateway, - icon: Icon( - singleAgent - ? Icons.hub_outlined - : Icons.settings_rounded, - ), - label: Text( - singleAgent - ? appText('打开设置中心', 'Open settings') - : appText('编辑连接', 'Edit connection'), - ), - style: OutlinedButton.styleFrom( - minimumSize: const Size(0, 28), - padding: const EdgeInsets.symmetric( - horizontal: 10, - vertical: 6, - ), - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(8), - ), - ), - ), - ], - ), - ], - ), - ), - ), - ), - ); - } -} diff --git a/test/quality/wave1_file_size_guard_test.dart b/test/quality/wave1_file_size_guard_test.dart index ce7570fd..361114f4 100644 --- a/test/quality/wave1_file_size_guard_test.dart +++ b/test/quality/wave1_file_size_guard_test.dart @@ -23,8 +23,8 @@ void main() { 'test/runtime/app_controller_assistant_flow_suite.dart': 800, 'test/runtime/app_controller_thread_skills_suite.dart': 800, - // Baseline caps for legacy oversized closures; tighten after T2/T3. - 'lib/features/assistant/assistant_page_main.dart': 2800, + // Tightened in T2 after assistant/composer closure split. + 'lib/features/assistant/assistant_page_main.dart': 2200, 'lib/app/app_controller_desktop_runtime_helpers.dart': 950, 'lib/app/app_controller_desktop_thread_sessions.dart': 1050, };