Remove project selection from task threads

This commit is contained in:
Haitao Pan 2026-04-11 18:25:26 +08:00
parent e15898fb24
commit 49d3036090
18 changed files with 234 additions and 298 deletions

View File

@ -49,8 +49,8 @@ extension AppControllerDesktopExternalAcpRouting on AppController {
normalizedSessionKey,
);
final preferredGatewayTarget = switch (sessionTarget) {
AssistantExecutionTarget.gateway => 'gateway',
AssistantExecutionTarget.singleAgent => 'gateway',
AssistantExecutionTarget.gateway => kCanonicalGatewayProviderId,
AssistantExecutionTarget.singleAgent => kCanonicalGatewayProviderId,
};
final availableSkills =
assistantImportedSkillsForSession(normalizedSessionKey)

View File

@ -157,9 +157,16 @@ String? assistantWorkingDirectoryForSessionRuntimeInternal(
AppController controller,
String sessionKey,
) {
final candidate = controller
.assistantSelectedWorkingDirectoryForSession(sessionKey)
.trim();
final normalizedSessionKey = controller.normalizedAssistantSessionKeyInternal(
sessionKey,
);
final candidate =
controller
.assistantThreadRecordsInternal[normalizedSessionKey]
?.workspaceBinding
.workspacePath
.trim() ??
'';
if (candidate.isEmpty) {
return null;
}

View File

@ -237,8 +237,6 @@ extension AppControllerDesktopSkillPermissions on AppController {
ThreadSelectionSource? singleAgentProviderSource,
ThreadSelectionSource? assistantModelSource,
ThreadSelectionSource? selectedSkillsSource,
String? selectedWorkingDirectory,
bool clearSelectedWorkingDirectory = false,
String? gatewayEntryState,
String? latestResolvedRuntimeModel,
String? lifecycleStatus,
@ -350,7 +348,6 @@ extension AppControllerDesktopSkillPermissions on AppController {
permissionLevel: AssistantPermissionLevel.defaultAccess,
messageViewMode: AssistantMessageViewMode.rendered,
latestResolvedRuntimeModel: '',
selectedWorkingDirectory: null,
gatewayEntryState: gatewayEntryStateForTargetInternal(
nextExecutionTarget,
),
@ -375,8 +372,6 @@ extension AppControllerDesktopSkillPermissions on AppController {
selectedSkillsSource ??
existing?.contextState.selectedSkillsSource,
latestResolvedRuntimeModel: latestResolvedRuntimeModel,
selectedWorkingDirectory: selectedWorkingDirectory?.trim(),
clearSelectedWorkingDirectory: clearSelectedWorkingDirectory,
gatewayEntryState: gatewayEntryState,
lastRemoteWorkingDirectory: lastRemoteWorkingDirectory,
lastRemoteWorkspaceRefKind: lastRemoteWorkspaceRefKind,

View File

@ -247,14 +247,16 @@ extension AppControllerDesktopThreadActions on AppController {
currentSessionKey,
executionTarget: currentTarget,
);
final workingDirectory = assistantSelectedWorkingDirectoryForSession(
currentSessionKey,
).trim();
final workingDirectory =
assistantWorkingDirectoryForSessionInternal(
currentSessionKey,
)?.trim() ??
'';
if (workingDirectory.isEmpty) {
final error = StateError(
appText(
'当前线程尚未选择项目目录,无法运行。请先选择项目',
'This thread has no project directory yet. Select a project before running.',
'当前任务线程缺少可运行的 workingDirectory无法执行',
'This task thread has no runnable workingDirectory yet.',
),
);
appendAssistantThreadMessageInternal(

View File

@ -198,17 +198,16 @@ extension AppControllerDesktopThreadBinding on AppController {
}) {
if (executionTarget == AssistantExecutionTarget.singleAgent) {
if (existingBinding != null &&
existingBinding.workspaceKind == WorkspaceKind.localFs &&
!isManagedLocalThreadWorkspacePathInternal(
existingBinding.workspacePath,
sessionKey,
) &&
ensureLocalWorkspaceDirectoryInternal(
existingBinding.workspacePath,
)) {
return existingBinding.copyWith(
displayPath: existingBinding.workspacePath,
);
existingBinding.workspaceKind == WorkspaceKind.localFs) {
final existingPath = existingBinding.workspacePath.trim();
if (existingPath.isNotEmpty &&
ensureLocalWorkspaceDirectoryInternal(existingPath)) {
// A task thread owns one stable local workingDirectory for its
// lifetime. Do not silently rebind it after the initial allocation.
return existingBinding.copyWith(
displayPath: existingBinding.workspacePath,
);
}
}
final localPath = localThreadWorkspacePathInternal(sessionKey);
if (localPath.isEmpty) {
@ -255,15 +254,16 @@ extension AppControllerDesktopThreadBinding on AppController {
required SingleAgentProvider singleAgentProvider,
ExecutionBinding? existingBinding,
}) {
final sanitizedProvider =
executionTarget == AssistantExecutionTarget.singleAgent
? settings.sanitizeSingleAgentProviderSelection(singleAgentProvider)
: SingleAgentProvider.unspecified;
final providerId = executionTarget == AssistantExecutionTarget.singleAgent
? settings
.sanitizeSingleAgentProviderSelection(singleAgentProvider)
.providerId
: kCanonicalGatewayProviderId;
return (existingBinding ??
ExecutionBinding(
executionMode: ThreadExecutionMode.localAgent,
executorId: sanitizedProvider.providerId,
providerId: sanitizedProvider.providerId,
executorId: providerId,
providerId: providerId,
endpointId: '',
))
.copyWith(
@ -272,8 +272,8 @@ extension AppControllerDesktopThreadBinding on AppController {
ThreadExecutionMode.localAgent,
AssistantExecutionTarget.gateway => ThreadExecutionMode.gateway,
},
executorId: sanitizedProvider.providerId,
providerId: sanitizedProvider.providerId,
executorId: providerId,
providerId: providerId,
providerSource:
executionTarget == AssistantExecutionTarget.singleAgent
? existingBinding?.providerSource

View File

@ -216,35 +216,6 @@ extension AppControllerDesktopThreadSessions on AppController {
'';
}
String assistantSelectedWorkingDirectoryForSession(String sessionKey) {
final normalizedSessionKey = normalizedAssistantSessionKeyInternal(
sessionKey,
);
return taskThreadForSessionInternal(
normalizedSessionKey,
)?.selectedWorkingDirectory?.trim() ??
'';
}
String assistantSelectedWorkingDirectoryDisplayLabelForSession(
String sessionKey,
) {
final workingDirectory = assistantSelectedWorkingDirectoryForSession(
sessionKey,
);
if (workingDirectory.isEmpty) {
return appText('选择项目', 'Select Project');
}
final segments = workingDirectory
.split(RegExp(r'[\\/]'))
.where((item) => item.trim().isNotEmpty)
.toList(growable: false);
if (segments.isEmpty) {
return workingDirectory;
}
return segments.last;
}
Future<AssistantArtifactSnapshot> loadAssistantArtifactSnapshot({
String? sessionKey,
}) {

View File

@ -698,7 +698,11 @@ extension AppControllerDesktopThreadStorage on AppController {
record.executionBinding.providerId,
),
)
: SingleAgentProvider.unspecified;
: const SingleAgentProvider(
providerId: kCanonicalGatewayProviderId,
label: kCanonicalGatewayProviderLabel,
badge: 'OC',
);
final workspaceBinding = record.workspaceBinding.copyWith(
workspaceId: sessionKey,
displayPath: record.workspaceKind == WorkspaceKind.localFs

View File

@ -372,32 +372,6 @@ extension AppControllerDesktopWorkspaceExecution on AppController {
notifyIfActiveInternal();
}
Future<void> saveAssistantSelectedWorkingDirectoryForSession(
String sessionKey,
String workingDirectory,
) async {
final normalizedSessionKey = normalizedAssistantSessionKeyInternal(
sessionKey,
);
final normalizedWorkingDirectory = workingDirectory.trim();
if (normalizedWorkingDirectory.isEmpty) {
upsertTaskThreadInternal(
normalizedSessionKey,
clearSelectedWorkingDirectory: true,
updatedAtMs: DateTime.now().millisecondsSinceEpoch.toDouble(),
);
} else {
upsertTaskThreadInternal(
normalizedSessionKey,
selectedWorkingDirectory: normalizedWorkingDirectory,
updatedAtMs: DateTime.now().millisecondsSinceEpoch.toDouble(),
);
}
recomputeTasksInternal();
notifyIfActiveInternal();
await flushAssistantThreadPersistenceInternal();
}
Future<void> refreshSingleAgentSkillsForSession(String sessionKey) async {
final normalizedSessionKey = normalizedAssistantSessionKeyInternal(
sessionKey,

View File

@ -4,7 +4,6 @@ import 'dart:async';
import 'dart:convert';
import 'dart:io';
import 'dart:math' as math;
import 'package:file_selector/file_selector.dart';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter_markdown/flutter_markdown.dart';
@ -343,31 +342,6 @@ class ComposerBarStateInternal extends State<ComposerBarInternal> {
});
}
Future<void> pickWorkingDirectoryInternal() async {
final controller = widget.controller;
final sessionKey = controller.currentSessionKey;
final selectedWorkingDirectory = controller
.assistantSelectedWorkingDirectoryForSession(sessionKey)
.trim();
final fallbackWorkspacePath = controller
.assistantWorkspacePathForSession(sessionKey)
.trim();
final initialDirectory = selectedWorkingDirectory.isNotEmpty
? selectedWorkingDirectory
: fallbackWorkspacePath;
final pickedDirectory = await getDirectoryPath(
confirmButtonText: appText('选择项目', 'Select Project'),
initialDirectory: initialDirectory.isNotEmpty ? initialDirectory : null,
);
if (!mounted || pickedDirectory == null || pickedDirectory.trim().isEmpty) {
return;
}
await controller.saveAssistantSelectedWorkingDirectoryForSession(
sessionKey,
pickedDirectory,
);
}
@override
Widget build(BuildContext context) {
final palette = context.palette;
@ -400,15 +374,6 @@ class ComposerBarStateInternal extends State<ComposerBarInternal> {
final selectedSkills = widget.availableSkills
.where((skill) => widget.selectedSkillKeys.contains(skill.key))
.toList(growable: false);
final selectedWorkingDirectory = controller
.assistantSelectedWorkingDirectoryForSession(
controller.currentSessionKey,
)
.trim();
final selectedWorkingDirectoryLabel = controller
.assistantSelectedWorkingDirectoryDisplayLabelForSession(
controller.currentSessionKey,
);
final submitLabel = connected
? appText('提交', 'Submit')
: singleAgent
@ -505,57 +470,6 @@ class ComposerBarStateInternal extends State<ComposerBarInternal> {
),
const SizedBox(width: 4),
],
Tooltip(
message: selectedWorkingDirectory.isEmpty
? appText(
'选择 bridge 执行使用的项目目录workingDirectory',
'Choose the bridge project directory (workingDirectory).',
)
: selectedWorkingDirectory,
child: InkWell(
key: const Key('assistant-working-directory-button'),
onTap: () => unawaited(pickWorkingDirectoryInternal()),
borderRadius: BorderRadius.circular(AppRadius.chip),
child: Container(
padding: const EdgeInsets.symmetric(
horizontal: 10,
vertical: 6,
),
decoration: BoxDecoration(
color: palette.surfacePrimary,
borderRadius: BorderRadius.circular(AppRadius.chip),
border: Border.all(color: palette.strokeSoft),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(
Icons.folder_open_rounded,
size: 16,
color: selectedWorkingDirectory.isEmpty
? palette.textMuted
: palette.textPrimary,
),
const SizedBox(width: 6),
ConstrainedBox(
constraints: const BoxConstraints(maxWidth: 160),
child: Text(
selectedWorkingDirectoryLabel,
overflow: TextOverflow.ellipsis,
style: Theme.of(context).textTheme.labelMedium
?.copyWith(
color: selectedWorkingDirectory.isEmpty
? palette.textMuted
: palette.textPrimary,
),
),
),
],
),
),
),
),
const SizedBox(width: 4),
if (singleAgent) ...[
PopupMenuButton<SingleAgentProvider>(
key: const Key('assistant-single-agent-provider-button'),
@ -599,6 +513,36 @@ class ComposerBarStateInternal extends State<ComposerBarInternal> {
),
),
const SizedBox(width: 4),
] else ...[
PopupMenuButton<String>(
key: const Key('assistant-gateway-provider-button'),
tooltip: appText('Gateway Provider', 'Gateway Provider'),
onSelected: (_) {},
itemBuilder: (context) => const <PopupMenuEntry<String>>[
PopupMenuItem<String>(
value: kCanonicalGatewayProviderId,
key: Key('assistant-gateway-provider-menu-item-openclaw'),
child: Row(
children: [
Icon(Icons.cloud_outlined, size: 18),
SizedBox(width: 10),
Expanded(child: Text(kCanonicalGatewayProviderLabel)),
Icon(Icons.check_rounded, size: 18),
],
),
),
],
child: ComposerToolbarChipInternal(
icon: Icons.cloud_outlined,
tooltip: gatewayProviderTooltipInternal(),
showChevron: true,
padding: const EdgeInsets.symmetric(
horizontal: 10,
vertical: 6,
),
),
),
const SizedBox(width: 4),
],
if (widget.showModelControl) ...[
widget.modelOptions.isEmpty

View File

@ -50,6 +50,11 @@ String singleAgentProviderTooltipInternal(
'Bridge Provider: ${provider.label.trim().isEmpty ? appText('未配置', 'Unconfigured') : provider.label}',
);
String gatewayProviderTooltipInternal() => appText(
'Gateway Provider: $kCanonicalGatewayProviderLabel',
'Gateway Provider: $kCanonicalGatewayProviderLabel',
);
String modelTooltipInternal(String modelLabel) =>
appText('模型: $modelLabel', 'Model: $modelLabel');

View File

@ -347,8 +347,8 @@ class GoTaskServiceRequest {
ExternalCodeAgentAcpRoutingConfig _synthesizedRouting() {
final gatewayTarget = normalizedTarget;
final preferredGatewayTarget = switch (gatewayTarget) {
AssistantExecutionTarget.gateway => 'gateway',
_ => 'gateway',
AssistantExecutionTarget.gateway => kCanonicalGatewayProviderId,
_ => kCanonicalGatewayProviderId,
};
final explicitExecutionTarget = switch (gatewayTarget) {
AssistantExecutionTarget.singleAgent => 'single-agent',

View File

@ -333,6 +333,9 @@ const List<SingleAgentProvider> kPresetExternalAcpProviders =
SingleAgentProvider.gemini,
];
const String kCanonicalGatewayProviderId = 'openclaw';
const String kCanonicalGatewayProviderLabel = 'OpenClaw';
const List<SingleAgentProvider> kKnownSingleAgentProviders =
<SingleAgentProvider>[
SingleAgentProvider.codex,

View File

@ -755,7 +755,6 @@ class ThreadContextState {
required this.latestResolvedRuntimeModel,
this.selectedModelSource = ThreadSelectionSource.inherited,
this.selectedSkillsSource = ThreadSelectionSource.inherited,
this.selectedWorkingDirectory,
this.gatewayEntryState,
this.lastRemoteWorkingDirectory,
this.lastRemoteWorkspaceRefKind,
@ -772,7 +771,6 @@ class ThreadContextState {
final String latestResolvedRuntimeModel;
final ThreadSelectionSource selectedModelSource;
final ThreadSelectionSource selectedSkillsSource;
final String? selectedWorkingDirectory;
final String? gatewayEntryState;
final String? lastRemoteWorkingDirectory;
final WorkspaceRefKind? lastRemoteWorkspaceRefKind;
@ -789,8 +787,6 @@ class ThreadContextState {
String? latestResolvedRuntimeModel,
ThreadSelectionSource? selectedModelSource,
ThreadSelectionSource? selectedSkillsSource,
String? selectedWorkingDirectory,
bool clearSelectedWorkingDirectory = false,
String? gatewayEntryState,
bool clearGatewayEntryState = false,
String? lastRemoteWorkingDirectory,
@ -809,9 +805,6 @@ class ThreadContextState {
latestResolvedRuntimeModel ?? this.latestResolvedRuntimeModel,
selectedModelSource: selectedModelSource ?? this.selectedModelSource,
selectedSkillsSource: selectedSkillsSource ?? this.selectedSkillsSource,
selectedWorkingDirectory: clearSelectedWorkingDirectory
? null
: (selectedWorkingDirectory ?? this.selectedWorkingDirectory),
gatewayEntryState: clearGatewayEntryState
? null
: (gatewayEntryState ?? this.gatewayEntryState),
@ -838,7 +831,6 @@ class ThreadContextState {
'latestResolvedRuntimeModel': latestResolvedRuntimeModel,
'selectedModelSource': selectedModelSource.name,
'selectedSkillsSource': selectedSkillsSource.name,
'selectedWorkingDirectory': selectedWorkingDirectory,
'gatewayEntryState': gatewayEntryState,
'lastRemoteWorkingDirectory': lastRemoteWorkingDirectory,
'lastRemoteWorkspaceRefKind': lastRemoteWorkspaceRefKind?.name,
@ -904,7 +896,6 @@ class ThreadContextState {
selectedSkillsSource: ThreadSelectionSourceCopy.fromJsonValue(
json['selectedSkillsSource']?.toString(),
),
selectedWorkingDirectory: json['selectedWorkingDirectory']?.toString(),
gatewayEntryState: json['gatewayEntryState']?.toString(),
lastRemoteWorkingDirectory: json['lastRemoteWorkingDirectory']
?.toString(),
@ -1000,7 +991,6 @@ class TaskThread {
String? latestResolvedRuntimeModel,
double? lastRunAtMs,
String? lastResultCode,
String? selectedWorkingDirectory,
String? lastRemoteWorkingDirectory,
WorkspaceRefKind? lastRemoteWorkspaceRefKind,
double? lastArtifactSyncAtMs,
@ -1038,10 +1028,6 @@ class TaskThread {
messageViewMode ?? AssistantMessageViewMode.rendered,
latestResolvedRuntimeModel:
latestResolvedRuntimeModel?.trim() ?? '',
selectedWorkingDirectory:
selectedWorkingDirectory?.trim().isNotEmpty == true
? selectedWorkingDirectory!.trim()
: null,
gatewayEntryState: gatewayEntryState?.trim(),
lastRemoteWorkingDirectory:
lastRemoteWorkingDirectory?.trim().isNotEmpty == true
@ -1084,7 +1070,6 @@ class TaskThread {
List<String> get selectedSkillKeys => contextState.selectedSkillKeys;
String get assistantModelId => contextState.selectedModelId;
AssistantMessageViewMode get messageViewMode => contextState.messageViewMode;
String? get selectedWorkingDirectory => contextState.selectedWorkingDirectory;
String? get gatewayEntryState => contextState.gatewayEntryState;
String? get lastRemoteWorkingDirectory =>
contextState.lastRemoteWorkingDirectory;
@ -1125,8 +1110,6 @@ class TaskThread {
String? assistantModelId,
ThreadSelectionSource? assistantModelSource,
ThreadSelectionSource? selectedSkillsSource,
String? selectedWorkingDirectory,
bool clearSelectedWorkingDirectory = false,
String? gatewayEntryState,
bool clearGatewayEntryState = false,
String? latestResolvedRuntimeModel,
@ -1150,8 +1133,6 @@ class TaskThread {
selectedModelSource: assistantModelSource,
selectedSkillsSource: selectedSkillsSource,
latestResolvedRuntimeModel: latestResolvedRuntimeModel,
selectedWorkingDirectory: selectedWorkingDirectory,
clearSelectedWorkingDirectory: clearSelectedWorkingDirectory,
gatewayEntryState: gatewayEntryState,
clearGatewayEntryState: clearGatewayEntryState,
lastRemoteWorkingDirectory: lastRemoteWorkingDirectory,
@ -1268,7 +1249,6 @@ class TaskThread {
'latestResolvedRuntimeModel': json['latestResolvedRuntimeModel'],
'selectedModelSource': json['assistantModelSource'],
'selectedSkillsSource': json['selectedSkillsSource'],
'selectedWorkingDirectory': json['selectedWorkingDirectory'],
'gatewayEntryState': json['gatewayEntryState'],
'lastRemoteWorkingDirectory': json['lastRemoteWorkingDirectory'],
'lastRemoteWorkspaceRefKind': json['lastRemoteWorkspaceRefKind'],

View File

@ -394,41 +394,26 @@ void main() {
);
});
group('selected working directory', () {
group('thread working directory', () {
test(
'persists thread project directory without changing local workspace binding',
'uses the unique thread workspace as the only workingDirectory source',
() async {
final controller = AppController();
addTearDown(controller.dispose);
const sessionKey = 'draft:project-dir';
const sessionKey = 'draft:thread-working-directory';
controller.initializeAssistantThreadContext(
sessionKey,
executionTarget: AssistantExecutionTarget.singleAgent,
);
final originalWorkspacePath = controller
.assistantWorkspacePathForSession(sessionKey);
await controller.saveAssistantSelectedWorkingDirectoryForSession(
sessionKey,
'/tmp/project-alpha',
);
final record = controller.requireTaskThreadForSessionInternal(
sessionKey,
);
expect(record.selectedWorkingDirectory, '/tmp/project-alpha');
expect(
controller.assistantSelectedWorkingDirectoryForSession(sessionKey),
'/tmp/project-alpha',
controller.assistantWorkingDirectoryForSessionInternal(sessionKey),
record.workspaceBinding.workspacePath,
);
expect(
controller.assistantSelectedWorkingDirectoryDisplayLabelForSession(
sessionKey,
),
'project-alpha',
);
expect(record.workspaceBinding.workspacePath, originalWorkspacePath);
},
);
});

View File

@ -1,7 +1,6 @@
import 'dart:io';
import 'package:flutter_test/flutter_test.dart';
import 'package:xworkmate/app/app_controller_desktop_core.dart';
import 'package:xworkmate/app/app_controller_desktop_runtime_helpers.dart';
import 'package:xworkmate/app/app_controller_desktop_thread_actions.dart';
import 'package:xworkmate/app/app_controller_desktop_thread_sessions.dart';
import 'package:xworkmate/app/app_controller_desktop_workspace_execution.dart';
@ -13,24 +12,16 @@ void main() {
group('thread workingDirectory dispatch', () {
test(
'single-agent requests reuse the thread selected workingDirectory',
'single-agent requests reuse the unique thread workspace workingDirectory',
() async {
final client = _CapturingGoTaskServiceClient();
final projectDir = Directory.systemTemp.createTempSync(
'xworkmate-project-alpha-',
);
final controller = AppController(
goTaskServiceClient: client,
availableSingleAgentProvidersOverride: const <SingleAgentProvider>[
SingleAgentProvider.codex,
],
);
addTearDown(() async {
controller.dispose();
if (projectDir.existsSync()) {
await projectDir.delete(recursive: true);
}
});
addTearDown(controller.dispose);
const sessionKey = 'draft:single-agent-working-directory';
controller.initializeAssistantThreadContext(
@ -38,10 +29,10 @@ void main() {
executionTarget: AssistantExecutionTarget.singleAgent,
);
await controller.switchSession(sessionKey);
await controller.saveAssistantSelectedWorkingDirectoryForSession(
sessionKey,
projectDir.path,
);
final expectedThreadWorkingDirectory = controller
.requireTaskThreadForSessionInternal(sessionKey)
.workspaceBinding
.workspacePath;
await controller.sendChatMessage('first turn');
await controller.sendChatMessage('second turn');
@ -57,60 +48,51 @@ void main() {
]);
expect(
client.requests.map((item) => item.workingDirectory).toList(),
<String>[projectDir.path, projectDir.path],
);
},
);
test(
'gateway threads persist their selected workingDirectory separately from workspace binding',
() async {
final projectDir = Directory.systemTemp.createTempSync(
'xworkmate-project-beta-',
);
final controller = AppController(
availableSingleAgentProvidersOverride: const <SingleAgentProvider>[
SingleAgentProvider.codex,
<String>[
expectedThreadWorkingDirectory,
expectedThreadWorkingDirectory,
],
);
addTearDown(() async {
controller.dispose();
if (projectDir.existsSync()) {
await projectDir.delete(recursive: true);
}
});
controller.appUiStateInternal = controller.appUiState.copyWith(
savedGatewayTargets: const <String>['gateway'],
);
controller.lastObservedSettingsSnapshotInternal =
controller.settingsController.snapshotInternal;
const sessionKey = 'draft:gateway-working-directory';
controller.initializeAssistantThreadContext(
sessionKey,
executionTarget: AssistantExecutionTarget.gateway,
);
await controller.switchSession(sessionKey);
await controller.setAssistantExecutionTarget(
AssistantExecutionTarget.gateway,
);
await controller.saveAssistantSelectedWorkingDirectoryForSession(
sessionKey,
projectDir.path,
);
final record = controller.requireTaskThreadForSessionInternal(
sessionKey,
);
expect(
controller.assistantExecutionTargetForSession(sessionKey),
AssistantExecutionTarget.gateway,
);
expect(record.selectedWorkingDirectory, projectDir.path);
expect(record.workspaceBinding.workspacePath, isNot(projectDir.path));
},
);
test('each task thread keeps an independent workingDirectory', () async {
final controller = AppController(
availableSingleAgentProvidersOverride: const <SingleAgentProvider>[
SingleAgentProvider.codex,
],
);
addTearDown(controller.dispose);
const sessionKey = 'draft:thread-working-directory-a';
const otherSessionKey = 'draft:thread-working-directory-b';
controller.initializeAssistantThreadContext(
sessionKey,
executionTarget: AssistantExecutionTarget.singleAgent,
);
controller.initializeAssistantThreadContext(
otherSessionKey,
executionTarget: AssistantExecutionTarget.singleAgent,
);
final recordA = controller.requireTaskThreadForSessionInternal(
sessionKey,
);
final recordB = controller.requireTaskThreadForSessionInternal(
otherSessionKey,
);
expect(
controller.assistantWorkingDirectoryForSessionInternal(sessionKey),
recordA.workspaceBinding.workspacePath,
);
expect(
controller.assistantWorkingDirectoryForSessionInternal(otherSessionKey),
recordB.workspaceBinding.workspacePath,
);
expect(
recordA.workspaceBinding.workspacePath,
isNot(recordB.workspaceBinding.workspacePath),
);
});
});
}

View File

@ -18,7 +18,7 @@ void main() {
TestWidgetsFlutterBinding.ensureInitialized();
testWidgets(
'mode picker keeps single-agent and gateway visible while project selector stays available',
'mode picker keeps single-agent and gateway visible while thread-only provider controls stay available',
(tester) async {
final root = Directory.systemTemp.createTempSync(
'xworkmate-picker-widget-test-',
@ -57,10 +57,6 @@ void main() {
);
controller.lastObservedSettingsSnapshotInternal =
controller.settingsController.snapshotInternal;
await controller.saveAssistantSelectedWorkingDirectoryForSession(
controller.currentSessionKey,
'/tmp/project-alpha',
);
await tester.pumpWidget(
MaterialApp(
@ -122,19 +118,112 @@ void main() {
]);
expect(
find.byKey(const Key('assistant-working-directory-button')),
findsNothing,
);
expect(
find.byKey(const Key('assistant-single-agent-provider-button')),
findsOneWidget,
);
expect(find.text('project-alpha'), findsOneWidget);
expect(
find.byKey(const Key('assistant-collaboration-toggle')),
findsNothing,
);
await tester.pumpWidget(const SizedBox.shrink());
controller.dispose();
await tester.pump();
},
);
testWidgets('gateway mode shows the canonical OpenClaw provider selector', (
tester,
) async {
final root = Directory.systemTemp.createTempSync(
'xworkmate-picker-widget-gateway-test-',
);
final store = SecureConfigStore(
enableSecureStorage: false,
appDataRootPathResolver: () async => '${root.path}/settings.sqlite3',
secretRootPathResolver: () async => root.path,
supportRootPathResolver: () async => root.path,
);
final controller = AppController(
store: store,
desktopPlatformService: UnsupportedDesktopPlatformService(),
skillDirectoryAccessService: _FakeSkillDirectoryAccessService(root.path),
goTaskServiceClient: const _FakeGoTaskServiceClient(),
singleAgentSharedSkillScanRootOverrides: const <String>[],
availableSingleAgentProvidersOverride: const <SingleAgentProvider>[
SingleAgentProvider.codex,
],
);
final inputController = TextEditingController();
final focusNode = FocusNode();
addTearDown(() async {
controller.dispose();
inputController.dispose();
focusNode.dispose();
if (root.existsSync()) {
await root.delete(recursive: true);
}
});
controller.appUiStateInternal = controller.appUiState.copyWith(
savedGatewayTargets: const <String>['gateway'],
);
controller.lastObservedSettingsSnapshotInternal =
controller.settingsController.snapshotInternal;
controller.initializeAssistantThreadContext(
controller.currentSessionKey,
executionTarget: AssistantExecutionTarget.gateway,
);
await tester.pumpWidget(
MaterialApp(
theme: AppTheme.light(platform: TargetPlatform.macOS),
home: Scaffold(
body: ComposerBarInternal(
controller: controller,
inputController: inputController,
focusNode: focusNode,
thinkingLabel: 'Normal',
showModelControl: false,
modelLabel: '',
modelOptions: const <String>[],
attachments: const <ComposerAttachmentInternal>[],
availableSkills: const <ComposerSkillOptionInternal>[],
selectedSkillKeys: const <String>[],
onRemoveAttachment: (_) {},
onToggleSkill: (_) {},
onThinkingChanged: (_) {},
onModelChanged: (_) async {},
onOpenGateway: () {},
onOpenAiGatewaySettings: () {},
onReconnectGateway: () async {},
onPickAttachments: () {},
onAddAttachment: (_) {},
onPasteImageAttachment: () async => null,
onContentHeightChanged: (_) {},
onInputHeightChanged: (_) {},
onSend: () async {},
),
),
),
);
await tester.pump();
await tester.pump(const Duration(milliseconds: 200));
expect(
find.byKey(const Key('assistant-single-agent-provider-button')),
findsNothing,
);
expect(
find.byKey(const Key('assistant-gateway-provider-button')),
findsOneWidget,
);
await tester.pumpWidget(const SizedBox.shrink());
await tester.pump();
});
}
class _FakeSkillDirectoryAccessService implements SkillDirectoryAccessService {

View File

@ -3,7 +3,6 @@ import 'dart:io';
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:xworkmate/app/app_controller_desktop_core.dart';
import 'package:xworkmate/app/app_controller_desktop_workspace_execution.dart';
import 'package:xworkmate/features/assistant/assistant_page_composer_bar.dart';
import 'package:xworkmate/features/assistant/assistant_page_composer_clipboard.dart';
import 'package:xworkmate/features/assistant/assistant_page_composer_skill_models.dart';
@ -17,7 +16,7 @@ import 'package:xworkmate/theme/app_theme.dart';
void main() {
TestWidgetsFlutterBinding.ensureInitialized();
testWidgets('renders composer with project workingDirectory chip', (
testWidgets('renders composer with thread provider controls only', (
tester,
) async {
await tester.binding.setSurfaceSize(const Size(1400, 320));
@ -61,10 +60,6 @@ void main() {
);
controller.lastObservedSettingsSnapshotInternal =
controller.settingsController.snapshotInternal;
await controller.saveAssistantSelectedWorkingDirectoryForSession(
controller.currentSessionKey,
'${root.path}/project-alpha',
);
await tester.pumpWidget(
MaterialApp(

Binary file not shown.

Before

Width:  |  Height:  |  Size: 7.9 KiB

After

Width:  |  Height:  |  Size: 6.9 KiB