From 2e467fa39e0d8b2a410dc90ff09346c3d7931a7f Mon Sep 17 00:00:00 2001 From: Haitao Pan Date: Sun, 15 Mar 2026 09:54:15 +0800 Subject: [PATCH] feat: unify assistant sidebar and task list --- lib/app/app_shell.dart | 65 +- lib/features/assistant/assistant_page.dart | 706 +++++++++++++++++---- lib/widgets/sidebar_navigation.dart | 36 +- test/features/assistant_page_test.dart | 122 +++- 4 files changed, 772 insertions(+), 157 deletions(-) diff --git a/lib/app/app_shell.dart b/lib/app/app_shell.dart index a6979a00..7a3a59d4 100644 --- a/lib/app/app_shell.dart +++ b/lib/app/app_shell.dart @@ -1,16 +1,16 @@ import 'package:flutter/material.dart'; - import '../features/account/account_page.dart'; - import '../features/ai_gateway/ai_gateway_page.dart'; - import '../features/assistant/assistant_page.dart'; - import '../features/claw_hub/claw_hub_page.dart'; - import '../features/mcp_server/mcp_server_page.dart'; - import '../features/mobile/ios_mobile_shell.dart'; - import '../features/modules/modules_page.dart'; - import '../features/secrets/secrets_page.dart'; - import '../features/settings/settings_page.dart'; - import '../features/skills/skills_page.dart'; - import '../features/tasks/tasks_page.dart'; +import '../features/account/account_page.dart'; +import '../features/ai_gateway/ai_gateway_page.dart'; +import '../features/assistant/assistant_page.dart'; +import '../features/claw_hub/claw_hub_page.dart'; +import '../features/mcp_server/mcp_server_page.dart'; +import '../features/mobile/ios_mobile_shell.dart'; +import '../features/modules/modules_page.dart'; +import '../features/secrets/secrets_page.dart'; +import '../features/settings/settings_page.dart'; +import '../features/skills/skills_page.dart'; +import '../features/tasks/tasks_page.dart'; import '../i18n/app_language.dart'; import '../models/app_models.dart'; import '../theme/app_palette.dart'; @@ -71,6 +71,9 @@ class _AppShellState extends State { final isMobile = constraints.maxWidth < 900; final sidebarState = controller.sidebarState; final showSidebar = sidebarState != AppSidebarState.hidden; + final embedSidebarIntoAssistant = + controller.destination == WorkspaceDestination.assistant && + showSidebar; final expandedSidebarWidth = _clampSidebarWidth( _sidebarExpandedWidth ?? _defaultSidebarWidth( @@ -197,7 +200,7 @@ class _AppShellState extends State { children: [ Row( children: [ - if (showSidebar) + if (showSidebar && !embedSidebarIntoAssistant) SidebarNavigation( currentSection: controller.destination, sidebarState: sidebarState, @@ -233,7 +236,8 @@ class _AppShellState extends State { ? expandedSidebarWidth : null, ), - if (sidebarState == AppSidebarState.expanded) + if (sidebarState == AppSidebarState.expanded && + !embedSidebarIntoAssistant) PaneResizeHandle( axis: Axis.horizontal, onDelta: (delta) { @@ -322,6 +326,41 @@ class _AppShellState extends State { WorkspaceDestination.assistant => AssistantPage( controller: widget.controller, onOpenDetail: onOpenDetail, + navigationPanelBuilder: + widget.controller.sidebarState == AppSidebarState.hidden + ? null + : (contentWidth) => SidebarNavigation( + currentSection: widget.controller.destination, + sidebarState: AppSidebarState.expanded, + appLanguage: widget.controller.appLanguage, + themeMode: widget.controller.themeMode, + onSectionChanged: widget.controller.navigateTo, + onToggleLanguage: widget.controller.toggleAppLanguage, + onCycleSidebarState: widget.controller.cycleSidebarState, + onExpandFromCollapsed: () => + widget.controller.setSidebarState(AppSidebarState.expanded), + onOpenAccount: () => + widget.controller.navigateTo(WorkspaceDestination.account), + onOpenThemeToggle: () => widget.controller.setThemeMode( + widget.controller.themeMode == ThemeMode.dark + ? ThemeMode.light + : ThemeMode.dark, + ), + accountName: + widget.controller.settings.accountUsername.trim().isEmpty + ? appText('本地操作员', 'Local Operator') + : widget.controller.settings.accountUsername, + accountSubtitle: + widget.controller.settings.accountWorkspace.trim().isEmpty + ? appText('账号', 'Account') + : widget.controller.settings.accountWorkspace, + expandedWidthOverride: contentWidth, + marginOverride: EdgeInsets.zero, + showCollapseControl: false, + ), + showStandaloneTaskRail: false, + unifiedPaneStartsCollapsed: + widget.controller.sidebarState == AppSidebarState.collapsed, ), WorkspaceDestination.tasks => TasksPage( controller: widget.controller, diff --git a/lib/features/assistant/assistant_page.dart b/lib/features/assistant/assistant_page.dart index f8354498..2b33e35f 100644 --- a/lib/features/assistant/assistant_page.dart +++ b/lib/features/assistant/assistant_page.dart @@ -20,10 +20,16 @@ class AssistantPage extends StatefulWidget { super.key, required this.controller, required this.onOpenDetail, + this.navigationPanelBuilder, + this.showStandaloneTaskRail = true, + this.unifiedPaneStartsCollapsed = false, }); final AppController controller; final ValueChanged onOpenDetail; + final Widget Function(double contentWidth)? navigationPanelBuilder; + final bool showStandaloneTaskRail; + final bool unifiedPaneStartsCollapsed; @override State createState() => _AssistantPageState(); @@ -42,6 +48,11 @@ class _AssistantPageState extends State { double _conversationPaneRatio = 0.7; double _threadRailWidth = 304; String _threadQuery = ''; + bool _sidePaneCollapsed = false; + _AssistantSidePane _activeSidePane = _AssistantSidePane.tasks; + final Map _taskSeeds = + {}; + final Set _archivedTaskKeys = {}; List<_ComposerAttachment> _attachments = const <_ComposerAttachment>[]; String? _lastSubmittedPrompt; String? _lastAutoAgentLabel; @@ -54,6 +65,16 @@ class _AssistantPageState extends State { _threadSearchController = TextEditingController(); _conversationController = ScrollController(); _composerFocusNode = FocusNode(); + _sidePaneCollapsed = widget.unifiedPaneStartsCollapsed; + } + + @override + void didUpdateWidget(covariant AssistantPage oldWidget) { + super.didUpdateWidget(oldWidget); + if (oldWidget.unifiedPaneStartsCollapsed != + widget.unifiedPaneStartsCollapsed) { + _sidePaneCollapsed = widget.unifiedPaneStartsCollapsed; + } } @override @@ -95,13 +116,19 @@ class _AssistantPageState extends State { padding: const EdgeInsets.fromLTRB(6, 6, 6, 0), child: LayoutBuilder( builder: (context, constraints) { - final showThreadRail = constraints.maxWidth >= 860; + final showUnifiedSidePane = + widget.navigationPanelBuilder != null && + constraints.maxWidth >= 860; + final showThreadRail = + !showUnifiedSidePane && + widget.showStandaloneTaskRail && + constraints.maxWidth >= 860; final mainWorkspace = _buildMainWorkspace( controller: controller, timelineItems: timelineItems, currentTask: currentTask, ); - if (!showThreadRail) { + if (!showThreadRail && !showUnifiedSidePane) { return mainWorkspace; } @@ -112,6 +139,90 @@ class _AssistantPageState extends State { .clamp(232.0, maxThreadRailWidth) .toDouble(); + if (showUnifiedSidePane) { + const sideTabRailWidth = 58.0; + final sidePanelContentWidth = + (threadRailWidth - sideTabRailWidth - 6) + .clamp(174.0, maxThreadRailWidth) + .toDouble(); + return Row( + children: [ + AnimatedContainer( + duration: const Duration(milliseconds: 220), + curve: Curves.easeOutCubic, + width: _sidePaneCollapsed + ? sideTabRailWidth + : threadRailWidth, + child: _AssistantUnifiedSidePane( + activePane: _activeSidePane, + collapsed: _sidePaneCollapsed, + taskPanel: _AssistantTaskRail( + key: const Key('assistant-task-rail'), + controller: controller, + tasks: visibleTasks, + query: _threadQuery, + searchController: _threadSearchController, + onQueryChanged: (value) { + setState(() { + _threadQuery = value.trim(); + }); + }, + onClearQuery: () { + _threadSearchController.clear(); + setState(() { + _threadQuery = ''; + }); + }, + onRefreshTasks: controller.refreshSessions, + onCreateTask: _createNewThread, + onOpenTasks: () { + controller.navigateTo(WorkspaceDestination.tasks); + }, + onOpenSkills: () { + controller.navigateTo(WorkspaceDestination.skills); + }, + onSelectTask: (sessionKey) async { + await controller.switchSession(sessionKey); + _focusComposer(); + }, + onArchiveTask: _archiveTask, + ), + navigationPanel: widget.navigationPanelBuilder!( + sidePanelContentWidth, + ), + onSelectPane: (pane) { + setState(() { + _activeSidePane = pane; + _sidePaneCollapsed = false; + }); + }, + onToggleCollapsed: () { + setState(() { + _sidePaneCollapsed = !_sidePaneCollapsed; + }); + }, + ), + ), + if (!_sidePaneCollapsed) + SizedBox( + width: 10, + child: PaneResizeHandle( + axis: Axis.horizontal, + onDelta: (delta) { + setState(() { + _threadRailWidth = (_threadRailWidth + delta) + .clamp(232.0, maxThreadRailWidth) + .toDouble(); + }); + }, + ), + ), + const SizedBox(width: 6), + Expanded(child: mainWorkspace), + ], + ); + } + return Row( children: [ SizedBox( @@ -145,6 +256,7 @@ class _AssistantPageState extends State { await controller.switchSession(sessionKey); _focusComposer(); }, + onArchiveTask: _archiveTask, ), ), SizedBox( @@ -190,15 +302,13 @@ class _AssistantPageState extends State { var minComposerHeight = availablePaneHeight >= 620 ? 176.0 : availablePaneHeight * 0.24; - if (minConversationHeight + minComposerHeight > - availablePaneHeight) { + if (minConversationHeight + minComposerHeight > availablePaneHeight) { minConversationHeight = availablePaneHeight * 0.52; minComposerHeight = availablePaneHeight - minConversationHeight; } - final maxConversationHeight = - (availablePaneHeight - minComposerHeight) - .clamp(minConversationHeight, availablePaneHeight) - .toDouble(); + final maxConversationHeight = (availablePaneHeight - minComposerHeight) + .clamp(minConversationHeight, availablePaneHeight) + .toDouble(); final conversationHeight = availablePaneHeight <= 0 ? 0.0 : (_conversationPaneRatio * availablePaneHeight) @@ -428,6 +538,20 @@ class _AssistantPageState extends State { _lastSubmittedPrompt = rawPrompt; _lastAutoAgentLabel = autoAgent?.name ?? controller.activeAgentName; _lastSubmittedAttachments = attachmentNames; + _touchTaskSeed( + sessionKey: controller.currentSessionKey, + title: + _taskSeeds[controller.currentSessionKey]?.title ?? + _fallbackSessionTitle(controller.currentSessionKey), + preview: rawPrompt, + status: + controller.connection.status == RuntimeConnectionStatus.connected + ? 'running' + : 'queued', + owner: autoAgent?.name ?? controller.activeAgentName, + surface: 'Assistant', + draft: controller.currentSessionKey.trim().startsWith('draft:'), + ); }); final attachmentPayloads = await _buildAttachmentPayloads(_attachments); @@ -578,60 +702,48 @@ class _AssistantPageState extends State { Future _createNewThread() async { final sessionKey = _buildDraftSessionKey(widget.controller); + setState(() { + _archivedTaskKeys.removeWhere( + (value) => _sessionKeysMatch(value, sessionKey), + ); + _taskSeeds[sessionKey] = _AssistantTaskSeed( + sessionKey: sessionKey, + title: appText('新对话', 'New conversation'), + preview: appText( + '等待描述这个任务的第一条消息', + 'Waiting for the first message of this task', + ), + status: 'queued', + updatedAtMs: DateTime.now().millisecondsSinceEpoch.toDouble(), + owner: widget.controller.activeAgentName, + surface: 'Assistant', + draft: true, + ); + }); await widget.controller.switchSession(sessionKey); _focusComposer(); } List<_AssistantTaskEntry> _buildTaskEntries(AppController controller) { - final sessions = controller.sessions.toList(growable: false) - ..sort( - (left, right) => - (right.updatedAtMs ?? 0).compareTo(left.updatedAtMs ?? 0), - ); - final entries = sessions - .map( - (session) => _AssistantTaskEntry( - sessionKey: session.key, - title: _sessionDisplayTitle(session), - preview: - _sessionPreview(session) ?? - appText('等待继续执行这个任务', 'Waiting to continue this task'), - status: _sessionStatus( - session, - currentSessionKey: controller.currentSessionKey, - hasPendingRun: controller.chatController.hasPendingRun, - ), - updatedAtLabel: _sessionUpdatedAtLabel(session.updatedAtMs), - owner: controller.activeAgentName, - surface: session.surface ?? session.kind ?? 'Assistant', - isCurrent: _sessionKeysMatch( - session.key, - controller.currentSessionKey, - ), - ), - ) - .toList(growable: true); - if (!entries.any( - (item) => _sessionKeysMatch(item.sessionKey, controller.currentSessionKey), - )) { - entries.insert( - 0, - _AssistantTaskEntry( - sessionKey: controller.currentSessionKey, - title: _fallbackSessionTitle(controller.currentSessionKey), - preview: appText( - '等待描述这个任务的第一条消息', - 'Waiting for the first message of this task', - ), - status: 'queued', - updatedAtLabel: appText('现在', 'Now'), - owner: controller.activeAgentName, - surface: 'Assistant', - isCurrent: true, - draft: true, - ), - ); - } + _synchronizeTaskSeeds(controller); + final entries = + _taskSeeds.values + .where((item) => !_isArchivedTask(item.sessionKey)) + .map( + (item) => item.toEntry( + isCurrent: _sessionKeysMatch( + item.sessionKey, + controller.currentSessionKey, + ), + ), + ) + .toList(growable: true) + ..sort((left, right) { + if (left.isCurrent != right.isCurrent) { + return left.isCurrent ? -1 : 1; + } + return (right.updatedAtMs ?? 0).compareTo(left.updatedAtMs ?? 0); + }); return entries; } @@ -640,11 +752,13 @@ class _AssistantPageState extends State { if (query.isEmpty) { return items; } - return items.where((item) { - final haystack = - '${item.title}\n${item.preview}\n${item.sessionKey}'.toLowerCase(); - return haystack.contains(query); - }).toList(growable: false); + return items + .where((item) { + final haystack = '${item.title}\n${item.preview}\n${item.sessionKey}' + .toLowerCase(); + return haystack.contains(query); + }) + .toList(growable: false); } _AssistantTaskEntry _resolveCurrentTask( @@ -661,7 +775,7 @@ class _AssistantPageState extends State { title: _fallbackSessionTitle(sessionKey), preview: '', status: 'queued', - updatedAtLabel: appText('现在', 'Now'), + updatedAtMs: DateTime.now().millisecondsSinceEpoch.toDouble(), owner: widget.controller.activeAgentName, surface: 'Assistant', isCurrent: true, @@ -669,6 +783,109 @@ class _AssistantPageState extends State { ); } + void _synchronizeTaskSeeds(AppController controller) { + for (final session in controller.sessions) { + if (_isArchivedTask(session.key)) { + continue; + } + _taskSeeds[session.key] = _AssistantTaskSeed( + sessionKey: session.key, + title: _sessionDisplayTitle(session), + preview: + _sessionPreview(session) ?? + appText('等待继续执行这个任务', 'Waiting to continue this task'), + status: _sessionStatus( + session, + currentSessionKey: controller.currentSessionKey, + hasPendingRun: controller.chatController.hasPendingRun, + ), + updatedAtMs: + session.updatedAtMs ?? + DateTime.now().millisecondsSinceEpoch.toDouble(), + owner: controller.activeAgentName, + surface: session.surface ?? session.kind ?? 'Assistant', + draft: session.key.trim().startsWith('draft:'), + ); + } + + if (_isArchivedTask(controller.currentSessionKey)) { + return; + } + _taskSeeds.putIfAbsent( + controller.currentSessionKey, + () => _AssistantTaskSeed( + sessionKey: controller.currentSessionKey, + title: _fallbackSessionTitle(controller.currentSessionKey), + preview: appText( + '等待描述这个任务的第一条消息', + 'Waiting for the first message of this task', + ), + status: 'queued', + updatedAtMs: DateTime.now().millisecondsSinceEpoch.toDouble(), + owner: controller.activeAgentName, + surface: 'Assistant', + draft: controller.currentSessionKey.trim().startsWith('draft:'), + ), + ); + } + + void _touchTaskSeed({ + required String sessionKey, + required String title, + required String preview, + required String status, + required String owner, + required String surface, + required bool draft, + }) { + _taskSeeds[sessionKey] = _AssistantTaskSeed( + sessionKey: sessionKey, + title: title, + preview: preview, + status: status, + updatedAtMs: DateTime.now().millisecondsSinceEpoch.toDouble(), + owner: owner, + surface: surface, + draft: draft, + ); + } + + bool _isArchivedTask(String sessionKey) { + for (final archivedKey in _archivedTaskKeys) { + if (_sessionKeysMatch(archivedKey, sessionKey)) { + return true; + } + } + return false; + } + + Future _archiveTask(String sessionKey) async { + final isCurrent = _sessionKeysMatch( + sessionKey, + widget.controller.currentSessionKey, + ); + setState(() { + _archivedTaskKeys.add(sessionKey); + _taskSeeds.removeWhere((key, _) => _sessionKeysMatch(key, sessionKey)); + }); + + if (!isCurrent) { + return; + } + + for (final candidate in _taskSeeds.keys) { + if (_isArchivedTask(candidate) || + _sessionKeysMatch(candidate, sessionKey)) { + continue; + } + await widget.controller.switchSession(candidate); + _focusComposer(); + return; + } + + await _createNewThread(); + } + String _buildDraftSessionKey(AppController controller) { final stamp = DateTime.now().millisecondsSinceEpoch; final selectedAgentId = controller.selectedAgentId.trim(); @@ -679,6 +896,171 @@ class _AssistantPageState extends State { } } +enum _AssistantSidePane { tasks, navigation } + +class _AssistantUnifiedSidePane extends StatelessWidget { + const _AssistantUnifiedSidePane({ + required this.activePane, + required this.collapsed, + required this.taskPanel, + required this.navigationPanel, + required this.onSelectPane, + required this.onToggleCollapsed, + }); + + final _AssistantSidePane activePane; + final bool collapsed; + final Widget taskPanel; + final Widget navigationPanel; + final ValueChanged<_AssistantSidePane> onSelectPane; + final VoidCallback onToggleCollapsed; + + @override + Widget build(BuildContext context) { + return Row( + children: [ + _AssistantSideTabRail( + activePane: activePane, + collapsed: collapsed, + onSelectPane: onSelectPane, + onToggleCollapsed: onToggleCollapsed, + ), + if (!collapsed) ...[ + const SizedBox(width: 6), + Expanded( + child: AnimatedSwitcher( + duration: const Duration(milliseconds: 180), + switchInCurve: Curves.easeOutCubic, + switchOutCurve: Curves.easeInCubic, + child: activePane == _AssistantSidePane.tasks + ? KeyedSubtree( + key: const ValueKey('assistant-side-pane-tasks'), + child: taskPanel, + ) + : KeyedSubtree( + key: const ValueKey( + 'assistant-side-pane-navigation', + ), + child: navigationPanel, + ), + ), + ), + ], + ], + ); + } +} + +class _AssistantSideTabRail extends StatelessWidget { + const _AssistantSideTabRail({ + required this.activePane, + required this.collapsed, + required this.onSelectPane, + required this.onToggleCollapsed, + }); + + final _AssistantSidePane activePane; + final bool collapsed; + final ValueChanged<_AssistantSidePane> onSelectPane; + final VoidCallback onToggleCollapsed; + + @override + Widget build(BuildContext context) { + final palette = context.palette; + + return Container( + key: const Key('assistant-side-pane'), + width: 58, + decoration: BoxDecoration( + color: palette.sidebar, + borderRadius: BorderRadius.circular(18), + border: Border.all( + color: palette.sidebarBorder.withValues(alpha: 0.72), + ), + ), + child: Column( + children: [ + const SizedBox(height: 8), + _AssistantSideTabButton( + key: const Key('assistant-side-pane-tab-tasks'), + icon: Icons.checklist_rtl_rounded, + selected: activePane == _AssistantSidePane.tasks, + tooltip: appText('任务', 'Tasks'), + onTap: () => onSelectPane(_AssistantSidePane.tasks), + ), + const SizedBox(height: 6), + _AssistantSideTabButton( + key: const Key('assistant-side-pane-tab-navigation'), + icon: Icons.dashboard_customize_outlined, + selected: activePane == _AssistantSidePane.navigation, + tooltip: appText('导航', 'Navigation'), + onTap: () => onSelectPane(_AssistantSidePane.navigation), + ), + const Spacer(), + IconButton( + key: const Key('assistant-side-pane-toggle'), + tooltip: collapsed + ? appText('展开侧板', 'Expand side pane') + : appText('收起侧板', 'Collapse side pane'), + onPressed: onToggleCollapsed, + icon: Icon( + collapsed + ? Icons.keyboard_double_arrow_right_rounded + : Icons.keyboard_double_arrow_left_rounded, + size: 18, + ), + ), + const SizedBox(height: 8), + ], + ), + ); + } +} + +class _AssistantSideTabButton extends StatelessWidget { + const _AssistantSideTabButton({ + super.key, + required this.icon, + required this.selected, + required this.tooltip, + required this.onTap, + }); + + final IconData icon; + final bool selected; + final String tooltip; + final VoidCallback onTap; + + @override + Widget build(BuildContext context) { + final palette = context.palette; + + return Tooltip( + message: tooltip, + child: Material( + color: Colors.transparent, + child: InkWell( + borderRadius: BorderRadius.circular(14), + onTap: onTap, + child: Container( + width: 42, + height: 42, + decoration: BoxDecoration( + color: selected ? palette.accentMuted : Colors.transparent, + borderRadius: BorderRadius.circular(14), + ), + child: Icon( + icon, + size: 20, + color: selected ? palette.accent : palette.textSecondary, + ), + ), + ), + ), + ); + } +} + class _AssistantLowerPane extends StatelessWidget { const _AssistantLowerPane({ required this.controller, @@ -776,8 +1158,8 @@ class _ConversationArea extends StatelessWidget { final palette = context.palette; final theme = Theme.of(context); final statusStyle = _pillStyleForStatus(context, currentTask.status); - final taskHint = controller.connection.status == - RuntimeConnectionStatus.connected + final taskHint = + controller.connection.status == RuntimeConnectionStatus.connected ? appText( '当前对话会作为任务上下文持续执行,切换左侧任务即可回到对应会话。', 'This conversation stays attached to the selected task. Pick another task on the left to jump back into it.', @@ -806,10 +1188,7 @@ class _ConversationArea extends StatelessWidget { style: theme.textTheme.titleLarge, ), const SizedBox(height: 4), - Text( - taskHint, - style: theme.textTheme.bodySmall, - ), + Text(taskHint, style: theme.textTheme.bodySmall), const SizedBox(height: 10), Wrap( spacing: 8, @@ -986,6 +1365,7 @@ class _AssistantTaskRail extends StatelessWidget { required this.onOpenTasks, required this.onOpenSkills, required this.onSelectTask, + required this.onArchiveTask, }); final AppController controller; @@ -999,6 +1379,7 @@ class _AssistantTaskRail extends StatelessWidget { final VoidCallback onOpenTasks; final VoidCallback onOpenSkills; final Future Function(String sessionKey) onSelectTask; + final Future Function(String sessionKey) onArchiveTask; @override Widget build(BuildContext context) { @@ -1100,8 +1481,7 @@ class _AssistantTaskRail extends StatelessWidget { runSpacing: 8, children: [ _MetaPill( - label: - '${appText('运行中', 'Running')} $runningCount', + label: '${appText('运行中', 'Running')} $runningCount', icon: Icons.play_circle_outline_rounded, ), _MetaPill( @@ -1186,6 +1566,9 @@ class _AssistantTaskRail extends StatelessWidget { onTap: () async { await onSelectTask(task.sessionKey); }, + onArchive: () async { + await onArchiveTask(task.sessionKey); + }, ); }, ), @@ -1197,10 +1580,15 @@ class _AssistantTaskRail extends StatelessWidget { } class _AssistantTaskTile extends StatelessWidget { - const _AssistantTaskTile({required this.entry, required this.onTap}); + const _AssistantTaskTile({ + required this.entry, + required this.onTap, + required this.onArchive, + }); final _AssistantTaskEntry entry; final VoidCallback onTap; + final VoidCallback onArchive; @override Widget build(BuildContext context) { @@ -1214,7 +1602,7 @@ class _AssistantTaskTile extends StatelessWidget { : Colors.transparent, borderRadius: BorderRadius.circular(12), child: InkWell( - key: ValueKey('assistant-task-${entry.sessionKey}'), + key: ValueKey('assistant-task-item-${entry.sessionKey}'), borderRadius: BorderRadius.circular(12), onTap: onTap, child: Container( @@ -1263,11 +1651,31 @@ class _AssistantTaskTile extends StatelessWidget { ), ), const SizedBox(width: 8), - Text( - entry.updatedAtLabel, - style: theme.textTheme.bodySmall?.copyWith( - color: palette.textMuted, - ), + Row( + mainAxisSize: MainAxisSize.min, + children: [ + 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: 16, + onPressed: onArchive, + icon: Icon( + Icons.archive_outlined, + size: 18, + color: palette.textMuted, + ), + ), + ], ), ], ), @@ -1282,7 +1690,10 @@ class _AssistantTaskTile extends StatelessWidget { ), ), const SizedBox(height: 8), - Row( + Wrap( + spacing: 6, + runSpacing: 6, + crossAxisAlignment: WrapCrossAlignment.center, children: [ _StatusPill( label: entry.draft @@ -1291,21 +1702,8 @@ class _AssistantTaskTile extends StatelessWidget { backgroundColor: statusStyle.backgroundColor, textColor: statusStyle.foregroundColor, ), - const SizedBox(width: 6), - Flexible( - child: _MetaPill( - label: entry.owner, - icon: Icons.smart_toy_outlined, - ), - ), - const SizedBox(width: 6), - Flexible( - child: _MetaPill( - label: entry.surface, - icon: Icons.forum_outlined, - ), - ), - const Spacer(), + _MetaPill(label: entry.owner, icon: Icons.smart_toy_outlined), + _MetaPill(label: entry.surface, icon: Icons.forum_outlined), if (entry.isCurrent) Text( appText('当前', 'Current'), @@ -1880,7 +2278,10 @@ class _ComposerToolbarChip extends StatelessWidget { final palette = context.palette; return Container( - padding: const EdgeInsets.symmetric(horizontal: AppSpacing.xs, vertical: 6), + padding: const EdgeInsets.symmetric( + horizontal: AppSpacing.xs, + vertical: 6, + ), decoration: BoxDecoration( color: backgroundColor ?? palette.surfaceSecondary, borderRadius: BorderRadius.circular(AppRadius.chip), @@ -2238,7 +2639,12 @@ class _ToolCallTileState extends State<_ToolCallTile> { curve: Curves.easeOutCubic, child: _expanded ? Padding( - padding: const EdgeInsets.fromLTRB(AppSpacing.sm, 0, AppSpacing.sm, AppSpacing.xs), + padding: const EdgeInsets.fromLTRB( + AppSpacing.sm, + 0, + AppSpacing.sm, + AppSpacing.xs, + ), child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ @@ -2322,7 +2728,10 @@ class _ConnectionChip extends StatelessWidget { }; return Container( - padding: const EdgeInsets.symmetric(horizontal: AppSpacing.xs, vertical: 5), + padding: const EdgeInsets.symmetric( + horizontal: AppSpacing.xs, + vertical: 5, + ), decoration: BoxDecoration( color: color, borderRadius: BorderRadius.circular(AppRadius.chip), @@ -2425,13 +2834,49 @@ class _TimelineItem { final bool error; } +class _AssistantTaskSeed { + const _AssistantTaskSeed({ + required this.sessionKey, + required this.title, + required this.preview, + required this.status, + required this.updatedAtMs, + required this.owner, + required this.surface, + required this.draft, + }); + + final String sessionKey; + final String title; + final String preview; + final String status; + final double updatedAtMs; + final String owner; + final String surface; + final bool draft; + + _AssistantTaskEntry toEntry({required bool isCurrent}) { + return _AssistantTaskEntry( + sessionKey: sessionKey, + title: title, + preview: preview, + status: status, + updatedAtMs: updatedAtMs, + owner: owner, + surface: surface, + isCurrent: isCurrent, + draft: draft, + ); + } +} + class _AssistantTaskEntry { const _AssistantTaskEntry({ required this.sessionKey, required this.title, required this.preview, required this.status, - required this.updatedAtLabel, + required this.updatedAtMs, required this.owner, required this.surface, required this.isCurrent, @@ -2442,11 +2887,13 @@ class _AssistantTaskEntry { final String title; final String preview; final String status; - final String updatedAtLabel; + final double? updatedAtMs; final String owner; final String surface; final bool isCurrent; final bool draft; + + String get updatedAtLabel => _sessionUpdatedAtLabel(updatedAtMs); } class _PillStyle { @@ -2470,30 +2917,45 @@ class _MetaPill extends StatelessWidget { final palette = context.palette; final theme = Theme.of(context); - return Container( - padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 6), - decoration: BoxDecoration( - color: palette.surfaceSecondary, - borderRadius: BorderRadius.circular(999), - border: Border.all(color: palette.strokeSoft), - ), - child: Row( - mainAxisSize: MainAxisSize.min, - children: [ - Icon(icon, size: 14, color: palette.textMuted), - const SizedBox(width: 6), - Flexible( - child: Text( - label, - maxLines: 1, - overflow: TextOverflow.ellipsis, - style: theme.textTheme.labelMedium?.copyWith( - color: palette.textSecondary, - ), - ), + return LayoutBuilder( + builder: (context, constraints) { + final maxWidth = constraints.maxWidth; + if (maxWidth.isFinite && maxWidth < 20) { + return const SizedBox.shrink(); + } + final showText = !maxWidth.isFinite || maxWidth >= 52; + final horizontalPadding = showText ? 10.0 : 8.0; + return Container( + padding: EdgeInsets.symmetric( + horizontal: horizontalPadding, + vertical: 6, ), - ], - ), + decoration: BoxDecoration( + color: palette.surfaceSecondary, + borderRadius: BorderRadius.circular(999), + border: Border.all(color: palette.strokeSoft), + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon(icon, size: 14, color: palette.textMuted), + if (showText) ...[ + const SizedBox(width: 6), + Flexible( + child: Text( + label, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: theme.textTheme.labelMedium?.copyWith( + color: palette.textSecondary, + ), + ), + ), + ], + ], + ), + ); + }, ); } } diff --git a/lib/widgets/sidebar_navigation.dart b/lib/widgets/sidebar_navigation.dart index d0b47f9c..7e99d3df 100644 --- a/lib/widgets/sidebar_navigation.dart +++ b/lib/widgets/sidebar_navigation.dart @@ -21,6 +21,8 @@ class SidebarNavigation extends StatelessWidget { required this.accountName, required this.accountSubtitle, this.expandedWidthOverride, + this.marginOverride, + this.showCollapseControl = true, }); final WorkspaceDestination currentSection; @@ -36,6 +38,8 @@ class SidebarNavigation extends StatelessWidget { final String accountName; final String accountSubtitle; final double? expandedWidthOverride; + final EdgeInsetsGeometry? marginOverride; + final bool showCollapseControl; static const _primarySections = [ WorkspaceDestination.assistant, @@ -69,7 +73,9 @@ class SidebarNavigation extends StatelessWidget { curve: Curves.easeOutCubic, width: isExpanded ? expandedWidth : AppSizes.sidebarCollapsedWidth, height: double.infinity, - margin: const EdgeInsets.fromLTRB(AppSpacing.xs, AppSpacing.xs, 6, 0), + margin: + marginOverride ?? + const EdgeInsets.fromLTRB(AppSpacing.xs, AppSpacing.xs, 6, 0), decoration: BoxDecoration( color: palette.sidebar, borderRadius: BorderRadius.circular(AppRadius.sidebar), @@ -145,6 +151,7 @@ class SidebarNavigation extends StatelessWidget { accountSubtitle: accountSubtitle, accountSelected: currentSection == WorkspaceDestination.account, + showCollapseControl: showCollapseControl, ), ], ), @@ -404,6 +411,7 @@ class SidebarFooter extends StatelessWidget { required this.accountName, required this.accountSubtitle, required this.accountSelected, + required this.showCollapseControl, }); final bool isCollapsed; @@ -419,6 +427,7 @@ class SidebarFooter extends StatelessWidget { final String accountName; final String accountSubtitle; final bool accountSelected; + final bool showCollapseControl; @override Widget build(BuildContext context) { @@ -450,12 +459,14 @@ class SidebarFooter extends StatelessWidget { onPressed: onOpenThemeToggle, ), const SizedBox(height: AppSpacing.xs), - _SidebarActionButton( - icon: _sidebarStateIcon(sidebarState), - tooltip: _sidebarStateLabel(sidebarState), - onPressed: onCycleSidebarState, - ), - const SizedBox(height: AppSpacing.xs), + if (showCollapseControl) ...[ + _SidebarActionButton( + icon: _sidebarStateIcon(sidebarState), + tooltip: _sidebarStateLabel(sidebarState), + onPressed: onCycleSidebarState, + ), + const SizedBox(height: AppSpacing.xs), + ], _SidebarActionButton( icon: Icons.tune_rounded, tooltip: appText('设置', 'Settings'), @@ -507,11 +518,12 @@ class SidebarFooter extends StatelessWidget { onPressed: onOpenThemeToggle, ), const SizedBox(width: AppSpacing.xs), - _SidebarActionButton( - icon: _sidebarStateIcon(sidebarState), - tooltip: _sidebarStateLabel(sidebarState), - onPressed: onCycleSidebarState, - ), + if (showCollapseControl) + _SidebarActionButton( + icon: _sidebarStateIcon(sidebarState), + tooltip: _sidebarStateLabel(sidebarState), + onPressed: onCycleSidebarState, + ), ], ), const SizedBox(height: AppSpacing.xs), diff --git a/test/features/assistant_page_test.dart b/test/features/assistant_page_test.dart index b125fae1..bda7c364 100644 --- a/test/features/assistant_page_test.dart +++ b/test/features/assistant_page_test.dart @@ -5,7 +5,34 @@ import 'package:xworkmate/features/assistant/assistant_page.dart'; import '../test_support.dart'; void main() { - testWidgets('AssistantPage desktop shows thread rail and creates draft thread', ( + testWidgets( + 'AssistantPage desktop shows thread rail and creates draft thread', + (WidgetTester tester) async { + final controller = await createTestController(tester); + + await pumpPage( + tester, + child: AssistantPage(controller: controller, onOpenDetail: (_) {}), + ); + + expect(find.byKey(const Key('assistant-task-rail')), findsOneWidget); + + final titleBefore = tester.widget( + find.byKey(const Key('assistant-conversation-title')), + ); + expect(titleBefore.data, '默认任务'); + + await tester.tap(find.byKey(const Key('assistant-new-task-button'))); + await tester.pumpAndSettle(); + + final titleAfter = tester.widget( + find.byKey(const Key('assistant-conversation-title')), + ); + expect(titleAfter.data, '新对话'); + }, + ); + + testWidgets('AssistantPage keeps draft task visible until archived', ( WidgetTester tester, ) async { final controller = await createTestController(tester); @@ -15,20 +42,92 @@ void main() { child: AssistantPage(controller: controller, onOpenDetail: (_) {}), ); - expect(find.byKey(const Key('assistant-task-rail')), findsOneWidget); - - final titleBefore = tester.widget( - find.byKey(const Key('assistant-conversation-title')), + expect( + find.byWidgetPredicate( + (widget) => + widget.key is ValueKey && + (widget.key as ValueKey).value.startsWith( + 'assistant-task-item-', + ), + ), + findsOneWidget, ); - expect(titleBefore.data, '默认任务'); await tester.tap(find.byKey(const Key('assistant-new-task-button'))); await tester.pumpAndSettle(); - final titleAfter = tester.widget( - find.byKey(const Key('assistant-conversation-title')), + await controller.refreshSessions(); + await tester.pumpAndSettle(); + + expect( + find.byWidgetPredicate( + (widget) => + widget.key is ValueKey && + (widget.key as ValueKey).value.startsWith( + 'assistant-task-item-', + ), + ), + findsNWidgets(2), ); - expect(titleAfter.data, '新对话'); + + final archiveButton = find.byWidgetPredicate( + (widget) => + widget.key is ValueKey && + (widget.key as ValueKey).value.startsWith( + 'assistant-task-archive-draft:', + ), + ); + expect(archiveButton, findsOneWidget); + + await tester.tap(archiveButton); + await tester.pumpAndSettle(); + + expect( + find.byWidgetPredicate( + (widget) => + widget.key is ValueKey && + (widget.key as ValueKey).value.startsWith( + 'assistant-task-item-', + ), + ), + findsOneWidget, + ); + }); + + testWidgets('AssistantPage can switch unified side pane tabs and collapse', ( + WidgetTester tester, + ) async { + final controller = await createTestController(tester); + + await pumpPage( + tester, + child: AssistantPage( + controller: controller, + onOpenDetail: (_) {}, + navigationPanelBuilder: (_) => const ColoredBox( + key: Key('assistant-nav-panel-probe'), + color: Colors.red, + ), + showStandaloneTaskRail: false, + ), + ); + + expect(find.byKey(const Key('assistant-side-pane')), findsOneWidget); + expect(find.byKey(const Key('assistant-task-rail')), findsOneWidget); + expect(find.byKey(const Key('assistant-nav-panel-probe')), findsNothing); + + await tester.tap( + find.byKey(const Key('assistant-side-pane-tab-navigation')), + ); + await tester.pumpAndSettle(); + + expect(find.byKey(const Key('assistant-nav-panel-probe')), findsOneWidget); + + await tester.tap(find.byKey(const Key('assistant-side-pane-toggle'))); + await tester.pumpAndSettle(); + + expect(find.byKey(const Key('assistant-nav-panel-probe')), findsNothing); + expect(find.byKey(const Key('assistant-side-pane')), findsOneWidget); }); testWidgets('AssistantPage narrow layout keeps existing single-pane flow', ( @@ -43,7 +142,10 @@ void main() { ); expect(find.byKey(const Key('assistant-task-rail')), findsNothing); - expect(find.byKey(const Key('assistant-conversation-title')), findsOneWidget); + expect( + find.byKey(const Key('assistant-conversation-title')), + findsOneWidget, + ); }); testWidgets('AssistantPage offline submit control opens gateway dialog', (