Merge branch 'codex/task-sidebar-target-visibility'
This commit is contained in:
commit
e73d6fa995
@ -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;
|
||||
|
||||
|
||||
@ -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;
|
||||
}
|
||||
|
||||
@ -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,
|
||||
]),
|
||||
|
||||
@ -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 ??
|
||||
|
||||
@ -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);
|
||||
|
||||
@ -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();
|
||||
|
||||
@ -260,6 +260,10 @@ extension AppControllerWebGatewayRelay on AppController {
|
||||
existing?.title ?? '',
|
||||
messages,
|
||||
fallback: resolvedKey,
|
||||
hasCustomTitle:
|
||||
(settingsInternal.assistantCustomTaskTitles[resolvedKey]?.trim() ??
|
||||
'')
|
||||
.isNotEmpty,
|
||||
),
|
||||
executionBinding: (existing?.executionBinding ??
|
||||
ExecutionBinding(
|
||||
|
||||
@ -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) {
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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);
|
||||
|
||||
@ -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),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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>(
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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);
|
||||
}
|
||||
|
||||
@ -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);
|
||||
}
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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);
|
||||
},
|
||||
);
|
||||
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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,
|
||||
],
|
||||
);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
71
test/runtime/task_title_visibility_suite.dart
Normal file
71
test/runtime/task_title_visibility_suite.dart
Normal 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, '已持久化的自动标题');
|
||||
},
|
||||
);
|
||||
});
|
||||
}
|
||||
@ -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,
|
||||
|
||||
@ -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,
|
||||
|
||||
Loading…
Reference in New Issue
Block a user