diff --git a/lib/app/app_controller_desktop.dart b/lib/app/app_controller_desktop.dart index 8964ad67..8e2c07b6 100644 --- a/lib/app/app_controller_desktop.dart +++ b/lib/app/app_controller_desktop.dart @@ -1272,11 +1272,75 @@ class AppController extends ChangeNotifier { final target = assistantExecutionTargetForSession(normalizedSessionKey); return switch (target) { AssistantExecutionTarget.remote => settings.remoteProjectRoot.trim(), - AssistantExecutionTarget.local || - AssistantExecutionTarget.singleAgent => settings.workspacePath.trim(), + AssistantExecutionTarget.local || AssistantExecutionTarget.singleAgent => + _defaultLocalWorkspaceRefForSession(normalizedSessionKey), }; } + String _defaultLocalWorkspaceRefForSession(String sessionKey) { + final normalizedSessionKey = _normalizedAssistantSessionKey(sessionKey); + final baseWorkspace = settings.workspacePath.trim(); + if (baseWorkspace.isEmpty || normalizedSessionKey == 'main') { + return baseWorkspace; + } + final threadWorkspace = + '${_trimTrailingPathSeparator(baseWorkspace)}/.xworkmate/threads/${_threadWorkspaceDirectoryName(normalizedSessionKey)}'; + _ensureLocalWorkspaceDirectory(threadWorkspace); + return threadWorkspace; + } + + String _threadWorkspaceDirectoryName(String sessionKey) { + final normalizedSessionKey = _normalizedAssistantSessionKey(sessionKey); + final sanitized = normalizedSessionKey + .replaceAll(RegExp(r'[^A-Za-z0-9._-]+'), '-') + .replaceAll(RegExp(r'-{2,}'), '-') + .replaceAll(RegExp(r'^[-.]+|[-.]+$'), ''); + return sanitized.isEmpty ? 'thread' : sanitized; + } + + String _trimTrailingPathSeparator(String path) { + if (path.endsWith('/') && path.length > 1) { + return path.substring(0, path.length - 1); + } + return path; + } + + void _ensureLocalWorkspaceDirectory(String path) { + final normalizedPath = path.trim(); + if (normalizedPath.isEmpty) { + return; + } + try { + Directory(normalizedPath).createSync(recursive: true); + } catch (_) { + // Best effort only. The caller can still decide whether to use fallback behavior. + } + } + + bool _usesLegacySharedWorkspaceRef( + String sessionKey, { + AssistantExecutionTarget? executionTarget, + String? workspaceRef, + WorkspaceRefKind? workspaceRefKind, + }) { + final normalizedSessionKey = _normalizedAssistantSessionKey(sessionKey); + if (normalizedSessionKey == 'main') { + return false; + } + final resolvedTarget = + executionTarget ?? + assistantExecutionTargetForSession(normalizedSessionKey); + if (resolvedTarget == AssistantExecutionTarget.remote) { + return false; + } + final normalizedRef = workspaceRef?.trim() ?? ''; + if (normalizedRef.isEmpty) { + return false; + } + return workspaceRefKind == WorkspaceRefKind.localPath && + normalizedRef == settings.workspacePath.trim(); + } + WorkspaceRefKind _defaultWorkspaceRefKindForTarget( AssistantExecutionTarget target, ) { @@ -1305,7 +1369,13 @@ class AppController extends ChangeNotifier { final existingWorkspaceRef = existing?.workspaceRef.trim() ?? ''; if (existing != null && existingWorkspaceRef.isNotEmpty && - existing.workspaceRefKind == nextWorkspaceRefKind) { + existing.workspaceRefKind == nextWorkspaceRefKind && + !_usesLegacySharedWorkspaceRef( + normalizedSessionKey, + executionTarget: resolvedTarget, + workspaceRef: existingWorkspaceRef, + workspaceRefKind: existing.workspaceRefKind, + )) { return; } if (existing != null && @@ -2201,11 +2271,7 @@ class AppController extends ChangeNotifier { singleAgentProvider: singleAgentProvider ?? singleAgentProviderForSession(currentSessionKey), - workspaceRef: switch (resolvedTarget) { - AssistantExecutionTarget.remote => settings.remoteProjectRoot.trim(), - AssistantExecutionTarget.local || - AssistantExecutionTarget.singleAgent => settings.workspacePath.trim(), - }, + workspaceRef: _defaultWorkspaceRefForSession(normalizedSessionKey), workspaceRefKind: _defaultWorkspaceRefKindForTarget(resolvedTarget), updatedAtMs: DateTime.now().millisecondsSinceEpoch.toDouble(), ); @@ -3379,15 +3445,20 @@ class AppController extends ChangeNotifier { SettingsSnapshot _sanitizeFeatureFlagSettings(SettingsSnapshot snapshot) { final features = featuresFor(_hostUiFeaturePlatform); - final allowedNavigation = normalizeAssistantNavigationDestinations( - snapshot.assistantNavigationDestinations, - ).where((entry) { - final destination = entry.destination; - if (destination != null) { - return features.allowedDestinations.contains(destination); - } - return features.allowedDestinations.contains(WorkspaceDestination.settings); - }).toList(growable: false); + final allowedNavigation = + normalizeAssistantNavigationDestinations( + snapshot.assistantNavigationDestinations, + ) + .where((entry) { + final destination = entry.destination; + if (destination != null) { + return features.allowedDestinations.contains(destination); + } + return features.allowedDestinations.contains( + WorkspaceDestination.settings, + ); + }) + .toList(growable: false); final sanitizedExecutionTarget = features.sanitizeExecutionTarget( snapshot.assistantExecutionTarget, ); @@ -4522,6 +4593,15 @@ class AppController extends ChangeNotifier { continue; } final titleFromSettings = assistantCustomTaskTitle(sessionKey); + final shouldMigrateWorkspaceRef = + record.workspaceRef.trim().isEmpty || + _usesLegacySharedWorkspaceRef( + sessionKey, + executionTarget: + record.executionTarget ?? settings.assistantExecutionTarget, + workspaceRef: record.workspaceRef, + workspaceRefKind: record.workspaceRefKind, + ); final normalizedRecord = record.copyWith( sessionKey: sessionKey, title: titleFromSettings.isEmpty @@ -4547,10 +4627,10 @@ class AppController extends ChangeNotifier { record.executionTarget ?? settings.assistantExecutionTarget, ) : record.gatewayEntryState, - workspaceRef: record.workspaceRef.trim().isEmpty + workspaceRef: shouldMigrateWorkspaceRef ? _defaultWorkspaceRefForSession(sessionKey) : record.workspaceRef.trim(), - workspaceRefKind: record.workspaceRef.trim().isEmpty + workspaceRefKind: shouldMigrateWorkspaceRef ? _defaultWorkspaceRefKindForTarget( record.executionTarget ?? settings.assistantExecutionTarget, ) @@ -4824,7 +4904,10 @@ class AppController extends ChangeNotifier { gatewayEntryState ?? existing?.gatewayEntryState ?? _gatewayEntryStateForTarget(nextExecutionTarget), - workspaceRef: workspaceRef ?? existing?.workspaceRef ?? '', + workspaceRef: + workspaceRef ?? + existing?.workspaceRef ?? + _defaultWorkspaceRefForSession(normalizedSessionKey), workspaceRefKind: workspaceRefKind ?? existing?.workspaceRefKind ?? diff --git a/test/runtime/app_controller_ai_gateway_chat_suite.dart b/test/runtime/app_controller_ai_gateway_chat_suite.dart index 9edc355e..ccb896ff 100644 --- a/test/runtime/app_controller_ai_gateway_chat_suite.dart +++ b/test/runtime/app_controller_ai_gateway_chat_suite.dart @@ -630,6 +630,88 @@ void main() { ); }, ); + + test( + 'AppController uses an isolated workspace for draft Single Agent threads', + () async { + SharedPreferences.setMockInitialValues({}); + final tempDirectory = await Directory.systemTemp.createTemp( + 'xworkmate-single-agent-isolated-thread-cwd-', + ); + final defaultWorkspace = Directory( + '${tempDirectory.path}/default-workspace', + ); + await defaultWorkspace.create(recursive: true); + addTearDown(() async { + if (await tempDirectory.exists()) { + await tempDirectory.delete(recursive: true); + } + }); + + final store = SecureConfigStore( + enableSecureStorage: false, + databasePathResolver: () async => '${tempDirectory.path}/settings.db', + fallbackDirectoryPathResolver: () async => tempDirectory.path, + ); + await store.initialize(); + await store.saveSettingsSnapshot( + SettingsSnapshot.defaults().copyWith( + workspacePath: defaultWorkspace.path, + assistantExecutionTarget: AssistantExecutionTarget.singleAgent, + ), + ); + + final runner = _FakeSingleAgentRunner( + resolvedProvider: SingleAgentProvider.codex, + result: const SingleAgentRunResult( + provider: SingleAgentProvider.codex, + output: 'THREAD_OK', + success: true, + errorMessage: '', + shouldFallbackToAiChat: false, + ), + ); + final controller = AppController( + store: store, + availableSingleAgentProvidersOverride: const [ + SingleAgentProvider.codex, + ], + runtimeCoordinator: RuntimeCoordinator( + gateway: _FakeGatewayRuntime(store: store), + codex: _FakeCodexRuntime(), + ), + singleAgentRunner: runner, + ); + addTearDown(controller.dispose); + + await _waitFor(() => !controller.initializing); + controller.initializeAssistantThreadContext( + 'draft:artifact-thread', + title: 'Artifact Thread', + executionTarget: AssistantExecutionTarget.singleAgent, + ); + await controller.switchSession('draft:artifact-thread'); + await controller.sendChatMessage('检查当前线程目录', thinking: 'low'); + + const expectedWorkspaceSuffix = + '.xworkmate/threads/draft-artifact-thread'; + expect(runner.runCalls, 1); + expect( + runner.lastRequest?.workingDirectory, + '${defaultWorkspace.path}/$expectedWorkspaceSuffix', + ); + expect( + controller.assistantWorkspaceRefForSession('draft:artifact-thread'), + '${defaultWorkspace.path}/$expectedWorkspaceSuffix', + ); + expect( + Directory( + '${defaultWorkspace.path}/$expectedWorkspaceSuffix', + ).existsSync(), + isTrue, + ); + }, + ); } class _FakeGatewayRuntime extends GatewayRuntime { diff --git a/test/runtime/app_controller_assistant_workspace_ref_test.dart b/test/runtime/app_controller_assistant_workspace_ref_test.dart index 85f8335e..761ef574 100644 --- a/test/runtime/app_controller_assistant_workspace_ref_test.dart +++ b/test/runtime/app_controller_assistant_workspace_ref_test.dart @@ -65,10 +65,14 @@ void main() { executionTarget: AssistantExecutionTarget.singleAgent, ); await controller.switchSession(draftKey); - expect( - controller.assistantWorkspaceRefForSession(draftKey), - controller.settings.workspacePath, + final draftWorkspaceRef = controller.assistantWorkspaceRefForSession( + draftKey, ); + expect( + draftWorkspaceRef, + startsWith('${controller.settings.workspacePath}/.xworkmate/threads/'), + ); + expect(draftWorkspaceRef, isNot(controller.settings.workspacePath)); expect( controller.assistantWorkspaceRefKindForSession(draftKey), WorkspaceRefKind.localPath, @@ -76,6 +80,67 @@ void main() { }, ); + test( + 'AppController migrates draft single-agent threads off the shared workspace root', + () async { + SharedPreferences.setMockInitialValues({}); + final tempDirectory = await Directory.systemTemp.createTemp( + 'xworkmate-thread-workspace-migrate-', + ); + final workspaceRoot = Directory('${tempDirectory.path}/workspace'); + await workspaceRoot.create(recursive: true); + addTearDown(() async { + if (await tempDirectory.exists()) { + try { + await tempDirectory.delete(recursive: true); + } catch (_) {} + } + }); + final store = SecureConfigStore( + enableSecureStorage: false, + databasePathResolver: () async => '${tempDirectory.path}/settings.db', + fallbackDirectoryPathResolver: () async => tempDirectory.path, + ); + await store.initialize(); + await store.saveSettingsSnapshot( + SettingsSnapshot.defaults().copyWith(workspacePath: workspaceRoot.path), + ); + await store.saveAssistantThreadRecords([ + AssistantThreadRecord( + sessionKey: 'draft:artifact-thread', + messages: const [], + updatedAtMs: 1, + title: 'Artifact Thread', + archived: false, + executionTarget: AssistantExecutionTarget.singleAgent, + messageViewMode: AssistantMessageViewMode.rendered, + workspaceRef: workspaceRoot.path, + workspaceRefKind: WorkspaceRefKind.localPath, + ), + ]); + + final controller = AppController(store: store); + addTearDown(controller.dispose); + + final deadline = DateTime.now().add(const Duration(seconds: 5)); + while (controller.initializing) { + if (DateTime.now().isAfter(deadline)) { + fail('controller did not initialize in time'); + } + await Future.delayed(const Duration(milliseconds: 20)); + } + + final migratedWorkspace = controller.assistantWorkspaceRefForSession( + 'draft:artifact-thread', + ); + expect( + migratedWorkspace, + '${workspaceRoot.path}/.xworkmate/threads/draft-artifact-thread', + ); + expect(Directory(migratedWorkspace).existsSync(), isTrue); + }, + ); + test( 'AppController preserves recorded workspace refs when switching threads', () async {