diff --git a/lib/app/app_controller_desktop_external_acp_routing.dart b/lib/app/app_controller_desktop_external_acp_routing.dart index 94f735a8..f5f198bf 100644 --- a/lib/app/app_controller_desktop_external_acp_routing.dart +++ b/lib/app/app_controller_desktop_external_acp_routing.dart @@ -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) diff --git a/lib/app/app_controller_desktop_runtime_coordination_impl.dart b/lib/app/app_controller_desktop_runtime_coordination_impl.dart index da21fd4c..8359ed0e 100644 --- a/lib/app/app_controller_desktop_runtime_coordination_impl.dart +++ b/lib/app/app_controller_desktop_runtime_coordination_impl.dart @@ -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; } diff --git a/lib/app/app_controller_desktop_skill_permissions.dart b/lib/app/app_controller_desktop_skill_permissions.dart index ea0dd13a..08dc23b1 100644 --- a/lib/app/app_controller_desktop_skill_permissions.dart +++ b/lib/app/app_controller_desktop_skill_permissions.dart @@ -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, diff --git a/lib/app/app_controller_desktop_thread_actions.dart b/lib/app/app_controller_desktop_thread_actions.dart index 2d2537dd..aa9b5e36 100644 --- a/lib/app/app_controller_desktop_thread_actions.dart +++ b/lib/app/app_controller_desktop_thread_actions.dart @@ -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( diff --git a/lib/app/app_controller_desktop_thread_binding.dart b/lib/app/app_controller_desktop_thread_binding.dart index c56fcfb8..78d6c0bb 100644 --- a/lib/app/app_controller_desktop_thread_binding.dart +++ b/lib/app/app_controller_desktop_thread_binding.dart @@ -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 diff --git a/lib/app/app_controller_desktop_thread_sessions.dart b/lib/app/app_controller_desktop_thread_sessions.dart index d78c5984..80e13b04 100644 --- a/lib/app/app_controller_desktop_thread_sessions.dart +++ b/lib/app/app_controller_desktop_thread_sessions.dart @@ -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 loadAssistantArtifactSnapshot({ String? sessionKey, }) { diff --git a/lib/app/app_controller_desktop_thread_storage.dart b/lib/app/app_controller_desktop_thread_storage.dart index 5a147ec9..39ce1164 100644 --- a/lib/app/app_controller_desktop_thread_storage.dart +++ b/lib/app/app_controller_desktop_thread_storage.dart @@ -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 diff --git a/lib/app/app_controller_desktop_workspace_execution.dart b/lib/app/app_controller_desktop_workspace_execution.dart index 1f105527..c86efa47 100644 --- a/lib/app/app_controller_desktop_workspace_execution.dart +++ b/lib/app/app_controller_desktop_workspace_execution.dart @@ -372,32 +372,6 @@ extension AppControllerDesktopWorkspaceExecution on AppController { notifyIfActiveInternal(); } - Future 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 refreshSingleAgentSkillsForSession(String sessionKey) async { final normalizedSessionKey = normalizedAssistantSessionKeyInternal( sessionKey, diff --git a/lib/features/assistant/assistant_page_composer_bar.dart b/lib/features/assistant/assistant_page_composer_bar.dart index f11fec2e..5aa4e37f 100644 --- a/lib/features/assistant/assistant_page_composer_bar.dart +++ b/lib/features/assistant/assistant_page_composer_bar.dart @@ -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 { }); } - Future 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 { 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 { ), 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( key: const Key('assistant-single-agent-provider-button'), @@ -599,6 +513,36 @@ class ComposerBarStateInternal extends State { ), ), const SizedBox(width: 4), + ] else ...[ + PopupMenuButton( + key: const Key('assistant-gateway-provider-button'), + tooltip: appText('Gateway Provider', 'Gateway Provider'), + onSelected: (_) {}, + itemBuilder: (context) => const >[ + PopupMenuItem( + 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 diff --git a/lib/features/assistant/assistant_page_tooltip_labels.dart b/lib/features/assistant/assistant_page_tooltip_labels.dart index d2649475..e6f81839 100644 --- a/lib/features/assistant/assistant_page_tooltip_labels.dart +++ b/lib/features/assistant/assistant_page_tooltip_labels.dart @@ -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'); diff --git a/lib/runtime/go_task_service_client.dart b/lib/runtime/go_task_service_client.dart index 25db6d42..ac903772 100644 --- a/lib/runtime/go_task_service_client.dart +++ b/lib/runtime/go_task_service_client.dart @@ -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', diff --git a/lib/runtime/runtime_models_connection.dart b/lib/runtime/runtime_models_connection.dart index 56feb6f0..a68e84a3 100644 --- a/lib/runtime/runtime_models_connection.dart +++ b/lib/runtime/runtime_models_connection.dart @@ -333,6 +333,9 @@ const List kPresetExternalAcpProviders = SingleAgentProvider.gemini, ]; +const String kCanonicalGatewayProviderId = 'openclaw'; +const String kCanonicalGatewayProviderLabel = 'OpenClaw'; + const List kKnownSingleAgentProviders = [ SingleAgentProvider.codex, diff --git a/lib/runtime/runtime_models_runtime_payloads.dart b/lib/runtime/runtime_models_runtime_payloads.dart index 1c9f8c6f..6285a00f 100644 --- a/lib/runtime/runtime_models_runtime_payloads.dart +++ b/lib/runtime/runtime_models_runtime_payloads.dart @@ -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 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'], diff --git a/test/app_controller_desktop_thread_binding_test.dart b/test/app_controller_desktop_thread_binding_test.dart index 7d57cf8a..3345883c 100644 --- a/test/app_controller_desktop_thread_binding_test.dart +++ b/test/app_controller_desktop_thread_binding_test.dart @@ -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); }, ); }); diff --git a/test/app_controller_desktop_working_directory_dispatch_test.dart b/test/app_controller_desktop_working_directory_dispatch_test.dart index 91da59e3..d9ba521c 100644 --- a/test/app_controller_desktop_working_directory_dispatch_test.dart +++ b/test/app_controller_desktop_working_directory_dispatch_test.dart @@ -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.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(), - [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.codex, + [ + expectedThreadWorkingDirectory, + expectedThreadWorkingDirectory, ], ); - addTearDown(() async { - controller.dispose(); - if (projectDir.existsSync()) { - await projectDir.delete(recursive: true); - } - }); - - controller.appUiStateInternal = controller.appUiState.copyWith( - savedGatewayTargets: const ['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.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), + ); + }); }); } diff --git a/test/assistant_execution_target_picker_widget_test.dart b/test/assistant_execution_target_picker_widget_test.dart index effd45a9..47cbf496 100644 --- a/test/assistant_execution_target_picker_widget_test.dart +++ b/test/assistant_execution_target_picker_widget_test.dart @@ -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 [], + availableSingleAgentProvidersOverride: const [ + 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 ['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 [], + attachments: const [], + availableSkills: const [], + selectedSkillKeys: const [], + 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 { diff --git a/test/features/assistant/assistant_page_composer_golden_test.dart b/test/features/assistant/assistant_page_composer_golden_test.dart index 40c6bac4..1b818e5d 100644 --- a/test/features/assistant/assistant_page_composer_golden_test.dart +++ b/test/features/assistant/assistant_page_composer_golden_test.dart @@ -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( diff --git a/test/features/assistant/goldens/assistant_page_composer_working_directory.png b/test/features/assistant/goldens/assistant_page_composer_working_directory.png index 2d4b37fc..a76ab0de 100644 Binary files a/test/features/assistant/goldens/assistant_page_composer_working_directory.png and b/test/features/assistant/goldens/assistant_page_composer_working_directory.png differ