diff --git a/lib/app/app_shell_desktop.dart b/lib/app/app_shell_desktop.dart index cdff1a1f..960f24c3 100644 --- a/lib/app/app_shell_desktop.dart +++ b/lib/app/app_shell_desktop.dart @@ -5,7 +5,6 @@ import '../features/mobile/mobile_shell.dart'; import '../i18n/app_language.dart'; import '../models/app_models.dart'; import '../theme/app_palette.dart'; -import '../theme/app_theme.dart'; import '../widgets/detail_drawer.dart'; import '../widgets/pane_resize_handle.dart'; import '../widgets/sidebar_navigation.dart'; @@ -25,6 +24,7 @@ class _AppShellState extends State { static const _sidebarMinWidth = 56.0; static const _sidebarViewportPadding = 72.0; static const _mainContentMinWidth = 640.0; + static const _sidebarExpandedBaseWidth = 336.0; double? _sidebarExpandedWidth; static const _mobileDestinations = [ @@ -45,10 +45,50 @@ class _AppShellState extends State { } double _defaultSidebarWidth(AppLanguage language, double viewportWidth) { - final baseWidth = language == AppLanguage.zh - ? AppSizes.sidebarExpandedWidthZh - : AppSizes.sidebarExpandedWidthEn; - return _clampSidebarWidth(baseWidth, viewportWidth); + return _clampSidebarWidth(_sidebarExpandedBaseWidth, viewportWidth); + } + + List _buildSidebarTaskItems(AppController controller) { + final currentSessionKey = controller.currentSessionKey.trim().isEmpty + ? 'main' + : controller.currentSessionKey.trim(); + return controller.assistantSessions.map((session) { + final sessionKey = session.key.trim().isEmpty ? 'main' : session.key.trim(); + final preview = session.lastMessagePreview?.trim() ?? ''; + return SidebarTaskItem( + sessionKey: sessionKey, + title: session.label.trim().isEmpty + ? appText('新对话', 'New conversation') + : session.label.trim(), + preview: preview, + updatedAtMs: session.updatedAtMs, + executionTarget: controller.assistantExecutionTargetForSession(sessionKey), + isCurrent: sessionKey == currentSessionKey, + pending: controller.assistantSessionHasPendingRun(sessionKey), + draft: sessionKey.startsWith('draft:'), + ); + }).toList(growable: false); + } + + Future _createSidebarConversation(AppController controller) async { + final sessionKey = 'draft:${DateTime.now().millisecondsSinceEpoch}'; + controller.initializeAssistantThreadContext( + sessionKey, + title: appText('新对话', 'New conversation'), + executionTarget: controller.currentAssistantExecutionTarget, + messageViewMode: controller.currentAssistantMessageViewMode, + singleAgentProvider: controller.currentSingleAgentProvider, + ); + controller.navigateTo(WorkspaceDestination.assistant); + await controller.switchSession(sessionKey); + } + + void _toggleSidebarVisibility(AppController controller) { + controller.setSidebarState( + controller.sidebarState == AppSidebarState.hidden + ? AppSidebarState.expanded + : AppSidebarState.hidden, + ); } @override @@ -74,6 +114,7 @@ class _AppShellState extends State { final uiFeatures = controller.featuresFor( resolveUiFeaturePlatformFromContext(context), ); + final sidebarTaskItems = _buildSidebarTaskItems(controller); final expandedSidebarWidth = _clampSidebarWidth( _sidebarExpandedWidth ?? _defaultSidebarWidth( @@ -234,9 +275,10 @@ class _AppShellState extends State { controller.navigateTo(destination); }, onToggleLanguage: controller.toggleAppLanguage, - onCycleSidebarState: controller.cycleSidebarState, - onExpandFromCollapsed: () => controller - .setSidebarState(AppSidebarState.expanded), + onCycleSidebarState: () => + _toggleSidebarVisibility(controller), + onExpandFromCollapsed: () => + _toggleSidebarVisibility(controller), onOpenHome: controller.navigateHome, onOpenAccount: () => controller.navigateTo( WorkspaceDestination.account, @@ -280,6 +322,26 @@ class _AppShellState extends State { uiFeatures.availableSettingsTabs, onSettingsTabChanged: (tab) => controller.openSettings(tab: tab), + taskItems: sidebarTaskItems, + assistantSkillCount: + controller.currentAssistantSkillCount, + onRefreshTasks: controller.refreshSessions, + onCreateTask: () => + _createSidebarConversation(controller), + onSelectTask: (sessionKey) async { + controller.navigateTo(WorkspaceDestination.assistant); + await controller.switchSession(sessionKey); + }, + onArchiveTask: (sessionKey) => + controller.saveAssistantTaskArchived( + sessionKey, + true, + ), + onRenameTask: (sessionKey, title) => + controller.saveAssistantTaskTitle( + sessionKey, + title, + ), ), if (sidebarState == AppSidebarState.expanded) PaneResizeHandle( @@ -332,13 +394,10 @@ class _AppShellState extends State { ), if (!showSidebar) Positioned( - left: 0, - top: 8, - bottom: 0, + left: 8, + bottom: 8, child: _SidebarRevealRail( - onExpand: () => controller.setSidebarState( - AppSidebarState.expanded, - ), + onExpand: () => _toggleSidebarVisibility(controller), ), ), ], @@ -398,23 +457,18 @@ class _SidebarRevealRailState extends State<_SidebarRevealRail> { onTap: widget.onExpand, child: AnimatedContainer( duration: const Duration(milliseconds: 180), - width: _hovered ? 22 : 10, + width: _hovered ? 40 : 32, + height: _hovered ? 40 : 32, decoration: BoxDecoration( - color: _hovered ? palette.surfacePrimary : Colors.transparent, - borderRadius: const BorderRadius.horizontal( - right: Radius.circular(14), - ), - border: Border.all( - color: _hovered ? palette.strokeSoft : Colors.transparent, - ), + color: _hovered ? palette.surfacePrimary : palette.chromeSurface, + borderRadius: BorderRadius.circular(999), + border: Border.all(color: palette.strokeSoft), + ), + child: Icon( + Icons.keyboard_double_arrow_right_rounded, + size: 18, + color: palette.textSecondary, ), - child: _hovered - ? Icon( - Icons.chevron_right_rounded, - size: 16, - color: palette.textMuted, - ) - : null, ), ), ), diff --git a/lib/app/app_shell_web.dart b/lib/app/app_shell_web.dart index c2004259..1cd4c669 100644 --- a/lib/app/app_shell_web.dart +++ b/lib/app/app_shell_web.dart @@ -3,7 +3,6 @@ import 'package:flutter/material.dart'; import '../i18n/app_language.dart'; import '../models/app_models.dart'; import '../theme/app_palette.dart'; -import '../theme/app_theme.dart'; import '../web/web_assistant_page.dart'; import '../web/web_settings_page.dart'; import '../web/web_workspace_pages.dart'; @@ -25,6 +24,7 @@ class _AppShellState extends State { static const _sidebarMinWidth = 56.0; static const _sidebarViewportPadding = 72.0; static const _mainContentMinWidth = 760.0; + static const _sidebarExpandedBaseWidth = 336.0; AppSidebarState _sidebarState = AppSidebarState.expanded; double? _sidebarExpandedWidth; @@ -39,22 +39,34 @@ class _AppShellState extends State { } double _defaultSidebarWidth(AppLanguage language, double viewportWidth) { - final baseWidth = language == AppLanguage.zh - ? AppSizes.sidebarExpandedWidthZh - : AppSizes.sidebarExpandedWidthEn; - return _clampSidebarWidth(baseWidth, viewportWidth); + return _clampSidebarWidth(_sidebarExpandedBaseWidth, viewportWidth); } - void _cycleSidebarState() { + void _toggleSidebarVisibility() { setState(() { - _sidebarState = switch (_sidebarState) { - AppSidebarState.expanded => AppSidebarState.collapsed, - AppSidebarState.collapsed => AppSidebarState.hidden, - AppSidebarState.hidden => AppSidebarState.expanded, - }; + _sidebarState = _sidebarState == AppSidebarState.hidden + ? AppSidebarState.expanded + : AppSidebarState.hidden; }); } + List _buildSidebarTaskItems(AppController controller) { + return controller.conversations + .map( + (item) => SidebarTaskItem( + sessionKey: item.sessionKey, + title: item.title, + preview: item.preview, + updatedAtMs: item.updatedAtMs, + executionTarget: item.executionTarget, + isCurrent: item.current, + pending: item.pending, + draft: item.sessionKey.startsWith('draft:'), + ), + ) + .toList(growable: false); + } + @override Widget build(BuildContext context) { return AnimatedBuilder( @@ -86,6 +98,7 @@ class _AppShellState extends State { child: LayoutBuilder( builder: (context, constraints) { final isMobile = constraints.maxWidth < 900; + final sidebarTaskItems = _buildSidebarTaskItems(controller); final expandedSidebarWidth = _clampSidebarWidth( _sidebarExpandedWidth ?? _defaultSidebarWidth( @@ -143,88 +156,127 @@ class _AppShellState extends State { ); } - return Row( + return Stack( children: [ - if (_sidebarState != AppSidebarState.hidden) - SidebarNavigation( - currentSection: currentDestination, - sidebarState: _sidebarState, - appLanguage: controller.appLanguage, - themeMode: controller.themeMode, - onSectionChanged: (destination) { - if (destination == WorkspaceDestination.settings) { - controller.openSettings(tab: SettingsTab.general); - return; - } - controller.navigateTo(destination); - }, - onToggleLanguage: controller.toggleAppLanguage, - onCycleSidebarState: _cycleSidebarState, - onExpandFromCollapsed: () { - setState(() { - _sidebarState = AppSidebarState.expanded; - }); - }, - onOpenHome: controller.navigateHome, - onOpenAccount: () {}, - onOpenThemeToggle: () => controller.setThemeMode( - controller.themeMode == ThemeMode.dark - ? ThemeMode.light - : ThemeMode.dark, + Row( + children: [ + if (_sidebarState != AppSidebarState.hidden) + SidebarNavigation( + currentSection: currentDestination, + sidebarState: _sidebarState, + appLanguage: controller.appLanguage, + themeMode: controller.themeMode, + onSectionChanged: (destination) { + if (destination == + WorkspaceDestination.settings) { + controller.openSettings( + tab: SettingsTab.general, + ); + return; + } + controller.navigateTo(destination); + }, + onToggleLanguage: controller.toggleAppLanguage, + onCycleSidebarState: _toggleSidebarVisibility, + onExpandFromCollapsed: _toggleSidebarVisibility, + onOpenHome: controller.navigateHome, + onOpenAccount: () {}, + onOpenThemeToggle: () => controller.setThemeMode( + controller.themeMode == ThemeMode.dark + ? ThemeMode.light + : ThemeMode.dark, + ), + accountName: + controller.settings.accountUsername + .trim() + .isNotEmpty + ? controller.settings.accountUsername + : appText('Web 操作员', 'Web operator'), + accountSubtitle: + controller.settings.accountWorkspace + .trim() + .isNotEmpty + ? controller.settings.accountWorkspace + : appText('Web 工作区', 'Web workspace'), + accountWorkspaceFollowed: + controller.settings.accountWorkspaceFollowed, + onToggleAccountWorkspaceFollowed: + controller.toggleAccountWorkspaceFollowed, + expandedWidthOverride: + _sidebarState == AppSidebarState.expanded + ? expandedSidebarWidth + : null, + favoriteDestinations: controller + .assistantNavigationDestinations + .toSet(), + onToggleFavorite: + controller.toggleAssistantNavigationDestination, + availableDestinations: + controller.capabilities.allowedDestinations, + currentSettingsTab: controller.settingsTab, + availableSettingsTabs: + uiFeatures.availableSettingsTabs, + onSettingsTabChanged: (tab) => + controller.openSettings(tab: tab), + taskItems: sidebarTaskItems, + assistantSkillCount: + controller.currentAssistantSkillCount, + onRefreshTasks: controller.refreshSessions, + onCreateTask: () async { + await controller.createConversation( + target: controller.assistantExecutionTarget, + ); + controller.navigateTo( + WorkspaceDestination.assistant, + ); + }, + onSelectTask: (sessionKey) async { + controller.navigateTo( + WorkspaceDestination.assistant, + ); + await controller.switchConversation(sessionKey); + }, + onArchiveTask: (sessionKey) => + controller.saveAssistantTaskArchived( + sessionKey, + true, + ), + onRenameTask: (sessionKey, title) => + controller.saveAssistantTaskTitle( + sessionKey, + title, + ), + ), + if (_sidebarState == AppSidebarState.expanded) + PaneResizeHandle( + axis: Axis.horizontal, + onDelta: (delta) { + setState(() { + _sidebarExpandedWidth = _clampSidebarWidth( + expandedSidebarWidth + delta, + constraints.maxWidth, + ); + }); + }, + ), + Expanded( + child: _WebShellBody( + child: _buildPage( + controller, + destination: currentDestination, + ), + ), ), - accountName: - controller.settings.accountUsername - .trim() - .isNotEmpty - ? controller.settings.accountUsername - : appText('Web 操作员', 'Web operator'), - accountSubtitle: - controller.settings.accountWorkspace - .trim() - .isNotEmpty - ? controller.settings.accountWorkspace - : appText('Web 工作区', 'Web workspace'), - accountWorkspaceFollowed: - controller.settings.accountWorkspaceFollowed, - onToggleAccountWorkspaceFollowed: - controller.toggleAccountWorkspaceFollowed, - expandedWidthOverride: - _sidebarState == AppSidebarState.expanded - ? expandedSidebarWidth - : null, - favoriteDestinations: controller - .assistantNavigationDestinations - .toSet(), - onToggleFavorite: - controller.toggleAssistantNavigationDestination, - availableDestinations: - controller.capabilities.allowedDestinations, - currentSettingsTab: controller.settingsTab, - availableSettingsTabs: - uiFeatures.availableSettingsTabs, - onSettingsTabChanged: (tab) => - controller.openSettings(tab: tab), - ), - if (_sidebarState == AppSidebarState.expanded) - PaneResizeHandle( - axis: Axis.horizontal, - onDelta: (delta) { - setState(() { - _sidebarExpandedWidth = _clampSidebarWidth( - expandedSidebarWidth + delta, - constraints.maxWidth, - ); - }); - }, - ), - Expanded( - child: _WebShellBody( - child: _buildPage( - controller, - destination: currentDestination, - ), - ), + ], ), + if (_sidebarState == AppSidebarState.hidden) + Positioned( + left: 8, + bottom: 8, + child: _SidebarRevealRail( + onExpand: _toggleSidebarVisibility, + ), + ), ], ); }, @@ -256,6 +308,49 @@ class _AppShellState extends State { } } +class _SidebarRevealRail extends StatefulWidget { + const _SidebarRevealRail({required this.onExpand}); + + final VoidCallback onExpand; + + @override + State<_SidebarRevealRail> createState() => _SidebarRevealRailState(); +} + +class _SidebarRevealRailState extends State<_SidebarRevealRail> { + bool _hovered = false; + + @override + Widget build(BuildContext context) { + final palette = context.palette; + return MouseRegion( + onEnter: (_) => setState(() => _hovered = true), + onExit: (_) => setState(() => _hovered = false), + child: Tooltip( + message: appText('展开左栏', 'Expand sidebar'), + child: GestureDetector( + onTap: widget.onExpand, + child: AnimatedContainer( + duration: const Duration(milliseconds: 180), + width: _hovered ? 40 : 32, + height: _hovered ? 40 : 32, + decoration: BoxDecoration( + color: _hovered ? palette.surfacePrimary : palette.chromeSurface, + borderRadius: BorderRadius.circular(999), + border: Border.all(color: palette.strokeSoft), + ), + child: Icon( + Icons.keyboard_double_arrow_right_rounded, + size: 18, + color: palette.textSecondary, + ), + ), + ), + ), + ); + } +} + class _WebShellBody extends StatelessWidget { const _WebShellBody({required this.child}); diff --git a/lib/app/workspace_page_registry.dart b/lib/app/workspace_page_registry.dart index d0fd8c07..ab243fd1 100644 --- a/lib/app/workspace_page_registry.dart +++ b/lib/app/workspace_page_registry.dart @@ -38,6 +38,7 @@ workspacePageSpecsInternal = { desktopBuilder: (controller, onOpenDetail) => AssistantPage( controller: controller, onOpenDetail: onOpenDetail, + showStandaloneTaskRail: false, ), mobileBuilder: (controller, onOpenDetail) => AssistantPage( controller: controller, diff --git a/lib/web/web_assistant_page_core.dart b/lib/web/web_assistant_page_core.dart index 8e5827c0..cec8dfab 100644 --- a/lib/web/web_assistant_page_core.dart +++ b/lib/web/web_assistant_page_core.dart @@ -19,8 +19,6 @@ import 'web_assistant_page_chrome.dart'; import 'web_assistant_page_workspace.dart'; import 'web_assistant_page_helpers.dart'; -const double webAssistantSidePaneMinWidthInternal = 304; -const double webAssistantSidePaneMaxWidthInternal = 420; const double webAssistantMainWorkspaceMinWidthInternal = 700; const double webAssistantComposerMinHeightInternal = 164; const double webAssistantConversationMinHeightInternal = 200; @@ -49,7 +47,6 @@ class WebAssistantPageStateInternal extends State { AssistantPermissionLevel.defaultAccess; bool useMultiAgentInternal = false; bool workspaceChromeCollapsedInternal = false; - double sidePaneWidthInternal = 344; bool artifactPaneCollapsedInternal = true; double artifactPaneWidthInternal = webAssistantArtifactPaneDefaultWidthInternal; @@ -88,19 +85,6 @@ class WebAssistantPageStateInternal extends State { return DesktopWorkspaceScaffold( child: LayoutBuilder( builder: (context, constraints) { - final maxSidePaneWidth = math.min( - webAssistantSidePaneMaxWidthInternal, - math.max( - webAssistantSidePaneMinWidthInternal, - constraints.maxWidth - - webAssistantMainWorkspaceMinWidthInternal, - ), - ); - final sidePaneWidth = sidePaneWidthInternal.clamp( - webAssistantSidePaneMinWidthInternal, - maxSidePaneWidth, - ); - return Column( children: [ AssistantWorkspaceChromeInternal( @@ -115,113 +99,38 @@ class WebAssistantPageStateInternal extends State { ), const SizedBox(height: 8), Expanded( - child: Row( - children: [ - SizedBox( - width: sidePaneWidth, - child: AssistantTaskPaneInternal( - controller: controller, - query: queryInternal, - searchController: searchControllerInternal, - onQueryChanged: (value) { - setState( - () => - queryInternal = value.trim().toLowerCase(), - ); - }, - onClearQuery: () { - searchControllerInternal.clear(); - setState(() => queryInternal = ''); - }, - showSingle: controller - .featuresFor(UiFeaturePlatform.web) - .supportsDirectAi, - showLocal: controller - .featuresFor(UiFeaturePlatform.web) - .supportsLocalGateway, - showRemote: controller - .featuresFor(UiFeaturePlatform.web) - .supportsRelayGateway, - single: filterConversationsInternal( - controller.conversationsForTarget( - AssistantExecutionTarget.singleAgent, - ), - queryInternal, - ), - local: filterConversationsInternal( - controller.conversationsForTarget( - AssistantExecutionTarget.local, - ), - queryInternal, - ), - remote: filterConversationsInternal( - controller.conversationsForTarget( - AssistantExecutionTarget.remote, - ), - queryInternal, - ), - onRename: renameConversationInternal, - onArchive: (sessionKey) => controller - .saveAssistantTaskArchived(sessionKey, true), - onOpenActions: openConversationActionsInternal, - ), - ), - SizedBox( - width: 8, - child: PaneResizeHandle( - axis: Axis.horizontal, - onDelta: (delta) { - setState(() { - sidePaneWidthInternal = - (sidePaneWidthInternal + delta) - .clamp( - webAssistantSidePaneMinWidthInternal, - maxSidePaneWidth, - ) - .toDouble(); - }); - }, - ), - ), - Expanded( - child: buildWorkspaceWithArtifactsInternal( - controller: controller, - child: ConversationWorkspaceInternal( - controller: controller, - scrollController: scrollControllerInternal, - inputController: inputControllerInternal, - currentMessages: currentMessages, - connectionState: connectionState, - thinkingLevel: thinkingLevelInternal, - permissionLevel: permissionLevelInternal, - useMultiAgent: useMultiAgentInternal, - attachments: attachmentsInternal, - composerHeight: composerHeightInternal, - onComposerHeightChanged: (value) { - setState(() => composerHeightInternal = value); - }, - onThinkingChanged: (value) { - setState(() => thinkingLevelInternal = value); - }, - onPermissionChanged: (value) { - setState(() => permissionLevelInternal = value); - }, - onToggleMultiAgent: (value) { - setState(() => useMultiAgentInternal = value); - }, - onAddAttachment: pickAttachmentsInternal, - onRemoveAttachment: (index) { - setState( - () => attachmentsInternal.removeAt(index), - ); - }, - onOpenSessionSettings: - openSessionSettingsInternal, - onSubmit: submitPromptInternal, - ), - ), - ), - ], + child: buildWorkspaceWithArtifactsInternal( + controller: controller, + child: ConversationWorkspaceInternal( + controller: controller, + scrollController: scrollControllerInternal, + inputController: inputControllerInternal, + currentMessages: currentMessages, + connectionState: connectionState, + thinkingLevel: thinkingLevelInternal, + permissionLevel: permissionLevelInternal, + useMultiAgent: useMultiAgentInternal, + attachments: attachmentsInternal, + composerHeight: composerHeightInternal, + onComposerHeightChanged: (value) { + setState(() => composerHeightInternal = value); + }, + onThinkingChanged: (value) { + setState(() => thinkingLevelInternal = value); + }, + onPermissionChanged: (value) { + setState(() => permissionLevelInternal = value); + }, + onToggleMultiAgent: (value) { + setState(() => useMultiAgentInternal = value); + }, + onAddAttachment: pickAttachmentsInternal, + onRemoveAttachment: (index) { + setState(() => attachmentsInternal.removeAt(index)); + }, + onOpenSessionSettings: openSessionSettingsInternal, + onSubmit: submitPromptInternal, + ), ), ), ], diff --git a/lib/widgets/sidebar_navigation.dart b/lib/widgets/sidebar_navigation.dart index 6518ecca..ecf24e34 100644 --- a/lib/widgets/sidebar_navigation.dart +++ b/lib/widgets/sidebar_navigation.dart @@ -2,6 +2,7 @@ import 'package:flutter/material.dart'; import '../i18n/app_language.dart'; import '../models/app_models.dart'; +import '../runtime/runtime_models.dart'; import '../theme/app_palette.dart'; import '../theme/app_theme.dart'; import 'chrome_quick_action_buttons.dart'; @@ -34,6 +35,13 @@ class SidebarNavigation extends StatelessWidget { this.currentSettingsTab, this.availableSettingsTabs = const [], this.onSettingsTabChanged, + this.taskItems = const [], + this.assistantSkillCount = 0, + this.onRefreshTasks, + this.onCreateTask, + this.onSelectTask, + this.onArchiveTask, + this.onRenameTask, }); final WorkspaceDestination currentSection; @@ -61,6 +69,13 @@ class SidebarNavigation extends StatelessWidget { final SettingsTab? currentSettingsTab; final List availableSettingsTabs; final ValueChanged? onSettingsTabChanged; + final List taskItems; + final int assistantSkillCount; + final Future Function()? onRefreshTasks; + final Future Function()? onCreateTask; + final Future Function(String sessionKey)? onSelectTask; + final Future Function(String sessionKey)? onArchiveTask; + final Future Function(String sessionKey, String title)? onRenameTask; static const _primarySections = [ WorkspaceDestination.assistant, @@ -85,6 +100,7 @@ class SidebarNavigation extends StatelessWidget { final palette = context.palette; final isExpanded = sidebarState == AppSidebarState.expanded; final isCollapsed = sidebarState == AppSidebarState.collapsed; + final showTaskSection = !isCollapsed; final primarySections = _filterSections(_primarySections); final workspaceSections = _filterSections(_workspaceSections); final toolSections = _filterSections(_toolSections); @@ -110,11 +126,7 @@ class SidebarNavigation extends StatelessWidget { child: Column( crossAxisAlignment: CrossAxisAlignment.stretch, children: [ - SidebarHeader( - isCollapsed: !isExpanded, - onTap: isCollapsed ? onExpandFromCollapsed : null, - ), - const SizedBox(height: 6), + const SizedBox(height: 4), Expanded( child: Column( crossAxisAlignment: CrossAxisAlignment.stretch, @@ -125,6 +137,29 @@ class SidebarNavigation extends StatelessWidget { child: Column( crossAxisAlignment: CrossAxisAlignment.stretch, children: [ + if (showTaskSection) + SidebarTaskSection( + items: taskItems, + skillCount: assistantSkillCount, + onRefreshTasks: onRefreshTasks, + onCreateTask: onCreateTask, + onSelectTask: onSelectTask, + onArchiveTask: onArchiveTask, + onRenameTask: onRenameTask, + ), + if (showTaskSection && + (primarySections.isNotEmpty || + workspaceSections.isNotEmpty || + toolSections.isNotEmpty)) + Padding( + padding: const EdgeInsets.symmetric(vertical: 8), + child: Container( + height: 1, + color: palette.chromeStroke.withValues( + alpha: 0.9, + ), + ), + ), if (primarySections.isNotEmpty) _SidebarSectionGroup( sections: primarySections, @@ -225,6 +260,595 @@ class SidebarNavigation extends StatelessWidget { } } +class SidebarTaskItem { + const SidebarTaskItem({ + required this.sessionKey, + required this.title, + required this.preview, + required this.updatedAtMs, + required this.executionTarget, + required this.isCurrent, + required this.pending, + this.draft = false, + }); + + final String sessionKey; + final String title; + final String preview; + final double? updatedAtMs; + final AssistantExecutionTarget executionTarget; + final bool isCurrent; + final bool pending; + final bool draft; +} + +class SidebarTaskSection extends StatefulWidget { + const SidebarTaskSection({ + super.key, + required this.items, + required this.skillCount, + this.onRefreshTasks, + this.onCreateTask, + this.onSelectTask, + this.onArchiveTask, + this.onRenameTask, + }); + + final List items; + final int skillCount; + final Future Function()? onRefreshTasks; + final Future Function()? onCreateTask; + final Future Function(String sessionKey)? onSelectTask; + final Future Function(String sessionKey)? onArchiveTask; + final Future Function(String sessionKey, String title)? onRenameTask; + + @override + State createState() => _SidebarTaskSectionState(); +} + +class _SidebarTaskSectionState extends State { + final TextEditingController _searchController = TextEditingController(); + final Set _expandedTargets = + {}; + String _query = ''; + + @override + void initState() { + super.initState(); + _syncExpandedTargets(); + } + + @override + void didUpdateWidget(covariant SidebarTaskSection oldWidget) { + super.didUpdateWidget(oldWidget); + if (oldWidget.items != widget.items) { + _syncExpandedTargets(); + } + } + + @override + void dispose() { + _searchController.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + final palette = context.palette; + final filteredItems = _filteredItems(); + final groups = _groupedItems(filteredItems); + final runningCount = filteredItems.where((item) => item.pending).length; + + return Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Padding( + padding: const EdgeInsets.fromLTRB(4, 0, 4, 8), + child: TextField( + key: const Key('workspace-sidebar-task-search'), + controller: _searchController, + onChanged: (value) { + setState(() { + _query = value.trim().toLowerCase(); + }); + }, + decoration: InputDecoration( + hintText: appText('搜索任务', 'Search tasks'), + prefixIcon: const Icon(Icons.search_rounded), + suffixIcon: Row( + mainAxisSize: MainAxisSize.min, + children: [ + if (_query.isNotEmpty) + IconButton( + tooltip: appText('清除搜索', 'Clear search'), + onPressed: () { + _searchController.clear(); + setState(() { + _query = ''; + }); + }, + icon: const Icon(Icons.close_rounded), + ), + if (widget.onRefreshTasks != null) + IconButton( + key: const Key('workspace-sidebar-task-refresh'), + tooltip: appText('刷新任务', 'Refresh tasks'), + onPressed: () async { + await widget.onRefreshTasks!(); + }, + icon: const Icon(Icons.refresh_rounded), + ), + ], + ), + ), + ), + ), + Padding( + padding: const EdgeInsets.fromLTRB(4, 0, 4, 8), + child: FilledButton.tonalIcon( + key: const Key('workspace-sidebar-new-task-button'), + onPressed: widget.onCreateTask == null + ? null + : () async { + await widget.onCreateTask!(); + }, + icon: const Icon(Icons.edit_note_rounded), + label: Text(appText('新对话', 'New conversation')), + style: FilledButton.styleFrom( + minimumSize: const Size(0, 40), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(10), + ), + ), + ), + ), + Padding( + padding: const EdgeInsets.fromLTRB(4, 0, 4, 8), + child: Wrap( + spacing: 6, + runSpacing: 6, + children: [ + _SidebarMetaPill( + icon: Icons.play_circle_outline_rounded, + label: '${appText('运行中', 'Running')} $runningCount', + ), + _SidebarMetaPill( + icon: Icons.forum_outlined, + label: '${appText('当前', 'Current')} ${filteredItems.length}', + ), + _SidebarMetaPill( + icon: Icons.auto_awesome_rounded, + label: '${appText('技能', 'Skills')} ${widget.skillCount}', + ), + ], + ), + ), + Padding( + padding: const EdgeInsets.fromLTRB(4, 4, 4, 6), + child: Row( + children: [ + Text(appText('任务列表', 'Task list'), style: theme.textTheme.titleSmall), + const SizedBox(width: 6), + Text( + '${filteredItems.length}', + style: theme.textTheme.bodySmall?.copyWith( + color: palette.textMuted, + ), + ), + ], + ), + ), + for (final group in groups) ...[ + _SidebarTaskGroupHeader( + executionTarget: group.executionTarget, + count: group.items.length, + expanded: _expandedTargets.contains(group.executionTarget), + onTap: () { + setState(() { + if (_expandedTargets.contains(group.executionTarget)) { + _expandedTargets.remove(group.executionTarget); + } else { + _expandedTargets.add(group.executionTarget); + } + }); + }, + ), + if (_expandedTargets.contains(group.executionTarget)) ...[ + if (group.items.isEmpty) + Padding( + padding: const EdgeInsets.fromLTRB(28, 0, 8, 6), + child: Text( + appText('当前分组没有任务。', 'No tasks in this group.'), + style: theme.textTheme.bodySmall?.copyWith( + color: palette.textMuted, + ), + ), + ), + for (final item in group.items) + Padding( + padding: const EdgeInsets.only(bottom: 4), + child: _SidebarTaskTile( + item: item, + onTap: widget.onSelectTask == null + ? null + : () async { + await widget.onSelectTask!(item.sessionKey); + }, + onArchive: widget.onArchiveTask == null || item.pending + ? null + : () async { + await widget.onArchiveTask!(item.sessionKey); + }, + onRename: widget.onRenameTask == null + ? null + : () async { + final renamed = await _promptRenameTask( + context, + item.title, + ); + if (!mounted || renamed == null) { + return; + } + await widget.onRenameTask!(item.sessionKey, renamed); + }, + ), + ), + ], + const SizedBox(height: 4), + ], + ], + ); + } + + List _filteredItems() { + if (_query.isEmpty) { + return widget.items; + } + return widget.items.where((item) { + final haystack = '${item.title}\n${item.preview}\n${item.sessionKey}' + .toLowerCase(); + return haystack.contains(_query); + }).toList(growable: false); + } + + List<_SidebarTaskGroup> _groupedItems(List items) { + final grouped = >{ + for (final target in AssistantExecutionTarget.values) + target: [], + }; + for (final item in items) { + grouped[item.executionTarget]!.add(item); + } + return AssistantExecutionTarget.values + .map( + (target) => _SidebarTaskGroup( + executionTarget: target, + items: grouped[target]!, + ), + ) + .toList(growable: false); + } + + Future _promptRenameTask( + BuildContext context, + String currentTitle, + ) async { + final input = TextEditingController(text: currentTitle); + final result = await showDialog( + context: context, + builder: (context) => AlertDialog( + title: Text(appText('重命名任务', 'Rename task')), + content: TextField( + key: const Key('workspace-sidebar-task-rename-input'), + controller: input, + autofocus: true, + decoration: InputDecoration( + labelText: appText('任务名称', 'Task name'), + hintText: appText('留空后恢复默认名称', 'Leave empty to restore default'), + ), + onSubmitted: (value) => Navigator.of(context).pop(value.trim()), + ), + actions: [ + TextButton( + onPressed: () => Navigator.of(context).pop(), + child: Text(appText('取消', 'Cancel')), + ), + FilledButton( + onPressed: () => Navigator.of(context).pop(input.text.trim()), + child: Text(appText('保存', 'Save')), + ), + ], + ), + ); + input.dispose(); + return result; + } + + void _syncExpandedTargets() { + if (_expandedTargets.isNotEmpty) { + return; + } + _expandedTargets.addAll(AssistantExecutionTarget.values); + } +} + +class _SidebarTaskGroup { + const _SidebarTaskGroup({ + required this.executionTarget, + required this.items, + }); + + final AssistantExecutionTarget executionTarget; + final List items; +} + +class _SidebarMetaPill extends StatelessWidget { + const _SidebarMetaPill({required this.icon, required this.label}); + + final IconData icon; + final String label; + + @override + Widget build(BuildContext context) { + final palette = context.palette; + return Container( + padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 6), + decoration: BoxDecoration( + color: palette.surfacePrimary, + borderRadius: BorderRadius.circular(999), + border: Border.all(color: palette.strokeSoft), + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon(icon, size: 14, color: palette.textSecondary), + const SizedBox(width: 6), + Text( + label, + style: Theme.of(context).textTheme.labelMedium?.copyWith( + color: palette.textSecondary, + fontWeight: FontWeight.w600, + ), + ), + ], + ), + ); + } +} + +class _SidebarTaskGroupHeader extends StatelessWidget { + const _SidebarTaskGroupHeader({ + 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('workspace-sidebar-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( + _sidebarTaskTargetIcon(executionTarget), + size: 14, + color: palette.textMuted, + ), + const SizedBox(width: 6), + Expanded( + 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 _SidebarTaskTile extends StatelessWidget { + const _SidebarTaskTile({ + required this.item, + this.onTap, + this.onArchive, + this.onRename, + }); + + final SidebarTaskItem item; + final Future Function()? onTap; + final Future Function()? onArchive; + final Future Function()? onRename; + + @override + Widget build(BuildContext context) { + final palette = context.palette; + final theme = Theme.of(context); + return Material( + color: item.isCurrent ? palette.surfacePrimary : Colors.transparent, + borderRadius: BorderRadius.circular(8), + child: InkWell( + key: ValueKey('workspace-sidebar-task-item-${item.sessionKey}'), + borderRadius: BorderRadius.circular(8), + onTap: onTap == null + ? null + : () async { + await onTap!(); + }, + onLongPress: onRename == null + ? null + : () async { + await onRename!(); + }, + onSecondaryTap: onRename == null + ? null + : () async { + await onRename!(); + }, + child: Container( + padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 8), + decoration: BoxDecoration( + color: item.isCurrent ? palette.surfaceSecondary : Colors.transparent, + borderRadius: BorderRadius.circular(8), + border: Border.all( + color: item.isCurrent ? palette.strokeSoft : Colors.transparent, + ), + ), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Container( + width: 24, + height: 24, + decoration: BoxDecoration( + color: item.pending + ? palette.accentMuted.withValues(alpha: 0.88) + : palette.surfacePrimary, + borderRadius: BorderRadius.circular(6), + ), + child: Icon( + item.draft + ? Icons.edit_note_rounded + : item.pending + ? Icons.play_arrow_rounded + : Icons.task_alt_rounded, + size: 15, + color: item.pending ? palette.accent : palette.textSecondary, + ), + ), + const SizedBox(width: 8), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + item.title, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: theme.textTheme.titleSmall?.copyWith( + fontWeight: item.isCurrent + ? FontWeight.w700 + : FontWeight.w600, + ), + ), + if (item.preview.trim().isNotEmpty) ...[ + const SizedBox(height: 2), + Text( + item.preview.trim(), + maxLines: 2, + overflow: TextOverflow.ellipsis, + style: theme.textTheme.bodySmall?.copyWith( + color: palette.textMuted, + ), + ), + ], + ], + ), + ), + const SizedBox(width: 8), + Column( + crossAxisAlignment: CrossAxisAlignment.end, + children: [ + Text( + _sidebarTaskUpdatedAtLabel(item.updatedAtMs), + style: theme.textTheme.bodySmall?.copyWith( + color: palette.textMuted, + ), + ), + if (onArchive != null) + IconButton( + key: ValueKey( + 'workspace-sidebar-task-archive-${item.sessionKey}', + ), + tooltip: appText('归档任务', 'Archive task'), + visualDensity: VisualDensity.compact, + splashRadius: 12, + onPressed: () async { + await onArchive!(); + }, + icon: Icon( + Icons.archive_outlined, + size: 18, + color: palette.textMuted, + ), + ), + ], + ), + ], + ), + ), + ), + ); + } +} + +String _sidebarTaskUpdatedAtLabel(double? updatedAtMs) { + if (updatedAtMs == null) { + return ''; + } + final timestamp = DateTime.fromMillisecondsSinceEpoch(updatedAtMs.round()); + final now = DateTime.now(); + final delta = now.difference(timestamp); + if (delta.inMinutes < 1) { + return appText('刚刚', 'Just now'); + } + if (delta.inHours < 1) { + return appText('${delta.inMinutes} 分钟前', '${delta.inMinutes}m ago'); + } + if (delta.inDays < 1) { + return appText('${delta.inHours} 小时前', '${delta.inHours}h ago'); + } + if (delta.inDays < 7) { + return appText('${delta.inDays} 天前', '${delta.inDays}d ago'); + } + return '${timestamp.month}/${timestamp.day}'; +} + +IconData _sidebarTaskTargetIcon(AssistantExecutionTarget target) { + return switch (target) { + AssistantExecutionTarget.singleAgent => Icons.hub_outlined, + AssistantExecutionTarget.local => Icons.computer_outlined, + AssistantExecutionTarget.remote => Icons.cloud_outlined, + }; +} + class SidebarHeader extends StatelessWidget { const SidebarHeader({super.key, required this.isCollapsed, this.onTap}); @@ -857,9 +1481,9 @@ class SidebarFooter extends StatelessWidget { IconData _sidebarStateIcon(AppSidebarState state) { return switch (state) { - AppSidebarState.expanded => Icons.view_sidebar_rounded, - AppSidebarState.collapsed => Icons.menu_rounded, - AppSidebarState.hidden => Icons.view_sidebar_rounded, + AppSidebarState.expanded => Icons.keyboard_double_arrow_left_rounded, + AppSidebarState.collapsed => Icons.keyboard_double_arrow_right_rounded, + AppSidebarState.hidden => Icons.keyboard_double_arrow_right_rounded, }; } diff --git a/test/web/web_ui_browser_test.dart b/test/web/web_ui_browser_test.dart index f2b1d1b4..247c9363 100644 --- a/test/web/web_ui_browser_test.dart +++ b/test/web/web_ui_browser_test.dart @@ -22,7 +22,15 @@ void main() { await tester.pumpAndSettle(); expect(find.text('助手'), findsWidgets); - expect(find.byKey(const Key('assistant-task-rail')), findsOneWidget); + expect(find.byKey(const Key('assistant-task-rail')), findsNothing); + expect( + find.byKey(const Key('workspace-sidebar-task-search')), + findsOneWidget, + ); + expect( + find.byKey(const Key('workspace-sidebar-new-task-button')), + findsOneWidget, + ); expect( find.byKey(const Key('assistant-workspace-chrome-toggle')), findsOneWidget, @@ -88,7 +96,8 @@ void main() { await tester.pumpAndSettle(); expect(find.byType(SidebarNavigation), findsOneWidget); - await tester.tap(find.text('自动化')); + await tester.ensureVisible(find.text('自动化')); + await tester.tap(find.text('自动化').hitTestable()); await tester.pumpAndSettle(); expect(find.text('任务工作台'), findsOneWidget); diff --git a/test/widgets/sidebar_navigation_suite.dart b/test/widgets/sidebar_navigation_suite.dart index fdab41e0..5042398d 100644 --- a/test/widgets/sidebar_navigation_suite.dart +++ b/test/widgets/sidebar_navigation_suite.dart @@ -5,8 +5,8 @@ import 'package:flutter/material.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:xworkmate/i18n/app_language.dart'; import 'package:xworkmate/models/app_models.dart'; +import 'package:xworkmate/runtime/runtime_models.dart'; import 'package:xworkmate/theme/app_theme.dart'; -import 'package:xworkmate/widgets/app_brand_logo.dart'; import 'package:xworkmate/widgets/sidebar_navigation.dart'; void main() { @@ -88,7 +88,8 @@ void main() { expect(find.text('工具'), findsOneWidget); expect(find.text('MCP Hub'), findsOneWidget); - await tester.tap(find.text('自动化')); + await tester.ensureVisible(find.text('自动化')); + await tester.tap(find.text('自动化').hitTestable()); await tester.pumpAndSettle(); expect(selected, WorkspaceDestination.tasks); @@ -201,9 +202,10 @@ void main() { await tester.pumpAndSettle(); expect(find.text('回到 APP首页'), findsOneWidget); - expect(find.text('新对话'), findsNothing); + expect(find.text('新对话'), findsWidgets); - await tester.tap(find.text('回到 APP首页')); + await tester.ensureVisible(find.text('回到 APP首页')); + await tester.tap(find.text('回到 APP首页').hitTestable()); await tester.pumpAndSettle(); expect(homeOpened, 1); @@ -269,7 +271,7 @@ void main() { expect(changedTabs, [SettingsTab.gateway]); }); - testWidgets('SidebarNavigation header uses chevron instead of brand logo', ( + testWidgets('SidebarNavigation merges task controls into the global left bar', ( WidgetTester tester, ) async { await tester.pumpWidget( @@ -291,13 +293,35 @@ void main() { accountName: 'Tester', accountSubtitle: 'Workspace', onToggleAccountWorkspaceFollowed: () async {}, + assistantSkillCount: 3, + taskItems: const [ + SidebarTaskItem( + sessionKey: 'draft:1', + title: '新的任务', + preview: '等待输入', + updatedAtMs: 1710000000000, + executionTarget: AssistantExecutionTarget.singleAgent, + isCurrent: true, + pending: false, + draft: true, + ), + ], ), ), ), ); await tester.pumpAndSettle(); - expect(find.byIcon(Icons.chevron_right_rounded), findsOneWidget); - expect(find.byType(AppBrandLogo), findsNothing); + expect( + find.byKey(const Key('workspace-sidebar-task-search')), + findsOneWidget, + ); + expect( + find.byKey(const Key('workspace-sidebar-new-task-button')), + findsOneWidget, + ); + expect(find.text('任务列表'), findsOneWidget); + expect(find.text('自动化'), findsOneWidget); + expect(find.text('新的任务'), findsOneWidget); }); }