From 49d3036090909eb77ee973eca7646f0ba7f8f41d Mon Sep 17 00:00:00 2001 From: Haitao Pan Date: Sat, 11 Apr 2026 18:25:26 +0800 Subject: [PATCH] Remove project selection from task threads --- ...ntroller_desktop_external_acp_routing.dart | 4 +- ...ler_desktop_runtime_coordination_impl.dart | 13 +- ..._controller_desktop_skill_permissions.dart | 5 - ...app_controller_desktop_thread_actions.dart | 12 +- ...app_controller_desktop_thread_binding.dart | 38 +++--- ...pp_controller_desktop_thread_sessions.dart | 29 ----- ...app_controller_desktop_thread_storage.dart | 6 +- ...ontroller_desktop_workspace_execution.dart | 26 ---- .../assistant_page_composer_bar.dart | 116 +++++------------- .../assistant_page_tooltip_labels.dart | 5 + lib/runtime/go_task_service_client.dart | 4 +- lib/runtime/runtime_models_connection.dart | 3 + .../runtime_models_runtime_payloads.dart | 20 --- ...ontroller_desktop_thread_binding_test.dart | 27 +--- ...sktop_working_directory_dispatch_test.dart | 114 ++++++++--------- ...t_execution_target_picker_widget_test.dart | 103 ++++++++++++++-- .../assistant_page_composer_golden_test.dart | 7 +- ...istant_page_composer_working_directory.png | Bin 8101 -> 7037 bytes 18 files changed, 234 insertions(+), 298 deletions(-) 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 2d4b37fcac946619f5064fcaf762a7cf79f59327..a76ab0deabd3e6aa6f98531b9b532e3e5eb92427 100644 GIT binary patch literal 7037 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 U34#NEuz^5U=C)>}3%)=88&zy}uK)l5 literal 8101 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 P9zd36)}~dzdH?ZWb7tXL