diff --git a/lib/app/app_controller_desktop_core.dart b/lib/app/app_controller_desktop_core.dart index 1dce5bab..39bd7cbd 100644 --- a/lib/app/app_controller_desktop_core.dart +++ b/lib/app/app_controller_desktop_core.dart @@ -590,7 +590,7 @@ class AppController extends ChangeNotifier { List get configuredSingleAgentProviders => normalizeSingleAgentProviderList( (availableSingleAgentProvidersOverrideInternal ?? - settings.availableSingleAgentProviders) + settings.savedSingleAgentProviders) .where((item) => item != SingleAgentProvider.auto) .map(settings.resolveSingleAgentProvider), ); @@ -600,6 +600,15 @@ class AppController extends ChangeNotifier { .where(canUseSingleAgentProviderInternal) .toList(growable: false); + List visibleAssistantExecutionTargets( + Iterable supportedTargets, + ) { + return settings.visibleAssistantExecutionTargets( + supportedTargets: supportedTargets, + availableSingleAgentProviders: availableSingleAgentProviders, + ); + } + bool get hasAnyAvailableSingleAgentProvider => availableSingleAgentProviders.isNotEmpty; diff --git a/lib/app/app_controller_desktop_settings.dart b/lib/app/app_controller_desktop_settings.dart index c37878eb..bc4ab235 100644 --- a/lib/app/app_controller_desktop_settings.dart +++ b/lib/app/app_controller_desktop_settings.dart @@ -237,7 +237,20 @@ extension AppControllerDesktopSettings on AppController { return; } final previous = settings; - await persistSettingsSnapshotInternal(snapshot); + var nextSnapshot = snapshot; + if (jsonEncode(previous.primaryLocalGatewayProfile.toJson()) != + jsonEncode(snapshot.primaryLocalGatewayProfile.toJson())) { + nextSnapshot = nextSnapshot.markGatewayTargetSaved( + AssistantExecutionTarget.local, + ); + } + if (jsonEncode(previous.primaryRemoteGatewayProfile.toJson()) != + jsonEncode(snapshot.primaryRemoteGatewayProfile.toJson())) { + nextSnapshot = nextSnapshot.markGatewayTargetSaved( + AssistantExecutionTarget.remote, + ); + } + await persistSettingsSnapshotInternal(nextSnapshot); if (disposedInternal) { return; } diff --git a/lib/app/app_controller_desktop_thread_sessions.dart b/lib/app/app_controller_desktop_thread_sessions.dart index fef7a809..6914fca6 100644 --- a/lib/app/app_controller_desktop_thread_sessions.dart +++ b/lib/app/app_controller_desktop_thread_sessions.dart @@ -116,9 +116,9 @@ extension AppControllerDesktopThreadSessions on AppController { ); final target = assistantExecutionTargetForSession(normalizedSessionKey); final latestResolvedModel = - taskThreadForSessionInternal(normalizedSessionKey) - ?.latestResolvedRuntimeModel - .trim() ?? + taskThreadForSessionInternal( + normalizedSessionKey, + )?.latestResolvedRuntimeModel.trim() ?? ''; if (target == AssistantExecutionTarget.singleAgent || target == AssistantExecutionTarget.auto) { @@ -156,15 +156,19 @@ extension AppControllerDesktopThreadSessions on AppController { final normalizedSessionKey = normalizedAssistantSessionKeyInternal( sessionKey, ); - return taskThreadForSessionInternal(normalizedSessionKey) - ?.workspaceBinding - .workspacePath - .trim() ?? + return taskThreadForSessionInternal( + normalizedSessionKey, + )?.workspaceBinding.workspacePath.trim() ?? ''; } WorkspaceRefKind assistantWorkspaceKindForSession(String sessionKey) { - final record = requireTaskThreadForSessionInternal(sessionKey); + final record = taskThreadForSessionInternal( + normalizedAssistantSessionKeyInternal(sessionKey), + ); + if (record == null) { + return WorkspaceRefKind.localPath; + } return record.workspaceBinding.workspaceKind == WorkspaceKind.localFs ? WorkspaceRefKind.localPath : WorkspaceRefKind.remotePath; @@ -174,10 +178,9 @@ extension AppControllerDesktopThreadSessions on AppController { final normalizedSessionKey = normalizedAssistantSessionKeyInternal( sessionKey, ); - return taskThreadForSessionInternal(normalizedSessionKey) - ?.workspaceBinding - .displayPath - .trim() ?? + return taskThreadForSessionInternal( + normalizedSessionKey, + )?.workspaceBinding.displayPath.trim() ?? ''; } @@ -212,9 +215,9 @@ extension AppControllerDesktopThreadSessions on AppController { sessionKey, ); final stored = SingleAgentProviderCopy.fromJsonValue( - taskThreadForSessionInternal(normalizedSessionKey) - ?.executionBinding - .providerId ?? + taskThreadForSessionInternal( + normalizedSessionKey, + )?.executionBinding.providerId ?? '', ); return settings.resolveSingleAgentProvider(stored); @@ -310,9 +313,9 @@ extension AppControllerDesktopThreadSessions on AppController { final normalizedSessionKey = normalizedAssistantSessionKeyInternal( sessionKey, ); - return taskThreadForSessionInternal(normalizedSessionKey) - ?.latestResolvedRuntimeModel - .trim() ?? + return taskThreadForSessionInternal( + normalizedSessionKey, + )?.latestResolvedRuntimeModel.trim() ?? ''; } @@ -403,13 +406,13 @@ extension AppControllerDesktopThreadSessions on AppController { if (target == AssistantExecutionTarget.singleAgent || target == AssistantExecutionTarget.auto) { final thread = taskThreadForSessionInternal(normalizedSessionKey); - final resolvedGatewayEntryState = switch ( - thread?.gatewayEntryState?.trim() ?? '' - ) { - 'auto' => '', - final value => value, - }; - final latestResolvedModel = thread?.latestResolvedRuntimeModel.trim() ?? ''; + final resolvedGatewayEntryState = + switch (thread?.gatewayEntryState?.trim() ?? '') { + 'auto' => '', + final value => value, + }; + final latestResolvedModel = + thread?.latestResolvedRuntimeModel.trim() ?? ''; final primaryLabel = target == AssistantExecutionTarget.auto ? 'Auto' : target.label; @@ -444,11 +447,13 @@ extension AppControllerDesktopThreadSessions on AppController { latestResolvedModel, ]), _ => joinConnectionPartsInternal([ - singleAgentResolvedProviderForSession(normalizedSessionKey) - ?.label - .isNotEmpty == + singleAgentResolvedProviderForSession( + normalizedSessionKey, + )?.label.isNotEmpty == true - ? singleAgentResolvedProviderForSession(normalizedSessionKey)!.label + ? singleAgentResolvedProviderForSession( + normalizedSessionKey, + )!.label : appText('Single Agent', 'Single Agent'), latestResolvedModel, ]), diff --git a/lib/app/app_controller_desktop_thread_storage.dart b/lib/app/app_controller_desktop_thread_storage.dart index 00e296b0..7b6b9418 100644 --- a/lib/app/app_controller_desktop_thread_storage.dart +++ b/lib/app/app_controller_desktop_thread_storage.dart @@ -261,12 +261,22 @@ extension AppControllerDesktopThreadStorage on AppController { GatewayChatMessage message, ) { final key = normalizedAssistantSessionKeyInternal(sessionKey); + final existingTitle = + assistantThreadRecordsInternal[key]?.title.trim() ?? ''; + final customTitle = + settings.assistantCustomTaskTitles[key]?.trim() ?? ''; final next = List.from( assistantThreadMessagesInternal[key] ?? const [], )..add(message); assistantThreadMessagesInternal[key] = next; upsertTaskThreadInternal( key, + title: derivePersistedTaskTitle( + existingTitle, + next, + fallback: key, + hasCustomTitle: customTitle.isNotEmpty, + ), messages: next, updatedAtMs: message.timestampMs ?? diff --git a/lib/app/app_controller_web_gateway_chat.dart b/lib/app/app_controller_web_gateway_chat.dart index 207a6cb0..b73cc86f 100644 --- a/lib/app/app_controller_web_gateway_chat.dart +++ b/lib/app/app_controller_web_gateway_chat.dart @@ -89,7 +89,14 @@ extension AppControllerWebGatewayChat on AppController { sessionKey, messages: nextMessages, executionTarget: target, - title: deriveThreadTitleInternal(current.title, nextMessages), + title: deriveThreadTitleInternal( + current.title, + nextMessages, + hasCustomTitle: + (settingsInternal.assistantCustomTaskTitles[sessionKey]?.trim() ?? + '') + .isNotEmpty, + ), updatedAtMs: DateTime.now().millisecondsSinceEpoch.toDouble(), ); pendingSessionKeysInternal.add(sessionKey); diff --git a/lib/app/app_controller_web_gateway_config.dart b/lib/app/app_controller_web_gateway_config.dart index 15a8f7f1..fcd03ee1 100644 --- a/lib/app/app_controller_web_gateway_config.dart +++ b/lib/app/app_controller_web_gateway_config.dart @@ -148,6 +148,10 @@ extension AppControllerWebGatewayConfig on AppController { tls: mode == RuntimeConnectionMode.local ? false : tls, ), ), + ).markGatewayTargetSaved( + profileIndex == kGatewayLocalProfileIndex + ? AssistantExecutionTarget.local + : AssistantExecutionTarget.remote, ); relayTokenByProfileInternal[profileIndex] = token.trim(); relayPasswordByProfileInternal[profileIndex] = password.trim(); diff --git a/lib/app/app_controller_web_gateway_relay.dart b/lib/app/app_controller_web_gateway_relay.dart index 5bfed917..074e5239 100644 --- a/lib/app/app_controller_web_gateway_relay.dart +++ b/lib/app/app_controller_web_gateway_relay.dart @@ -260,6 +260,10 @@ extension AppControllerWebGatewayRelay on AppController { existing?.title ?? '', messages, fallback: resolvedKey, + hasCustomTitle: + (settingsInternal.assistantCustomTaskTitles[resolvedKey]?.trim() ?? + '') + .isNotEmpty, ), executionBinding: (existing?.executionBinding ?? ExecutionBinding( diff --git a/lib/app/app_controller_web_helpers.dart b/lib/app/app_controller_web_helpers.dart index ce488ab4..d17b8d49 100644 --- a/lib/app/app_controller_web_helpers.dart +++ b/lib/app/app_controller_web_helpers.dart @@ -1001,23 +1001,14 @@ extension AppControllerWebHelpers on AppController { String currentTitle, List messages, { String fallback = '', + bool hasCustomTitle = false, }) { - final trimmedCurrent = currentTitle.trim(); - if (trimmedCurrent.isNotEmpty && - trimmedCurrent != appText('新对话', 'New conversation')) { - return trimmedCurrent; - } - for (final message in messages) { - if (message.role.trim().toLowerCase() != 'user') { - continue; - } - final text = message.text.trim(); - if (text.isEmpty) { - continue; - } - return text.length <= 32 ? text : '${text.substring(0, 32)}...'; - } - return fallback.isEmpty ? appText('新对话', 'New conversation') : fallback; + return derivePersistedTaskTitle( + currentTitle, + messages, + fallback: fallback, + hasCustomTitle: hasCustomTitle, + ); } String hostLabelInternal(String rawUrl) { diff --git a/lib/app/app_controller_web_session_actions.dart b/lib/app/app_controller_web_session_actions.dart index 599efae7..f3c98bb7 100644 --- a/lib/app/app_controller_web_session_actions.dart +++ b/lib/app/app_controller_web_session_actions.dart @@ -25,9 +25,17 @@ import 'app_controller_web_helpers.dart'; extension AppControllerWebSessionActions on AppController { Future createConversation({AssistantExecutionTarget? target}) async { - final inheritedTarget = + final requestedTarget = sanitizeTargetInternal(target) ?? assistantExecutionTargetForSession(currentSessionKeyInternal); + final visibleTargets = visibleAssistantExecutionTargets(const [ + AssistantExecutionTarget.singleAgent, + AssistantExecutionTarget.local, + AssistantExecutionTarget.remote, + ]); + final inheritedTarget = visibleTargets.contains(requestedTarget) + ? requestedTarget + : (visibleTargets.isNotEmpty ? visibleTargets.first : requestedTarget); final inheritedRecord = taskThreadForSessionInternal(currentSessionKeyInternal); final baseRecord = newRecordInternal( target: inheritedTarget, @@ -108,9 +116,17 @@ extension AppControllerWebSessionActions on AppController { Future setAssistantExecutionTarget( AssistantExecutionTarget target, ) async { - final resolvedTarget = + final requestedTarget = sanitizeTargetInternal(target) ?? assistantExecutionTargetForSession(currentSessionKeyInternal); + final visibleTargets = visibleAssistantExecutionTargets(const [ + AssistantExecutionTarget.singleAgent, + AssistantExecutionTarget.local, + AssistantExecutionTarget.remote, + ]); + final resolvedTarget = visibleTargets.contains(requestedTarget) + ? requestedTarget + : (visibleTargets.isNotEmpty ? visibleTargets.first : requestedTarget); final sessionKey = normalizedSessionKeyInternal(currentSessionKeyInternal); upsertThreadRecordInternal( sessionKey, diff --git a/lib/app/app_controller_web_sessions.dart b/lib/app/app_controller_web_sessions.dart index d76fe42b..3f9ff56d 100644 --- a/lib/app/app_controller_web_sessions.dart +++ b/lib/app/app_controller_web_sessions.dart @@ -139,7 +139,19 @@ extension AppControllerWebSessions on AppController { singleAgentProviderForSession(currentSessionKeyInternal); List get singleAgentProviderOptions => - settingsInternal.availableSingleAgentProviders; + settingsInternal.savedSingleAgentProviders; + + List get availableSingleAgentProviders => + singleAgentProviderOptions; + + List visibleAssistantExecutionTargets( + Iterable supportedTargets, + ) { + return settingsInternal.visibleAssistantExecutionTargets( + supportedTargets: supportedTargets, + availableSingleAgentProviders: availableSingleAgentProviders, + ); + } bool singleAgentUsesAiChatFallbackForSession(String sessionKey) { final provider = singleAgentProviderForSession(sessionKey); diff --git a/lib/app/app_shell_desktop.dart b/lib/app/app_shell_desktop.dart index 12996eaf..baa7b050 100644 --- a/lib/app/app_shell_desktop.dart +++ b/lib/app/app_shell_desktop.dart @@ -4,6 +4,7 @@ import '../features/account/account_page.dart'; import '../features/mobile/mobile_shell.dart'; import '../i18n/app_language.dart'; import '../models/app_models.dart'; +import '../runtime/runtime_models.dart'; import '../theme/app_palette.dart'; import '../widgets/detail_drawer.dart'; import '../widgets/pane_resize_handle.dart'; @@ -52,30 +53,45 @@ class _AppShellState extends State { 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); + 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 { + Future _createSidebarConversation( + AppController controller, + List visibleTargets, + ) async { final sessionKey = 'draft:${DateTime.now().millisecondsSinceEpoch}'; + final target = + visibleTargets.contains(controller.currentAssistantExecutionTarget) + ? controller.currentAssistantExecutionTarget + : (visibleTargets.isNotEmpty + ? visibleTargets.first + : controller.currentAssistantExecutionTarget); controller.initializeAssistantThreadContext( sessionKey, title: appText('新对话', 'New conversation'), - executionTarget: controller.currentAssistantExecutionTarget, + executionTarget: target, messageViewMode: controller.currentAssistantMessageViewMode, singleAgentProvider: controller.currentSingleAgentProvider, ); @@ -103,7 +119,9 @@ class _AppShellState extends State { bottom: false, child: Column( children: [ - if ((controller.startupTaskThreadWarning ?? '').trim().isNotEmpty) + if ((controller.startupTaskThreadWarning ?? '') + .trim() + .isNotEmpty) Padding( padding: const EdgeInsets.fromLTRB(12, 12, 12, 0), child: Container( @@ -125,7 +143,8 @@ class _AppShellState extends State { ), const SizedBox(width: 12), TextButton( - onPressed: controller.dismissStartupTaskThreadWarning, + onPressed: + controller.dismissStartupTaskThreadWarning, child: Text(appText('关闭', 'Dismiss')), ), ], @@ -143,11 +162,18 @@ class _AppShellState extends State { constraints.maxWidth < 900; final isMobile = constraints.maxWidth < 900; final sidebarState = controller.sidebarState; - final showSidebar = sidebarState != AppSidebarState.hidden; + final showSidebar = + sidebarState != AppSidebarState.hidden; final uiFeatures = controller.featuresFor( resolveUiFeaturePlatformFromContext(context), ); - final sidebarTaskItems = _buildSidebarTaskItems(controller); + final visibleExecutionTargets = controller + .visibleAssistantExecutionTargets( + uiFeatures.availableExecutionTargets, + ); + final sidebarTaskItems = _buildSidebarTaskItems( + controller, + ); final expandedSidebarWidth = _clampSidebarWidth( _sidebarExpandedWidth ?? _defaultSidebarWidth( @@ -167,276 +193,319 @@ class _AppShellState extends State { .where(controller.capabilities.supportsDestination) .toList(growable: false); final resolvedMobileDestination = - availableMobileDestinations.contains(mobileDestination) + availableMobileDestinations.contains( + mobileDestination, + ) ? mobileDestination : (availableMobileDestinations.isEmpty ? mobileDestination : availableMobileDestinations.first); - void openMobileDetail(DetailPanelData detail) { - showModalBottomSheet( - context: context, - isScrollControlled: true, - backgroundColor: Colors.transparent, - builder: (sheetContext) { - return FractionallySizedBox( - heightFactor: 0.92, - child: DetailSheet( - data: detail, - onClose: () => Navigator.of(sheetContext).pop(), - ), - ); - }, - ); - } + void openMobileDetail(DetailPanelData detail) { + showModalBottomSheet( + context: context, + isScrollControlled: true, + backgroundColor: Colors.transparent, + builder: (sheetContext) { + return FractionallySizedBox( + heightFactor: 0.92, + child: DetailSheet( + data: detail, + onClose: () => Navigator.of(sheetContext).pop(), + ), + ); + }, + ); + } - void openAccountSheet() { - showModalBottomSheet( - context: context, - isScrollControlled: true, - backgroundColor: Colors.transparent, - builder: (sheetContext) { - return Container( - margin: EdgeInsets.fromLTRB( - 12, - MediaQuery.of(sheetContext).padding.top + 12, - 12, - 12, - ), - decoration: BoxDecoration( - color: palette.surfacePrimary, - borderRadius: BorderRadius.circular(28), - border: Border.all(color: palette.strokeSoft), - ), - child: SafeArea( - top: false, - child: AccountPage(controller: controller), - ), - ); - }, - ); - } + void openAccountSheet() { + showModalBottomSheet( + context: context, + isScrollControlled: true, + backgroundColor: Colors.transparent, + builder: (sheetContext) { + return Container( + margin: EdgeInsets.fromLTRB( + 12, + MediaQuery.of(sheetContext).padding.top + 12, + 12, + 12, + ), + decoration: BoxDecoration( + color: palette.surfacePrimary, + borderRadius: BorderRadius.circular(28), + border: Border.all(color: palette.strokeSoft), + ), + child: SafeArea( + top: false, + child: AccountPage(controller: controller), + ), + ); + }, + ); + } - if (isCompactMobile) { - return MobileShell(controller: controller); - } + if (isCompactMobile) { + return MobileShell(controller: controller); + } - if (isMobile) { - return Stack( - children: [ - Column( + if (isMobile) { + return Stack( + children: [ + Column( + children: [ + Expanded( + child: Padding( + padding: const EdgeInsets.fromLTRB( + 12, + 12, + 12, + 0, + ), + child: ClipRRect( + borderRadius: BorderRadius.circular(24), + child: Container( + color: palette.canvas.withValues( + alpha: 0.18, + ), + child: _pageForDestination( + resolvedMobileDestination, + openMobileDetail, + ), + ), + ), + ), + ), + Padding( + padding: const EdgeInsets.fromLTRB( + 12, + 10, + 12, + 12, + ), + child: ClipRRect( + borderRadius: BorderRadius.circular(24), + child: NavigationBar( + selectedIndex: + availableMobileDestinations.isEmpty + ? 0 + : availableMobileDestinations.indexOf( + resolvedMobileDestination, + ), + onDestinationSelected: (index) { + controller.navigateTo( + availableMobileDestinations[index], + ); + }, + destinations: availableMobileDestinations + .map( + (destination) => + NavigationDestination( + icon: Icon(destination.icon), + label: destination.label, + ), + ) + .toList(), + ), + ), + ), + ], + ), + Positioned( + right: 24, + bottom: 96, + child: + controller.capabilities.supportsDestination( + WorkspaceDestination.account, + ) + ? FloatingActionButton.small( + onPressed: openAccountSheet, + child: const Icon( + Icons.account_circle_rounded, + ), + ) + : const SizedBox.shrink(), + ), + ], + ); + } + + return Stack( children: [ - Expanded( - child: Padding( - padding: const EdgeInsets.fromLTRB(12, 12, 12, 0), - child: ClipRRect( - borderRadius: BorderRadius.circular(24), - child: Container( - color: palette.canvas.withValues(alpha: 0.18), - child: _pageForDestination( - resolvedMobileDestination, - openMobileDetail, + Row( + children: [ + if (showSidebar) + SidebarNavigation( + currentSection: controller.destination, + sidebarState: sidebarState, + appLanguage: controller.appLanguage, + themeMode: controller.themeMode, + onSectionChanged: (destination) { + if (destination == + WorkspaceDestination.settings) { + controller.openSettings( + tab: SettingsTab.gateway, + ); + return; + } + controller.navigateTo(destination); + }, + onToggleLanguage: + controller.toggleAppLanguage, + onCycleSidebarState: () => + _toggleSidebarVisibility(controller), + onExpandFromCollapsed: () => + _toggleSidebarVisibility(controller), + onOpenHome: controller.navigateHome, + onOpenAccount: () => controller.navigateTo( + WorkspaceDestination.account, + ), + onOpenThemeToggle: () => + controller.setThemeMode( + controller.themeMode == ThemeMode.dark + ? ThemeMode.light + : ThemeMode.dark, + ), + accountName: + controller.settings.accountUsername + .trim() + .isEmpty + ? appText('本地操作员', 'Local Operator') + : controller.settings.accountUsername, + accountSubtitle: + controller.settings.accountWorkspace + .trim() + .isEmpty + ? appText('账号', 'Account') + : controller.settings.accountWorkspace, + accountWorkspaceFollowed: controller + .settings + .accountWorkspaceFollowed, + onToggleAccountWorkspaceFollowed: + controller.toggleAccountWorkspaceFollowed, + onOpenOnlineWorkspace: + controller.openOnlineWorkspace, + expandedWidthOverride: + sidebarState == AppSidebarState.expanded + ? expandedSidebarWidth + : null, + marginOverride: const EdgeInsets.fromLTRB( + 4, + 4, + 4, + 0, + ), + 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, + visibleExecutionTargets: + visibleExecutionTargets, + assistantSkillCount: + controller.currentAssistantSkillCount, + onRefreshTasks: controller.refreshSessions, + onCreateTask: () => + _createSidebarConversation( + controller, + visibleExecutionTargets, + ), + 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( + axis: Axis.horizontal, + extent: 8, + onDelta: (delta) { + setState(() { + _sidebarExpandedWidth = + _clampSidebarWidth( + expandedSidebarWidth + delta, + constraints.maxWidth, + ); + }); + }, + ), + Expanded( + child: Padding( + padding: const EdgeInsets.fromLTRB( + 0, + 4, + 4, + 0, + ), + child: AnimatedPadding( + duration: const Duration(milliseconds: 220), + curve: Curves.easeOutCubic, + padding: EdgeInsets.only( + right: showPinnedDetail ? 336 : 0, + ), + child: DecoratedBox( + decoration: BoxDecoration( + color: palette.canvas, + ), + child: _buildCurrentPage( + controller.openDetail, + ), + ), ), ), ), - ), + ], ), - Padding( - padding: const EdgeInsets.fromLTRB(12, 10, 12, 12), - child: ClipRRect( - borderRadius: BorderRadius.circular(24), - child: NavigationBar( - selectedIndex: - availableMobileDestinations.isEmpty - ? 0 - : availableMobileDestinations.indexOf( - resolvedMobileDestination, - ), - onDestinationSelected: (index) { - controller.navigateTo( - availableMobileDestinations[index], - ); - }, - destinations: availableMobileDestinations - .map( - (destination) => NavigationDestination( - icon: Icon(destination.icon), - label: destination.label, - ), - ) - .toList(), + if (controller.detailPanel != null && + !showPinnedDetail) + Positioned.fill( + child: GestureDetector( + onTap: controller.closeDetail, + child: Container( + color: Colors.black.withValues(alpha: 0.12), + ), + ), + ), + if (controller.detailPanel != null) + Align( + alignment: Alignment.centerRight, + child: DetailDrawer( + data: controller.detailPanel!, + onClose: controller.closeDetail, + ), + ), + if (!showSidebar) + Positioned( + left: 8, + top: 8, + child: _SidebarRevealRail( + onExpand: () => + _toggleSidebarVisibility(controller), ), ), - ), ], - ), - Positioned( - right: 24, - bottom: 96, - child: - controller.capabilities.supportsDestination( - WorkspaceDestination.account, - ) - ? FloatingActionButton.small( - onPressed: openAccountSheet, - child: const Icon(Icons.account_circle_rounded), - ) - : const SizedBox.shrink(), - ), - ], - ); - } - - return Stack( - children: [ - Row( - children: [ - if (showSidebar) - SidebarNavigation( - currentSection: controller.destination, - sidebarState: sidebarState, - appLanguage: controller.appLanguage, - themeMode: controller.themeMode, - onSectionChanged: (destination) { - if (destination == - WorkspaceDestination.settings) { - controller.openSettings( - tab: SettingsTab.gateway, - ); - return; - } - controller.navigateTo(destination); - }, - onToggleLanguage: controller.toggleAppLanguage, - onCycleSidebarState: () => - _toggleSidebarVisibility(controller), - onExpandFromCollapsed: () => - _toggleSidebarVisibility(controller), - onOpenHome: controller.navigateHome, - onOpenAccount: () => controller.navigateTo( - WorkspaceDestination.account, - ), - onOpenThemeToggle: () => controller.setThemeMode( - controller.themeMode == ThemeMode.dark - ? ThemeMode.light - : ThemeMode.dark, - ), - accountName: - controller.settings.accountUsername - .trim() - .isEmpty - ? appText('本地操作员', 'Local Operator') - : controller.settings.accountUsername, - accountSubtitle: - controller.settings.accountWorkspace - .trim() - .isEmpty - ? appText('账号', 'Account') - : controller.settings.accountWorkspace, - accountWorkspaceFollowed: - controller.settings.accountWorkspaceFollowed, - onToggleAccountWorkspaceFollowed: - controller.toggleAccountWorkspaceFollowed, - onOpenOnlineWorkspace: - controller.openOnlineWorkspace, - expandedWidthOverride: - sidebarState == AppSidebarState.expanded - ? expandedSidebarWidth - : null, - marginOverride: const EdgeInsets.fromLTRB(4, 4, 4, 0), - 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: () => - _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( - axis: Axis.horizontal, - extent: 8, - onDelta: (delta) { - setState(() { - _sidebarExpandedWidth = _clampSidebarWidth( - expandedSidebarWidth + delta, - constraints.maxWidth, - ); - }); - }, - ), - Expanded( - child: Padding( - padding: const EdgeInsets.fromLTRB(0, 4, 4, 0), - child: AnimatedPadding( - duration: const Duration(milliseconds: 220), - curve: Curves.easeOutCubic, - padding: EdgeInsets.only( - right: showPinnedDetail ? 336 : 0, - ), - child: DecoratedBox( - decoration: BoxDecoration( - color: palette.canvas, - ), - child: _buildCurrentPage(controller.openDetail), - ), - ), - ), - ), - ], - ), - if (controller.detailPanel != null && !showPinnedDetail) - Positioned.fill( - child: GestureDetector( - onTap: controller.closeDetail, - child: Container( - color: Colors.black.withValues(alpha: 0.12), - ), - ), - ), - if (controller.detailPanel != null) - Align( - alignment: Alignment.centerRight, - child: DetailDrawer( - data: controller.detailPanel!, - onClose: controller.closeDetail, - ), - ), - if (!showSidebar) - Positioned( - left: 8, - top: 8, - child: _SidebarRevealRail( - onExpand: () => _toggleSidebarVisibility(controller), - ), - ), - ], - ); + ); }, ), ), diff --git a/lib/features/assistant/assistant_page_components.dart b/lib/features/assistant/assistant_page_components.dart index ccb35b4a..39274f89 100644 --- a/lib/features/assistant/assistant_page_components.dart +++ b/lib/features/assistant/assistant_page_components.dart @@ -79,7 +79,14 @@ class AssistantTaskRailStateInternal extends State { final theme = Theme.of(context); final palette = context.palette; final tasks = widget.tasks; - final groupedTasks = groupTasksForRailInternal(tasks); + final groupedTasks = groupTasksForRailInternal( + tasks, + widget.controller.visibleAssistantExecutionTargets(const [ + AssistantExecutionTarget.singleAgent, + AssistantExecutionTarget.local, + AssistantExecutionTarget.remote, + ]), + ); final runningCount = tasks .where((task) => normalizedTaskStatusInternal(task.status) == 'running') .length; @@ -276,15 +283,20 @@ class AssistantTaskRailStateInternal extends State { List groupTasksForRailInternal( List tasks, + List visibleExecutionTargets, ) { final grouped = >{ - for (final target in AssistantExecutionTarget.values) + for (final target in visibleExecutionTargets) target: [], }; for (final task in tasks) { - grouped[task.executionTarget]!.add(task); + final bucket = grouped[task.executionTarget]; + if (bucket == null) { + continue; + } + bucket.add(task); } - return AssistantExecutionTarget.values + return visibleExecutionTargets .map( (target) => AssistantTaskGroupInternal( executionTarget: target, diff --git a/lib/features/assistant/assistant_page_composer_bar.dart b/lib/features/assistant/assistant_page_composer_bar.dart index 5509fe96..6b5e2b2d 100644 --- a/lib/features/assistant/assistant_page_composer_bar.dart +++ b/lib/features/assistant/assistant_page_composer_bar.dart @@ -355,7 +355,16 @@ class ComposerBarStateInternal extends State { final connected = connectionState.connected; final reconnectAvailable = controller.canQuickConnectGateway; final connecting = connectionState.connecting; - final executionTarget = controller.assistantExecutionTarget; + final visibleExecutionTargets = controller.visibleAssistantExecutionTargets( + uiFeatures.availableExecutionTargets, + ); + final executionTarget = visibleExecutionTargets.contains( + controller.assistantExecutionTarget, + ) + ? controller.assistantExecutionTarget + : (visibleExecutionTargets.isNotEmpty + ? visibleExecutionTargets.first + : controller.assistantExecutionTarget); final permissionLevel = controller.assistantPermissionLevel; final selectedSkills = widget.availableSkills .where((skill) => widget.selectedSkillKeys.contains(skill.key)) @@ -409,39 +418,41 @@ class ComposerBarStateInternal extends State { ), const SizedBox(width: 6), ], - PopupMenuButton( - key: const Key('assistant-execution-target-button'), - tooltip: appText('任务对话模式', 'Task Dialog Mode'), - onSelected: (value) { - controller.setAssistantExecutionTarget(value); - }, - itemBuilder: (context) => uiFeatures.availableExecutionTargets - .map( - (value) => PopupMenuItem( - value: value, - child: Row( - children: [ - Icon(value.icon, size: 18), - const SizedBox(width: 10), - Expanded(child: Text(value.label)), - if (value == executionTarget) - const Icon(Icons.check_rounded, size: 18), - ], + if (visibleExecutionTargets.isNotEmpty) ...[ + PopupMenuButton( + key: const Key('assistant-execution-target-button'), + tooltip: appText('任务对话模式', 'Task Dialog Mode'), + onSelected: (value) { + controller.setAssistantExecutionTarget(value); + }, + itemBuilder: (context) => visibleExecutionTargets + .map( + (value) => PopupMenuItem( + value: value, + child: Row( + children: [ + Icon(value.icon, size: 18), + const SizedBox(width: 10), + Expanded(child: Text(value.label)), + if (value == executionTarget) + const Icon(Icons.check_rounded, size: 18), + ], + ), ), - ), - ) - .toList(), - child: ComposerToolbarChipInternal( - icon: executionTarget.icon, - tooltip: executionTargetTooltipInternal(executionTarget), - showChevron: true, - padding: const EdgeInsets.symmetric( - horizontal: 10, - vertical: 6, + ) + .toList(), + child: ComposerToolbarChipInternal( + icon: executionTarget.icon, + tooltip: executionTargetTooltipInternal(executionTarget), + showChevron: true, + padding: const EdgeInsets.symmetric( + horizontal: 10, + vertical: 6, + ), ), ), - ), - const SizedBox(width: 4), + const SizedBox(width: 4), + ], if (singleAgent && executionTarget != AssistantExecutionTarget.auto) ...[ PopupMenuButton( diff --git a/lib/features/assistant/assistant_page_state_actions.dart b/lib/features/assistant/assistant_page_state_actions.dart index de28a9bc..89ff4003 100644 --- a/lib/features/assistant/assistant_page_state_actions.dart +++ b/lib/features/assistant/assistant_page_state_actions.dart @@ -77,7 +77,10 @@ extension AssistantPageStateActionsInternal on AssistantPageStateInternal { resolveUiFeaturePlatformFromContext(context), ); final settings = controller.settings; - final executionTarget = controller.assistantExecutionTarget; + final executionTarget = resolvedVisibleExecutionTargetInternal( + controller, + supportedTargets: uiFeatures.availableExecutionTargets, + ); final rawPrompt = inputControllerInternal.text.trim(); if (rawPrompt.isEmpty) { return; @@ -434,7 +437,14 @@ extension AssistantPageStateActionsInternal on AssistantPageStateInternal { Future createNewThreadInternal() async { final sessionKey = buildDraftSessionKeyInternal(widget.controller); - final inheritedTarget = widget.controller.currentAssistantExecutionTarget; + final inheritedTarget = resolvedVisibleExecutionTargetInternal( + widget.controller, + supportedTargets: const [ + AssistantExecutionTarget.singleAgent, + AssistantExecutionTarget.local, + AssistantExecutionTarget.remote, + ], + ); final inheritedViewMode = widget.controller.currentAssistantMessageViewMode; setState(() { archivedTaskKeysInternal.removeWhere( @@ -531,7 +541,14 @@ extension AssistantPageStateActionsInternal on AssistantPageStateInternal { updatedAtMs: DateTime.now().millisecondsSinceEpoch.toDouble(), owner: conversationOwnerLabelInternal(widget.controller), surface: 'Assistant', - executionTarget: widget.controller.currentAssistantExecutionTarget, + executionTarget: resolvedVisibleExecutionTargetInternal( + widget.controller, + supportedTargets: const [ + AssistantExecutionTarget.singleAgent, + AssistantExecutionTarget.local, + AssistantExecutionTarget.remote, + ], + ), isCurrent: true, draft: true, ); @@ -650,6 +667,22 @@ extension AssistantPageStateActionsInternal on AssistantPageStateInternal { return fallbackSessionTitleInternal(sessionKey); } + AssistantExecutionTarget resolvedVisibleExecutionTargetInternal( + AppController controller, { + required Iterable supportedTargets, + }) { + final visibleTargets = controller.visibleAssistantExecutionTargets( + supportedTargets, + ); + if (visibleTargets.contains(controller.currentAssistantExecutionTarget)) { + return controller.currentAssistantExecutionTarget; + } + if (visibleTargets.isNotEmpty) { + return visibleTargets.first; + } + return controller.currentAssistantExecutionTarget; + } + void touchTaskSeedInternal({ required String sessionKey, required String title, diff --git a/lib/runtime/runtime_models_runtime_payloads.dart b/lib/runtime/runtime_models_runtime_payloads.dart index 0ad02682..0d5df29a 100644 --- a/lib/runtime/runtime_models_runtime_payloads.dart +++ b/lib/runtime/runtime_models_runtime_payloads.dart @@ -1212,3 +1212,46 @@ class TaskThread { ); } } + +const int kDefaultTaskTitleMaxLength = 32; + +bool isNewConversationTaskTitle(String title) { + final trimmed = title.trim(); + return trimmed == '新对话' || trimmed == 'New conversation'; +} + +String firstUserMessageTaskTitle( + Iterable messages, { + String fallback = '', +}) { + for (final message in messages) { + if (message.role.trim().toLowerCase() != 'user') { + continue; + } + final text = message.text.trim(); + if (text.isEmpty) { + continue; + } + if (text.length <= kDefaultTaskTitleMaxLength) { + return text; + } + return '${text.substring(0, kDefaultTaskTitleMaxLength)}...'; + } + return fallback.trim().isEmpty ? appText('新对话', 'New conversation') : fallback; +} + +String derivePersistedTaskTitle( + String currentTitle, + Iterable messages, { + String fallback = '', + bool hasCustomTitle = false, +}) { + if (hasCustomTitle) { + return currentTitle.trim(); + } + final trimmedCurrent = currentTitle.trim(); + if (trimmedCurrent.isNotEmpty && !isNewConversationTaskTitle(trimmedCurrent)) { + return trimmedCurrent; + } + return firstUserMessageTaskTitle(messages, fallback: fallback); +} diff --git a/lib/runtime/runtime_models_settings_snapshot.dart b/lib/runtime/runtime_models_settings_snapshot.dart index 28ea86ff..b2d63632 100644 --- a/lib/runtime/runtime_models_settings_snapshot.dart +++ b/lib/runtime/runtime_models_settings_snapshot.dart @@ -46,6 +46,7 @@ class SettingsSnapshot { required this.assistantNavigationDestinations, required this.assistantCustomTaskTitles, required this.assistantArchivedTaskKeys, + required this.savedGatewayTargets, required this.assistantLastSessionKey, }); @@ -83,6 +84,7 @@ class SettingsSnapshot { final List assistantNavigationDestinations; final Map assistantCustomTaskTitles; final List assistantArchivedTaskKeys; + final List savedGatewayTargets; final String assistantLastSessionKey; factory SettingsSnapshot.defaults() { @@ -121,6 +123,7 @@ class SettingsSnapshot { assistantNavigationDestinations: kAssistantNavigationDestinationDefaults, assistantCustomTaskTitles: const {}, assistantArchivedTaskKeys: const [], + savedGatewayTargets: const [], assistantLastSessionKey: '', ); } @@ -160,6 +163,7 @@ class SettingsSnapshot { List? assistantNavigationDestinations, Map? assistantCustomTaskTitles, List? assistantArchivedTaskKeys, + List? savedGatewayTargets, String? assistantLastSessionKey, }) { final resolvedGatewayProfiles = gatewayProfiles != null @@ -217,6 +221,9 @@ class SettingsSnapshot { assistantCustomTaskTitles ?? this.assistantCustomTaskTitles, assistantArchivedTaskKeys: assistantArchivedTaskKeys ?? this.assistantArchivedTaskKeys, + savedGatewayTargets: normalizeSavedGatewayTargets( + savedGatewayTargets ?? this.savedGatewayTargets, + ), assistantLastSessionKey: assistantLastSessionKey ?? this.assistantLastSessionKey, ); @@ -266,6 +273,7 @@ class SettingsSnapshot { .toList(growable: false), 'assistantCustomTaskTitles': assistantCustomTaskTitles, 'assistantArchivedTaskKeys': assistantArchivedTaskKeys, + 'savedGatewayTargets': savedGatewayTargets, 'assistantLastSessionKey': assistantLastSessionKey, }; } @@ -303,6 +311,15 @@ class SettingsSnapshot { return normalized; } + List normalizeSavedGatewayTargetsFromJson(Object? value) { + if (value is! List) { + return const []; + } + return normalizeSavedGatewayTargets( + value.map((item) => item?.toString() ?? ''), + ); + } + final rawAssistantNavigationDestinations = json['assistantNavigationDestinations']; final assistantNavigationDestinations = @@ -423,6 +440,9 @@ class SettingsSnapshot { assistantArchivedTaskKeys: normalizeTaskKeys( json['assistantArchivedTaskKeys'], ), + savedGatewayTargets: normalizeSavedGatewayTargetsFromJson( + json['savedGatewayTargets'], + ), assistantLastSessionKey: json['assistantLastSessionKey'] as String? ?? '', ); } @@ -551,6 +571,74 @@ class SettingsSnapshot { externalAcpEndpoints.map((item) => item.toProvider()), ); + List get savedSingleAgentProviders => + normalizeSingleAgentProviderList( + externalAcpEndpoints + .where( + (item) => + item.enabled && + item.endpoint.trim().isNotEmpty, + ) + .map((item) => item.toProvider()), + ); + + bool isGatewayTargetSaved(AssistantExecutionTarget target) { + final targetKey = switch (target) { + AssistantExecutionTarget.local => 'local', + AssistantExecutionTarget.remote => 'remote', + _ => '', + }; + return targetKey.isNotEmpty && savedGatewayTargets.contains(targetKey); + } + + SettingsSnapshot markGatewayTargetSaved(AssistantExecutionTarget target) { + final targetKey = switch (target) { + AssistantExecutionTarget.local => 'local', + AssistantExecutionTarget.remote => 'remote', + _ => '', + }; + if (targetKey.isEmpty || savedGatewayTargets.contains(targetKey)) { + return this; + } + return copyWith( + savedGatewayTargets: [...savedGatewayTargets, targetKey], + ); + } + + List visibleSingleAgentProviders( + Iterable availableProviders, + ) { + final allowedProviderIds = savedSingleAgentProviders + .map((item) => item.providerId) + .toSet(); + return normalizeSingleAgentProviderList( + availableProviders.where( + (item) => allowedProviderIds.contains(item.providerId), + ), + ); + } + + List visibleAssistantExecutionTargets({ + required Iterable supportedTargets, + required Iterable availableSingleAgentProviders, + }) { + final supported = supportedTargets.toSet(); + final visible = []; + if (supported.contains(AssistantExecutionTarget.singleAgent) && + visibleSingleAgentProviders(availableSingleAgentProviders).isNotEmpty) { + visible.add(AssistantExecutionTarget.singleAgent); + } + if (supported.contains(AssistantExecutionTarget.local) && + isGatewayTargetSaved(AssistantExecutionTarget.local)) { + visible.add(AssistantExecutionTarget.local); + } + if (supported.contains(AssistantExecutionTarget.remote) && + isGatewayTargetSaved(AssistantExecutionTarget.remote)) { + visible.add(AssistantExecutionTarget.remote); + } + return List.unmodifiable(visible); + } + SettingsSnapshot copyWithExternalAcpEndpointForProvider( SingleAgentProvider provider, ExternalAcpEndpointProfile profile, @@ -564,3 +652,17 @@ class SettingsSnapshot { ); } } + +List normalizeSavedGatewayTargets(Iterable rawTargets) { + final normalized = []; + final seen = {}; + for (final item in rawTargets) { + final normalizedTarget = item.trim().toLowerCase(); + if ((normalizedTarget != 'local' && normalizedTarget != 'remote') || + !seen.add(normalizedTarget)) { + continue; + } + normalized.add(normalizedTarget); + } + return List.unmodifiable(normalized); +} diff --git a/lib/widgets/sidebar_navigation.dart b/lib/widgets/sidebar_navigation.dart index 37fd8302..b0f3814b 100644 --- a/lib/widgets/sidebar_navigation.dart +++ b/lib/widgets/sidebar_navigation.dart @@ -38,6 +38,11 @@ class SidebarNavigation extends StatelessWidget { this.availableSettingsTabs = const [], this.onSettingsTabChanged, this.taskItems = const [], + this.visibleExecutionTargets = const [ + AssistantExecutionTarget.singleAgent, + AssistantExecutionTarget.local, + AssistantExecutionTarget.remote, + ], this.assistantSkillCount = 0, this.onRefreshTasks, this.onCreateTask, @@ -72,6 +77,7 @@ class SidebarNavigation extends StatelessWidget { final List availableSettingsTabs; final ValueChanged? onSettingsTabChanged; final List taskItems; + final List visibleExecutionTargets; final int assistantSkillCount; final Future Function()? onRefreshTasks; final Future Function()? onCreateTask; @@ -121,6 +127,7 @@ class SidebarNavigation extends StatelessWidget { Expanded( child: SidebarTaskSection( items: taskItems, + visibleExecutionTargets: visibleExecutionTargets, skillCount: assistantSkillCount, showCollapseControl: showCollapseControl, onCycleSidebarState: onCycleSidebarState, diff --git a/lib/widgets/sidebar_navigation_task_section.dart b/lib/widgets/sidebar_navigation_task_section.dart index 3811667d..102ddb7b 100644 --- a/lib/widgets/sidebar_navigation_task_section.dart +++ b/lib/widgets/sidebar_navigation_task_section.dart @@ -26,6 +26,7 @@ class SidebarTaskSection extends StatefulWidget { const SidebarTaskSection({ super.key, required this.items, + required this.visibleExecutionTargets, required this.skillCount, required this.showCollapseControl, required this.onCycleSidebarState, @@ -37,6 +38,7 @@ class SidebarTaskSection extends StatefulWidget { }); final List items; + final List visibleExecutionTargets; final int skillCount; final bool showCollapseControl; final VoidCallback onCycleSidebarState; @@ -267,13 +269,17 @@ class _SidebarTaskSectionState extends State { List<_SidebarTaskGroup> _groupedItems(List items) { final grouped = >{ - for (final target in AssistantExecutionTarget.values) + for (final target in widget.visibleExecutionTargets) target: [], }; for (final item in items) { - grouped[item.executionTarget]!.add(item); + final bucket = grouped[item.executionTarget]; + if (bucket == null) { + continue; + } + bucket.add(item); } - return AssistantExecutionTarget.values + return widget.visibleExecutionTargets .map( (target) => _SidebarTaskGroup( executionTarget: target, diff --git a/test/features/assistant_page_suite_composer.dart b/test/features/assistant_page_suite_composer.dart index 21169d1d..2d1d7a92 100644 --- a/test/features/assistant_page_suite_composer.dart +++ b/test/features/assistant_page_suite_composer.dart @@ -139,7 +139,7 @@ void registerAssistantPageSuiteComposerTestsInternal() { ); expect( find.byKey(const Key('assistant-execution-target-button')), - findsOneWidget, + findsNothing, ); expect( find.byKey(const Key('assistant-skill-picker-button')), @@ -149,7 +149,6 @@ void registerAssistantPageSuiteComposerTestsInternal() { find.byKey(const Key('assistant-permission-button')), findsOneWidget, ); - expect(find.byKey(const Key('assistant-model-button')), findsNothing); expect(find.byKey(const Key('assistant-thinking-button')), findsOneWidget); expect(find.byTooltip('模式'), findsNothing); @@ -163,21 +162,55 @@ void registerAssistantPageSuiteComposerTestsInternal() { await tester.tapAt(const Offset(24, 24)); await pumpForUiSyncInternal(tester); - - await tester.tap( - find.byKey(const Key('assistant-execution-target-button')), - ); - await pumpForUiSyncInternal(tester); - - expect( - executionTargetMenuItemInternal(AssistantExecutionTarget.auto), - findsNothing, - ); - expect(find.text('单机智能体'), findsWidgets); - expect(find.text('本地 OpenClaw Gateway'), findsWidgets); - expect(find.text('远程 OpenClaw Gateway'), findsWidgets); }); + testWidgets( + 'AssistantPage execution target menu shows only saved visible targets', + (WidgetTester tester) async { + late final AppController controller; + await tester.runAsync(() async { + SharedPreferences.setMockInitialValues({}); + final store = createIsolatedTestStore(enableSecureStorage: false); + final defaults = SettingsSnapshot.defaults(); + await store.saveSettingsSnapshot( + defaults.copyWith(savedGatewayTargets: const ['remote']), + ); + controller = AppController( + store: store, + runtimeCoordinator: RuntimeCoordinator( + gateway: FakeGatewayRuntimeInternal(store: store), + codex: FakeCodexRuntimeInternal(), + ), + ); + final stopwatch = Stopwatch()..start(); + while (controller.initializing) { + if (stopwatch.elapsed > const Duration(seconds: 10)) { + fail('controller did not finish initializing before timeout'); + } + await Future.delayed(const Duration(milliseconds: 20)); + } + }); + addTearDown(controller.dispose); + + await pumpPage( + tester, + child: AssistantPage(controller: controller, onOpenDetail: (_) {}), + ); + + await tester.tap( + find.byKey(const Key('assistant-execution-target-button')), + ); + await pumpForUiSyncInternal(tester); + + expect(find.text('远程 OpenClaw Gateway'), findsWidgets); + expect(find.text('本地 OpenClaw Gateway'), findsNothing); + expect( + executionTargetMenuItemInternal(AssistantExecutionTarget.auto), + findsNothing, + ); + }, + ); + testWidgets( 'AssistantPage clears submitted composer text before send completes', (WidgetTester tester) async { @@ -628,37 +661,7 @@ void registerAssistantPageSuiteComposerTestsInternal() { }); testWidgets( - 'AssistantPage hides Auto execution target when desktop flag is disabled', - (WidgetTester tester) async { - final controller = await createTestController(tester); - - await pumpPage( - tester, - child: AssistantPage(controller: controller, onOpenDetail: (_) {}), - platform: TargetPlatform.macOS, - ); - - await tester.tap( - find.byKey(const Key('assistant-execution-target-button')), - ); - await pumpForUiSyncInternal(tester); - - expect( - controller.assistantExecutionTarget, - isNot(AssistantExecutionTarget.auto), - ); - expect( - executionTargetMenuItemInternal(AssistantExecutionTarget.auto), - findsNothing, - ); - expect(find.text('单机智能体'), findsWidgets); - expect(find.text('本地 OpenClaw Gateway'), findsWidgets); - expect(find.text('远程 OpenClaw Gateway'), findsWidgets); - }, - ); - - testWidgets( - 'AssistantPage shows Auto execution target when desktop flag is enabled', + 'AssistantPage hides Auto execution target even when the desktop feature flag is enabled', (WidgetTester tester) async { final manifest = UiFeatureManifest.fallback().copyWithFeature( platform: UiFeaturePlatform.desktop, @@ -667,10 +670,31 @@ void registerAssistantPageSuiteComposerTestsInternal() { enabled: true, releaseTier: UiFeatureReleaseTier.stable, ); - final controller = await createTestController( - tester, - uiFeatureManifest: manifest, - ); + late final AppController controller; + await tester.runAsync(() async { + SharedPreferences.setMockInitialValues({}); + final store = createIsolatedTestStore(enableSecureStorage: false); + final defaults = SettingsSnapshot.defaults(); + await store.saveSettingsSnapshot( + defaults.copyWith(savedGatewayTargets: const ['remote']), + ); + controller = AppController( + store: store, + runtimeCoordinator: RuntimeCoordinator( + gateway: FakeGatewayRuntimeInternal(store: store), + codex: FakeCodexRuntimeInternal(), + ), + uiFeatureManifest: manifest, + ); + final stopwatch = Stopwatch()..start(); + while (controller.initializing) { + if (stopwatch.elapsed > const Duration(seconds: 10)) { + fail('controller did not finish initializing before timeout'); + } + await Future.delayed(const Duration(milliseconds: 20)); + } + }); + addTearDown(controller.dispose); await pumpPage( tester, @@ -685,8 +709,9 @@ void registerAssistantPageSuiteComposerTestsInternal() { expect( executionTargetMenuItemInternal(AssistantExecutionTarget.auto), - findsOneWidget, + findsNothing, ); + expect(find.text('远程 OpenClaw Gateway'), findsWidgets); }, ); diff --git a/test/features/assistant_page_suite_core.dart b/test/features/assistant_page_suite_core.dart index 821c2eb3..07c3b464 100644 --- a/test/features/assistant_page_suite_core.dart +++ b/test/features/assistant_page_suite_core.dart @@ -341,7 +341,7 @@ void registerAssistantPageSuiteCoreTestsInternal() { skip: true, ); - testWidgets('AssistantPage shows four collapsed task groups by default', ( + testWidgets('AssistantPage hides task groups when no target is saved', ( WidgetTester tester, ) async { final controller = await createTestController(tester); @@ -353,34 +353,24 @@ void registerAssistantPageSuiteCoreTestsInternal() { expect( find.byKey(const ValueKey('assistant-task-group-auto')), - findsOneWidget, + findsNothing, ); expect( find.byKey(const ValueKey('assistant-task-group-singleAgent')), - findsOneWidget, + findsNothing, ); expect( find.byKey(const ValueKey('assistant-task-group-local')), - findsOneWidget, + findsNothing, ); expect( find.byKey(const ValueKey('assistant-task-group-remote')), - findsOneWidget, + findsNothing, ); expect( find.byKey(const ValueKey('assistant-task-item-main')), findsNothing, ); - - await tester.tap( - find.byKey(const ValueKey('assistant-task-group-auto')), - ); - await pumpForUiSyncInternal(tester); - - expect( - find.byKey(const ValueKey('assistant-task-item-main')), - findsOneWidget, - ); }); testWidgets('AssistantPage ignores legacy navigation panel injection', ( @@ -465,7 +455,9 @@ void registerAssistantPageSuiteCoreTestsInternal() { WidgetTester tester, ) async { final controller = await createTestController(tester); - await controller.setAssistantExecutionTarget(AssistantExecutionTarget.local); + await controller.setAssistantExecutionTarget( + AssistantExecutionTarget.local, + ); await pumpPage( tester, diff --git a/test/runtime/external_acp_endpoint_settings_suite.dart b/test/runtime/external_acp_endpoint_settings_suite.dart index f173e5ea..b18de18b 100644 --- a/test/runtime/external_acp_endpoint_settings_suite.dart +++ b/test/runtime/external_acp_endpoint_settings_suite.dart @@ -221,5 +221,66 @@ void main() { ); }, ); + + test('saved single-agent providers require a non-empty saved endpoint', () { + final defaults = SettingsSnapshot.defaults(); + final snapshot = defaults.copyWith( + externalAcpEndpoints: normalizeExternalAcpEndpoints( + profiles: [ + ...defaults.externalAcpEndpoints, + ExternalAcpEndpointProfile.defaultsForProvider( + SingleAgentProvider.codex, + ).copyWith(endpoint: 'wss://codex.example.com/acp'), + const ExternalAcpEndpointProfile( + providerKey: 'custom-agent-2', + label: 'Empty Agent', + badge: 'EA', + endpoint: '', + authRef: '', + enabled: true, + ), + ], + ), + ); + + expect( + snapshot.savedSingleAgentProviders + .map((item) => item.label) + .toList(growable: false), + const ['Codex'], + ); + }); + + test('visible execution targets only include explicitly saved targets', () { + final defaults = SettingsSnapshot.defaults(); + final snapshot = defaults + .copyWith( + externalAcpEndpoints: normalizeExternalAcpEndpoints( + profiles: [ + ...defaults.externalAcpEndpoints, + ExternalAcpEndpointProfile.defaultsForProvider( + SingleAgentProvider.codex, + ).copyWith(endpoint: 'wss://codex.example.com/acp'), + ], + ), + ) + .markGatewayTargetSaved(AssistantExecutionTarget.remote); + + expect( + snapshot.visibleAssistantExecutionTargets( + supportedTargets: const [ + AssistantExecutionTarget.auto, + AssistantExecutionTarget.singleAgent, + AssistantExecutionTarget.local, + AssistantExecutionTarget.remote, + ], + availableSingleAgentProviders: snapshot.availableSingleAgentProviders, + ), + const [ + AssistantExecutionTarget.singleAgent, + AssistantExecutionTarget.remote, + ], + ); + }); }); } diff --git a/test/runtime/task_title_visibility_suite.dart b/test/runtime/task_title_visibility_suite.dart new file mode 100644 index 00000000..b4a95174 --- /dev/null +++ b/test/runtime/task_title_visibility_suite.dart @@ -0,0 +1,71 @@ +@TestOn('vm') +library; + +import 'package:flutter_test/flutter_test.dart'; +import 'package:xworkmate/runtime/runtime_models.dart'; + +GatewayChatMessage _userMessage(String text) => GatewayChatMessage( + id: 'user-${text.hashCode}', + role: 'user', + text: text, + timestampMs: DateTime(2026, 4, 6).millisecondsSinceEpoch.toDouble(), + toolCallId: null, + toolName: null, + stopReason: null, + pending: false, + error: false, +); + +GatewayChatMessage _assistantMessage(String text) => GatewayChatMessage( + id: 'assistant-${text.hashCode}', + role: 'assistant', + text: text, + timestampMs: DateTime(2026, 4, 6).millisecondsSinceEpoch.toDouble(), + toolCallId: null, + toolName: null, + stopReason: null, + pending: false, + error: false, +); + +void main() { + group('Task title persistence', () { + test('derives the default task title from the first user message', () { + final title = derivePersistedTaskTitle('新对话', [ + _userMessage('请帮我排查桌面端任务边栏为什么一直显示新任务'), + ]); + + expect(title, '请帮我排查桌面端任务边栏为什么一直显示新任务'); + }); + + test('keeps the persisted auto title after later messages arrive', () { + final title = derivePersistedTaskTitle('首条任务说明', [ + _userMessage('首条任务说明'), + _assistantMessage('收到,我来看看'), + _userMessage('补充更多上下文,但不应该改标题'), + ]); + + expect(title, '首条任务说明'); + }); + + test('does not overwrite a custom title with an auto-derived title', () { + final title = derivePersistedTaskTitle('我自己改过的标题', [ + _userMessage('默认标题候选'), + ], hasCustomTitle: true); + + expect(title, '我自己改过的标题'); + }); + + test( + 'falls back to the persisted auto title after custom title is cleared', + () { + final title = derivePersistedTaskTitle( + '已持久化的自动标题', + [_userMessage('新的消息不应该重新改标题')], + ); + + expect(title, '已持久化的自动标题'); + }, + ); + }); +} diff --git a/test/test_support.dart b/test/test_support.dart index 694f58f6..f321f8bb 100644 --- a/test/test_support.dart +++ b/test/test_support.dart @@ -7,6 +7,11 @@ import 'package:shared_preferences/shared_preferences.dart'; import 'package:xworkmate/app/app_controller.dart'; import 'package:xworkmate/app/ui_feature_manifest.dart'; import 'package:xworkmate/runtime/account_runtime_client.dart'; +import 'package:xworkmate/runtime/codex_runtime.dart'; +import 'package:xworkmate/runtime/device_identity_store.dart'; +import 'package:xworkmate/runtime/gateway_runtime.dart'; +import 'package:xworkmate/runtime/runtime_coordinator.dart'; +import 'package:xworkmate/runtime/runtime_models.dart'; import 'package:xworkmate/runtime/secure_config_store.dart'; import 'package:xworkmate/theme/app_theme.dart'; import 'package:xworkmate/runtime/desktop_platform_service.dart'; @@ -53,11 +58,16 @@ Future createTestController( SharedPreferences.setMockInitialValues({}); final testRoot = '${Directory.systemTemp.path}/xworkmate-widget-tests-${DateTime.now().microsecondsSinceEpoch}'; + final store = SecureConfigStore( + enableSecureStorage: false, + databasePathResolver: () async => '$testRoot/settings.sqlite3', + fallbackDirectoryPathResolver: () async => testRoot, + ); final controller = AppController( - store: SecureConfigStore( - enableSecureStorage: false, - databasePathResolver: () async => '$testRoot/settings.sqlite3', - fallbackDirectoryPathResolver: () async => testRoot, + store: store, + runtimeCoordinator: RuntimeCoordinator( + gateway: _TestFakeGatewayRuntime(store: store), + codex: _TestFakeCodexRuntime(), ), desktopPlatformService: desktopPlatformService, uiFeatureManifest: uiFeatureManifest, @@ -71,6 +81,101 @@ Future createTestController( return controller; } +class _TestFakeGatewayRuntime extends GatewayRuntime { + _TestFakeGatewayRuntime({required super.store}) + : super(identityStore: DeviceIdentityStore(store)); + + GatewayConnectionSnapshot _snapshot = GatewayConnectionSnapshot.initial(); + + @override + bool get isConnected => _snapshot.status == RuntimeConnectionStatus.connected; + + @override + GatewayConnectionSnapshot get snapshot => _snapshot; + + @override + Stream get events => const Stream.empty(); + + @override + Future connectProfile( + GatewayConnectionProfile profile, { + int? profileIndex, + String authTokenOverride = '', + String authPasswordOverride = '', + }) async { + _snapshot = GatewayConnectionSnapshot.initial(mode: profile.mode).copyWith( + status: RuntimeConnectionStatus.connected, + statusText: 'Connected', + remoteAddress: '${profile.host}:${profile.port}', + connectAuthMode: 'none', + ); + notifyListeners(); + } + + @override + Future disconnect({bool clearDesiredProfile = true}) async { + _snapshot = _snapshot.copyWith( + status: RuntimeConnectionStatus.offline, + statusText: 'Offline', + remoteAddress: null, + clearLastError: true, + clearLastErrorCode: true, + clearLastErrorDetailCode: true, + ); + notifyListeners(); + } + + @override + Future request( + String method, { + Map? params, + Duration timeout = const Duration(seconds: 30), + }) async { + switch (method) { + case 'health': + case 'status': + return {'ok': true}; + case 'agents.list': + return {'agents': const [], 'mainKey': 'main'}; + case 'sessions.list': + return {'sessions': const []}; + case 'chat.history': + return {'messages': const []}; + case 'skills.status': + return {'skills': const []}; + case 'channels.status': + return { + 'channelMeta': const [], + 'channelLabels': const {}, + 'channelDetailLabels': const {}, + 'channelAccounts': const {}, + 'channelOrder': const [], + }; + case 'models.list': + return {'models': const []}; + case 'cron.list': + return {'jobs': const []}; + case 'device.pair.list': + return { + 'pending': const [], + 'paired': const [], + }; + case 'system-presence': + return const []; + default: + return {}; + } + } +} + +class _TestFakeCodexRuntime extends CodexRuntime { + @override + Future findCodexBinary() async => null; + + @override + Future stop() async {} +} + Future pumpPage( WidgetTester tester, { required Widget child, diff --git a/test/widgets/sidebar_navigation_suite.dart b/test/widgets/sidebar_navigation_suite.dart index 9b4ed8da..681ac7e2 100644 --- a/test/widgets/sidebar_navigation_suite.dart +++ b/test/widgets/sidebar_navigation_suite.dart @@ -113,52 +113,55 @@ void main() { await tester.pumpAndSettle(); expect(accountOpened, 1); - await tester.tap(find.byKey(const Key('workspace-sidebar-collapse-button'))); + await tester.tap( + find.byKey(const Key('workspace-sidebar-collapse-button')), + ); await tester.pumpAndSettle(); expect(sidebarCycled, 1); }); - testWidgets('SidebarNavigation no longer expands settings sub navigation in sidebar', ( - WidgetTester tester, - ) async { - await tester.pumpWidget( - MaterialApp( - theme: AppTheme.light(), - home: Scaffold( - body: SidebarNavigation( - currentSection: WorkspaceDestination.settings, - sidebarState: AppSidebarState.expanded, - appLanguage: AppLanguage.zh, - themeMode: ThemeMode.light, - onSectionChanged: (_) {}, - onToggleLanguage: () {}, - onCycleSidebarState: () {}, - onExpandFromCollapsed: () {}, - onOpenHome: () {}, - onOpenAccount: () {}, - onOpenThemeToggle: () {}, - accountName: 'Tester', - accountSubtitle: 'Workspace', - onToggleAccountWorkspaceFollowed: () async {}, + testWidgets( + 'SidebarNavigation no longer expands settings sub navigation in sidebar', + (WidgetTester tester) async { + await tester.pumpWidget( + MaterialApp( + theme: AppTheme.light(), + home: Scaffold( + body: SidebarNavigation( + currentSection: WorkspaceDestination.settings, + sidebarState: AppSidebarState.expanded, + appLanguage: AppLanguage.zh, + themeMode: ThemeMode.light, + onSectionChanged: (_) {}, + onToggleLanguage: () {}, + onCycleSidebarState: () {}, + onExpandFromCollapsed: () {}, + onOpenHome: () {}, + onOpenAccount: () {}, + onOpenThemeToggle: () {}, + accountName: 'Tester', + accountSubtitle: 'Workspace', + onToggleAccountWorkspaceFollowed: () async {}, + ), ), ), - ), - ); - await tester.pumpAndSettle(); + ); + await tester.pumpAndSettle(); - expect( - find.byKey(const ValueKey('sidebar-settings-tab-general')), - findsNothing, - ); - expect( - find.byKey(const ValueKey('sidebar-settings-tab-workspace')), - findsNothing, - ); - expect( - find.byKey(const ValueKey('sidebar-settings-tab-gateway')), - findsNothing, - ); - }); + expect( + find.byKey(const ValueKey('sidebar-settings-tab-general')), + findsNothing, + ); + expect( + find.byKey(const ValueKey('sidebar-settings-tab-workspace')), + findsNothing, + ); + expect( + find.byKey(const ValueKey('sidebar-settings-tab-gateway')), + findsNothing, + ); + }, + ); testWidgets('SidebarNavigation shows collapsed expand button at the top', ( WidgetTester tester, @@ -190,7 +193,10 @@ void main() { ); await tester.pumpAndSettle(); - expect(find.byKey(const Key('sidebar-header-expand-button')), findsOneWidget); + expect( + find.byKey(const Key('sidebar-header-expand-button')), + findsOneWidget, + ); expect( find.byKey(const ValueKey('sidebar-footer-collapse')), findsNothing, @@ -201,66 +207,152 @@ void main() { expect(expanded, 1); }); - testWidgets('SidebarNavigation merges task controls into the global left bar', ( - WidgetTester tester, - ) async { - await tester.pumpWidget( - MaterialApp( - theme: AppTheme.light(), - home: Scaffold( - body: SidebarNavigation( - currentSection: WorkspaceDestination.assistant, - sidebarState: AppSidebarState.expanded, - appLanguage: AppLanguage.zh, - themeMode: ThemeMode.light, - onSectionChanged: (_) {}, - onToggleLanguage: () {}, - onCycleSidebarState: () {}, - onExpandFromCollapsed: () {}, - onOpenHome: () {}, - onOpenAccount: () {}, - onOpenThemeToggle: () {}, - 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, - ), - ], + testWidgets( + 'SidebarNavigation merges task controls into the global left bar', + (WidgetTester tester) async { + await tester.pumpWidget( + MaterialApp( + theme: AppTheme.light(), + home: Scaffold( + body: SidebarNavigation( + currentSection: WorkspaceDestination.assistant, + sidebarState: AppSidebarState.expanded, + appLanguage: AppLanguage.zh, + themeMode: ThemeMode.light, + onSectionChanged: (_) {}, + onToggleLanguage: () {}, + onCycleSidebarState: () {}, + onExpandFromCollapsed: () {}, + onOpenHome: () {}, + onOpenAccount: () {}, + onOpenThemeToggle: () {}, + 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(); + ); + await tester.pumpAndSettle(); - 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('自动化'), findsNothing); - expect(find.text('MCP Hub'), findsNothing); - expect(find.text('新的任务'), findsOneWidget); - expect( - find.byKey( - const ValueKey('workspace-sidebar-task-group-singleAgent'), - ), - findsOneWidget, - ); - }); + 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('自动化'), findsNothing); + expect(find.text('MCP Hub'), findsNothing); + expect(find.text('新的任务'), findsOneWidget); + expect( + find.byKey( + const ValueKey('workspace-sidebar-task-group-singleAgent'), + ), + findsOneWidget, + ); + }, + ); + + testWidgets( + 'SidebarNavigation only shows configured execution target groups', + (WidgetTester tester) async { + await tester.pumpWidget( + MaterialApp( + theme: AppTheme.light(), + home: Scaffold( + body: SidebarNavigation( + currentSection: WorkspaceDestination.assistant, + sidebarState: AppSidebarState.expanded, + appLanguage: AppLanguage.zh, + themeMode: ThemeMode.light, + onSectionChanged: (_) {}, + onToggleLanguage: () {}, + onCycleSidebarState: () {}, + onExpandFromCollapsed: () {}, + onOpenHome: () {}, + onOpenAccount: () {}, + onOpenThemeToggle: () {}, + accountName: 'Tester', + accountSubtitle: 'Workspace', + onToggleAccountWorkspaceFollowed: () async {}, + visibleExecutionTargets: const [ + AssistantExecutionTarget.singleAgent, + AssistantExecutionTarget.remote, + ], + taskItems: const [ + SidebarTaskItem( + sessionKey: 'single-agent-task', + title: '单机任务', + preview: '已保存 provider', + updatedAtMs: 1710000000000, + executionTarget: AssistantExecutionTarget.singleAgent, + isCurrent: true, + pending: false, + ), + SidebarTaskItem( + sessionKey: 'remote-task', + title: '远程任务', + preview: '已保存远程 gateway', + updatedAtMs: 1710000001000, + executionTarget: AssistantExecutionTarget.remote, + isCurrent: false, + pending: false, + ), + SidebarTaskItem( + sessionKey: 'local-task', + title: '本地任务', + preview: '未保存本地 gateway', + updatedAtMs: 1710000002000, + executionTarget: AssistantExecutionTarget.local, + isCurrent: false, + pending: false, + ), + ], + ), + ), + ), + ); + await tester.pumpAndSettle(); + + expect( + find.byKey( + const ValueKey('workspace-sidebar-task-group-singleAgent'), + ), + findsOneWidget, + ); + expect( + find.byKey( + const ValueKey('workspace-sidebar-task-group-remote'), + ), + findsOneWidget, + ); + expect( + find.byKey( + const ValueKey('workspace-sidebar-task-group-local'), + ), + findsNothing, + ); + expect(find.text('单机任务'), findsOneWidget); + expect(find.text('远程任务'), findsOneWidget); + expect(find.text('本地任务'), findsNothing); + }, + ); testWidgets('SidebarNavigation keeps footer pinned while task list scrolls', ( WidgetTester tester,