Merge branch 'codex/task-sidebar-target-visibility'

This commit is contained in:
Haitao Pan 2026-04-06 14:58:38 +08:00
commit e73d6fa995
24 changed files with 1236 additions and 536 deletions

View File

@ -590,7 +590,7 @@ class AppController extends ChangeNotifier {
List<SingleAgentProvider> 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<AssistantExecutionTarget> visibleAssistantExecutionTargets(
Iterable<AssistantExecutionTarget> supportedTargets,
) {
return settings.visibleAssistantExecutionTargets(
supportedTargets: supportedTargets,
availableSingleAgentProviders: availableSingleAgentProviders,
);
}
bool get hasAnyAvailableSingleAgentProvider =>
availableSingleAgentProviders.isNotEmpty;

View File

@ -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;
}

View File

@ -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(<String>[
singleAgentResolvedProviderForSession(normalizedSessionKey)
?.label
.isNotEmpty ==
singleAgentResolvedProviderForSession(
normalizedSessionKey,
)?.label.isNotEmpty ==
true
? singleAgentResolvedProviderForSession(normalizedSessionKey)!.label
? singleAgentResolvedProviderForSession(
normalizedSessionKey,
)!.label
: appText('Single Agent', 'Single Agent'),
latestResolvedModel,
]),

View File

@ -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<GatewayChatMessage>.from(
assistantThreadMessagesInternal[key] ?? const <GatewayChatMessage>[],
)..add(message);
assistantThreadMessagesInternal[key] = next;
upsertTaskThreadInternal(
key,
title: derivePersistedTaskTitle(
existingTitle,
next,
fallback: key,
hasCustomTitle: customTitle.isNotEmpty,
),
messages: next,
updatedAtMs:
message.timestampMs ??

View File

@ -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);

View File

@ -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();

View File

@ -260,6 +260,10 @@ extension AppControllerWebGatewayRelay on AppController {
existing?.title ?? '',
messages,
fallback: resolvedKey,
hasCustomTitle:
(settingsInternal.assistantCustomTaskTitles[resolvedKey]?.trim() ??
'')
.isNotEmpty,
),
executionBinding: (existing?.executionBinding ??
ExecutionBinding(

View File

@ -1001,23 +1001,14 @@ extension AppControllerWebHelpers on AppController {
String currentTitle,
List<GatewayChatMessage> 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) {

View File

@ -25,9 +25,17 @@ import 'app_controller_web_helpers.dart';
extension AppControllerWebSessionActions on AppController {
Future<void> createConversation({AssistantExecutionTarget? target}) async {
final inheritedTarget =
final requestedTarget =
sanitizeTargetInternal(target) ??
assistantExecutionTargetForSession(currentSessionKeyInternal);
final visibleTargets = visibleAssistantExecutionTargets(const <AssistantExecutionTarget>[
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<void> setAssistantExecutionTarget(
AssistantExecutionTarget target,
) async {
final resolvedTarget =
final requestedTarget =
sanitizeTargetInternal(target) ??
assistantExecutionTargetForSession(currentSessionKeyInternal);
final visibleTargets = visibleAssistantExecutionTargets(const <AssistantExecutionTarget>[
AssistantExecutionTarget.singleAgent,
AssistantExecutionTarget.local,
AssistantExecutionTarget.remote,
]);
final resolvedTarget = visibleTargets.contains(requestedTarget)
? requestedTarget
: (visibleTargets.isNotEmpty ? visibleTargets.first : requestedTarget);
final sessionKey = normalizedSessionKeyInternal(currentSessionKeyInternal);
upsertThreadRecordInternal(
sessionKey,

View File

@ -139,7 +139,19 @@ extension AppControllerWebSessions on AppController {
singleAgentProviderForSession(currentSessionKeyInternal);
List<SingleAgentProvider> get singleAgentProviderOptions =>
settingsInternal.availableSingleAgentProviders;
settingsInternal.savedSingleAgentProviders;
List<SingleAgentProvider> get availableSingleAgentProviders =>
singleAgentProviderOptions;
List<AssistantExecutionTarget> visibleAssistantExecutionTargets(
Iterable<AssistantExecutionTarget> supportedTargets,
) {
return settingsInternal.visibleAssistantExecutionTargets(
supportedTargets: supportedTargets,
availableSingleAgentProviders: availableSingleAgentProviders,
);
}
bool singleAgentUsesAiChatFallbackForSession(String sessionKey) {
final provider = singleAgentProviderForSession(sessionKey);

View File

@ -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<AppShell> {
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<void> _createSidebarConversation(AppController controller) async {
Future<void> _createSidebarConversation(
AppController controller,
List<AssistantExecutionTarget> 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<AppShell> {
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<AppShell> {
),
const SizedBox(width: 12),
TextButton(
onPressed: controller.dismissStartupTaskThreadWarning,
onPressed:
controller.dismissStartupTaskThreadWarning,
child: Text(appText('关闭', 'Dismiss')),
),
],
@ -143,11 +162,18 @@ class _AppShellState extends State<AppShell> {
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<AppShell> {
.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<void>(
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<void>(
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<void>(
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<void>(
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),
),
),
],
);
);
},
),
),

View File

@ -79,7 +79,14 @@ class AssistantTaskRailStateInternal extends State<AssistantTaskRailInternal> {
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>[
AssistantExecutionTarget.singleAgent,
AssistantExecutionTarget.local,
AssistantExecutionTarget.remote,
]),
);
final runningCount = tasks
.where((task) => normalizedTaskStatusInternal(task.status) == 'running')
.length;
@ -276,15 +283,20 @@ class AssistantTaskRailStateInternal extends State<AssistantTaskRailInternal> {
List<AssistantTaskGroupInternal> groupTasksForRailInternal(
List<AssistantTaskEntryInternal> tasks,
List<AssistantExecutionTarget> visibleExecutionTargets,
) {
final grouped = <AssistantExecutionTarget, List<AssistantTaskEntryInternal>>{
for (final target in AssistantExecutionTarget.values)
for (final target in visibleExecutionTargets)
target: <AssistantTaskEntryInternal>[],
};
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,

View File

@ -355,7 +355,16 @@ class ComposerBarStateInternal extends State<ComposerBarInternal> {
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<ComposerBarInternal> {
),
const SizedBox(width: 6),
],
PopupMenuButton<AssistantExecutionTarget>(
key: const Key('assistant-execution-target-button'),
tooltip: appText('任务对话模式', 'Task Dialog Mode'),
onSelected: (value) {
controller.setAssistantExecutionTarget(value);
},
itemBuilder: (context) => uiFeatures.availableExecutionTargets
.map(
(value) => PopupMenuItem<AssistantExecutionTarget>(
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<AssistantExecutionTarget>(
key: const Key('assistant-execution-target-button'),
tooltip: appText('任务对话模式', 'Task Dialog Mode'),
onSelected: (value) {
controller.setAssistantExecutionTarget(value);
},
itemBuilder: (context) => visibleExecutionTargets
.map(
(value) => PopupMenuItem<AssistantExecutionTarget>(
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<SingleAgentProvider>(

View File

@ -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<void> createNewThreadInternal() async {
final sessionKey = buildDraftSessionKeyInternal(widget.controller);
final inheritedTarget = widget.controller.currentAssistantExecutionTarget;
final inheritedTarget = resolvedVisibleExecutionTargetInternal(
widget.controller,
supportedTargets: const <AssistantExecutionTarget>[
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>[
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<AssistantExecutionTarget> 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,

View File

@ -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<GatewayChatMessage> 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<GatewayChatMessage> 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);
}

View File

@ -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<AssistantFocusEntry> assistantNavigationDestinations;
final Map<String, String> assistantCustomTaskTitles;
final List<String> assistantArchivedTaskKeys;
final List<String> savedGatewayTargets;
final String assistantLastSessionKey;
factory SettingsSnapshot.defaults() {
@ -121,6 +123,7 @@ class SettingsSnapshot {
assistantNavigationDestinations: kAssistantNavigationDestinationDefaults,
assistantCustomTaskTitles: const <String, String>{},
assistantArchivedTaskKeys: const <String>[],
savedGatewayTargets: const <String>[],
assistantLastSessionKey: '',
);
}
@ -160,6 +163,7 @@ class SettingsSnapshot {
List<AssistantFocusEntry>? assistantNavigationDestinations,
Map<String, String>? assistantCustomTaskTitles,
List<String>? assistantArchivedTaskKeys,
List<String>? 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<String> normalizeSavedGatewayTargetsFromJson(Object? value) {
if (value is! List) {
return const <String>[];
}
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<SingleAgentProvider> 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: <String>[...savedGatewayTargets, targetKey],
);
}
List<SingleAgentProvider> visibleSingleAgentProviders(
Iterable<SingleAgentProvider> availableProviders,
) {
final allowedProviderIds = savedSingleAgentProviders
.map((item) => item.providerId)
.toSet();
return normalizeSingleAgentProviderList(
availableProviders.where(
(item) => allowedProviderIds.contains(item.providerId),
),
);
}
List<AssistantExecutionTarget> visibleAssistantExecutionTargets({
required Iterable<AssistantExecutionTarget> supportedTargets,
required Iterable<SingleAgentProvider> availableSingleAgentProviders,
}) {
final supported = supportedTargets.toSet();
final visible = <AssistantExecutionTarget>[];
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<AssistantExecutionTarget>.unmodifiable(visible);
}
SettingsSnapshot copyWithExternalAcpEndpointForProvider(
SingleAgentProvider provider,
ExternalAcpEndpointProfile profile,
@ -564,3 +652,17 @@ class SettingsSnapshot {
);
}
}
List<String> normalizeSavedGatewayTargets(Iterable<String> rawTargets) {
final normalized = <String>[];
final seen = <String>{};
for (final item in rawTargets) {
final normalizedTarget = item.trim().toLowerCase();
if ((normalizedTarget != 'local' && normalizedTarget != 'remote') ||
!seen.add(normalizedTarget)) {
continue;
}
normalized.add(normalizedTarget);
}
return List<String>.unmodifiable(normalized);
}

View File

@ -38,6 +38,11 @@ class SidebarNavigation extends StatelessWidget {
this.availableSettingsTabs = const <SettingsTab>[],
this.onSettingsTabChanged,
this.taskItems = const <SidebarTaskItem>[],
this.visibleExecutionTargets = const <AssistantExecutionTarget>[
AssistantExecutionTarget.singleAgent,
AssistantExecutionTarget.local,
AssistantExecutionTarget.remote,
],
this.assistantSkillCount = 0,
this.onRefreshTasks,
this.onCreateTask,
@ -72,6 +77,7 @@ class SidebarNavigation extends StatelessWidget {
final List<SettingsTab> availableSettingsTabs;
final ValueChanged<SettingsTab>? onSettingsTabChanged;
final List<SidebarTaskItem> taskItems;
final List<AssistantExecutionTarget> visibleExecutionTargets;
final int assistantSkillCount;
final Future<void> Function()? onRefreshTasks;
final Future<void> Function()? onCreateTask;
@ -121,6 +127,7 @@ class SidebarNavigation extends StatelessWidget {
Expanded(
child: SidebarTaskSection(
items: taskItems,
visibleExecutionTargets: visibleExecutionTargets,
skillCount: assistantSkillCount,
showCollapseControl: showCollapseControl,
onCycleSidebarState: onCycleSidebarState,

View File

@ -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<SidebarTaskItem> items;
final List<AssistantExecutionTarget> visibleExecutionTargets;
final int skillCount;
final bool showCollapseControl;
final VoidCallback onCycleSidebarState;
@ -267,13 +269,17 @@ class _SidebarTaskSectionState extends State<SidebarTaskSection> {
List<_SidebarTaskGroup> _groupedItems(List<SidebarTaskItem> items) {
final grouped = <AssistantExecutionTarget, List<SidebarTaskItem>>{
for (final target in AssistantExecutionTarget.values)
for (final target in widget.visibleExecutionTargets)
target: <SidebarTaskItem>[],
};
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,

View File

@ -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(<String, Object>{});
final store = createIsolatedTestStore(enableSecureStorage: false);
final defaults = SettingsSnapshot.defaults();
await store.saveSettingsSnapshot(
defaults.copyWith(savedGatewayTargets: const <String>['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<void>.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(<String, Object>{});
final store = createIsolatedTestStore(enableSecureStorage: false);
final defaults = SettingsSnapshot.defaults();
await store.saveSettingsSnapshot(
defaults.copyWith(savedGatewayTargets: const <String>['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<void>.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);
},
);

View File

@ -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<String>('assistant-task-group-auto')),
findsOneWidget,
findsNothing,
);
expect(
find.byKey(const ValueKey<String>('assistant-task-group-singleAgent')),
findsOneWidget,
findsNothing,
);
expect(
find.byKey(const ValueKey<String>('assistant-task-group-local')),
findsOneWidget,
findsNothing,
);
expect(
find.byKey(const ValueKey<String>('assistant-task-group-remote')),
findsOneWidget,
findsNothing,
);
expect(
find.byKey(const ValueKey<String>('assistant-task-item-main')),
findsNothing,
);
await tester.tap(
find.byKey(const ValueKey<String>('assistant-task-group-auto')),
);
await pumpForUiSyncInternal(tester);
expect(
find.byKey(const ValueKey<String>('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,

View File

@ -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: <ExternalAcpEndpointProfile>[
...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 <String>['Codex'],
);
});
test('visible execution targets only include explicitly saved targets', () {
final defaults = SettingsSnapshot.defaults();
final snapshot = defaults
.copyWith(
externalAcpEndpoints: normalizeExternalAcpEndpoints(
profiles: <ExternalAcpEndpointProfile>[
...defaults.externalAcpEndpoints,
ExternalAcpEndpointProfile.defaultsForProvider(
SingleAgentProvider.codex,
).copyWith(endpoint: 'wss://codex.example.com/acp'),
],
),
)
.markGatewayTargetSaved(AssistantExecutionTarget.remote);
expect(
snapshot.visibleAssistantExecutionTargets(
supportedTargets: const <AssistantExecutionTarget>[
AssistantExecutionTarget.auto,
AssistantExecutionTarget.singleAgent,
AssistantExecutionTarget.local,
AssistantExecutionTarget.remote,
],
availableSingleAgentProviders: snapshot.availableSingleAgentProviders,
),
const <AssistantExecutionTarget>[
AssistantExecutionTarget.singleAgent,
AssistantExecutionTarget.remote,
],
);
});
});
}

View File

@ -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('新对话', <GatewayChatMessage>[
_userMessage('请帮我排查桌面端任务边栏为什么一直显示新任务'),
]);
expect(title, '请帮我排查桌面端任务边栏为什么一直显示新任务');
});
test('keeps the persisted auto title after later messages arrive', () {
final title = derivePersistedTaskTitle('首条任务说明', <GatewayChatMessage>[
_userMessage('首条任务说明'),
_assistantMessage('收到,我来看看'),
_userMessage('补充更多上下文,但不应该改标题'),
]);
expect(title, '首条任务说明');
});
test('does not overwrite a custom title with an auto-derived title', () {
final title = derivePersistedTaskTitle('我自己改过的标题', <GatewayChatMessage>[
_userMessage('默认标题候选'),
], hasCustomTitle: true);
expect(title, '我自己改过的标题');
});
test(
'falls back to the persisted auto title after custom title is cleared',
() {
final title = derivePersistedTaskTitle(
'已持久化的自动标题',
<GatewayChatMessage>[_userMessage('新的消息不应该重新改标题')],
);
expect(title, '已持久化的自动标题');
},
);
});
}

View File

@ -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<AppController> createTestController(
SharedPreferences.setMockInitialValues(<String, Object>{});
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<AppController> 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<GatewayPushEvent> get events => const Stream<GatewayPushEvent>.empty();
@override
Future<void> 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<void> disconnect({bool clearDesiredProfile = true}) async {
_snapshot = _snapshot.copyWith(
status: RuntimeConnectionStatus.offline,
statusText: 'Offline',
remoteAddress: null,
clearLastError: true,
clearLastErrorCode: true,
clearLastErrorDetailCode: true,
);
notifyListeners();
}
@override
Future<dynamic> request(
String method, {
Map<String, dynamic>? params,
Duration timeout = const Duration(seconds: 30),
}) async {
switch (method) {
case 'health':
case 'status':
return <String, dynamic>{'ok': true};
case 'agents.list':
return <String, dynamic>{'agents': const <Object>[], 'mainKey': 'main'};
case 'sessions.list':
return <String, dynamic>{'sessions': const <Object>[]};
case 'chat.history':
return <String, dynamic>{'messages': const <Object>[]};
case 'skills.status':
return <String, dynamic>{'skills': const <Object>[]};
case 'channels.status':
return <String, dynamic>{
'channelMeta': const <Object>[],
'channelLabels': const <String, dynamic>{},
'channelDetailLabels': const <String, dynamic>{},
'channelAccounts': const <String, dynamic>{},
'channelOrder': const <Object>[],
};
case 'models.list':
return <String, dynamic>{'models': const <Object>[]};
case 'cron.list':
return <String, dynamic>{'jobs': const <Object>[]};
case 'device.pair.list':
return <String, dynamic>{
'pending': const <Object>[],
'paired': const <Object>[],
};
case 'system-presence':
return const <Object>[];
default:
return <String, dynamic>{};
}
}
}
class _TestFakeCodexRuntime extends CodexRuntime {
@override
Future<String?> findCodexBinary() async => null;
@override
Future<void> stop() async {}
}
Future<void> pumpPage(
WidgetTester tester, {
required Widget child,

View File

@ -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<String>('sidebar-settings-tab-general')),
findsNothing,
);
expect(
find.byKey(const ValueKey<String>('sidebar-settings-tab-workspace')),
findsNothing,
);
expect(
find.byKey(const ValueKey<String>('sidebar-settings-tab-gateway')),
findsNothing,
);
});
expect(
find.byKey(const ValueKey<String>('sidebar-settings-tab-general')),
findsNothing,
);
expect(
find.byKey(const ValueKey<String>('sidebar-settings-tab-workspace')),
findsNothing,
);
expect(
find.byKey(const ValueKey<String>('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<String>('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>[
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>[
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<String>('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<String>('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>[
AssistantExecutionTarget.singleAgent,
AssistantExecutionTarget.remote,
],
taskItems: const <SidebarTaskItem>[
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<String>('workspace-sidebar-task-group-singleAgent'),
),
findsOneWidget,
);
expect(
find.byKey(
const ValueKey<String>('workspace-sidebar-task-group-remote'),
),
findsOneWidget,
);
expect(
find.byKey(
const ValueKey<String>('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,