diff --git a/lib/app/app_controller_desktop_single_agent.dart b/lib/app/app_controller_desktop_single_agent.dart index fce21617..c4cc076c 100644 --- a/lib/app/app_controller_desktop_single_agent.dart +++ b/lib/app/app_controller_desktop_single_agent.dart @@ -155,6 +155,21 @@ extension AppControllerDesktopSingleAgent on AppController { configuredCodexCliPath: configuredCodexCliPath, ), ); + final resolvedWorkingDirectory = result.resolvedWorkingDirectory.trim(); + final resolvedWorkspaceRefKind = result.resolvedWorkspaceRefKind; + if (resolvedWorkingDirectory.isNotEmpty && + resolvedWorkspaceRefKind == WorkspaceRefKind.remotePath && + (assistantWorkspaceRefForSession(sessionKey) != + resolvedWorkingDirectory || + assistantWorkspaceRefKindForSession(sessionKey) != + resolvedWorkspaceRefKind)) { + upsertAssistantThreadRecordInternal( + sessionKey, + workspaceRef: resolvedWorkingDirectory, + workspaceRefKind: resolvedWorkspaceRefKind, + updatedAtMs: DateTime.now().millisecondsSinceEpoch.toDouble(), + ); + } final resolvedRuntimeModel = result.resolvedModel.trim(); if (resolvedRuntimeModel.isNotEmpty) { singleAgentRuntimeModelBySessionInternal[sessionKey] = diff --git a/lib/widgets/assistant_artifact_sidebar.dart b/lib/widgets/assistant_artifact_sidebar.dart index 19b6d220..715b5fda 100644 --- a/lib/widgets/assistant_artifact_sidebar.dart +++ b/lib/widgets/assistant_artifact_sidebar.dart @@ -18,7 +18,7 @@ typedef AssistantArtifactSnapshotLoader = typedef AssistantArtifactPreviewLoader = Future Function(AssistantArtifactEntry entry); -enum AssistantArtifactSidebarTab { results, files, changes, preview } +enum AssistantArtifactSidebarTab { files, preview } class AssistantArtifactSidebar extends StatefulWidget { const AssistantArtifactSidebar({ @@ -46,7 +46,7 @@ class AssistantArtifactSidebar extends StatefulWidget { } class _AssistantArtifactSidebarState extends State { - AssistantArtifactSidebarTab _activeTab = AssistantArtifactSidebarTab.results; + AssistantArtifactSidebarTab _activeTab = AssistantArtifactSidebarTab.files; AssistantArtifactSnapshot? _snapshot; AssistantArtifactEntry? _selectedEntry; AssistantArtifactPreview _preview = const AssistantArtifactPreview.empty(); @@ -66,7 +66,7 @@ class _AssistantArtifactSidebarState extends State { if (oldWidget.sessionKey != widget.sessionKey || oldWidget.workspaceRef != widget.workspaceRef || oldWidget.workspaceRefKind != widget.workspaceRefKind) { - _activeTab = AssistantArtifactSidebarTab.results; + _activeTab = AssistantArtifactSidebarTab.files; _selectedEntry = null; _preview = const AssistantArtifactPreview.empty(); unawaited(_refreshSnapshot()); @@ -181,7 +181,7 @@ class _AssistantArtifactSidebarState extends State { onChanged: (value) { final nextTab = AssistantArtifactSidebarTab.values.firstWhere( (item) => _labelForTab(item) == value, - orElse: () => AssistantArtifactSidebarTab.results, + orElse: () => AssistantArtifactSidebarTab.files, ); setState(() { _activeTab = nextTab; @@ -240,25 +240,13 @@ class _AssistantArtifactSidebarState extends State { ); } return switch (_activeTab) { - AssistantArtifactSidebarTab.results => _ArtifactEntryList( - key: const Key('assistant-artifact-tab-results'), - entries: snapshot.resultEntries, - emptyMessage: snapshot.resultMessage, - onSelectEntry: _selectEntry, - selectedEntry: _selectedEntry, - ), AssistantArtifactSidebarTab.files => _ArtifactEntryList( key: const Key('assistant-artifact-tab-files'), - entries: snapshot.fileEntries, - emptyMessage: snapshot.filesMessage, + entries: previewCandidates, + emptyMessage: _filesEmptyMessage(snapshot), onSelectEntry: _selectEntry, selectedEntry: _selectedEntry, ), - AssistantArtifactSidebarTab.changes => _ArtifactChangeList( - key: const Key('assistant-artifact-tab-changes'), - changes: snapshot.changes, - emptyMessage: snapshot.changesMessage, - ), AssistantArtifactSidebarTab.preview => _ArtifactPreviewPanel( key: const Key('assistant-artifact-tab-preview'), entry: _selectedEntry, @@ -286,6 +274,21 @@ class _AssistantArtifactSidebarState extends State { .toList(growable: false); } + String _filesEmptyMessage(AssistantArtifactSnapshot snapshot) { + final filesMessage = snapshot.filesMessage.trim(); + if (filesMessage.isNotEmpty) { + return filesMessage; + } + final resultsMessage = snapshot.resultMessage.trim(); + if (resultsMessage.isNotEmpty) { + return resultsMessage; + } + return appText( + '当前线程里还没有可展示的文件。', + 'No files are available for this thread yet.', + ); + } + Future _refreshSnapshot() async { setState(() { _loadingSnapshot = true; @@ -371,9 +374,7 @@ class _AssistantArtifactSidebarState extends State { String _labelForTab(AssistantArtifactSidebarTab tab) { return switch (tab) { - AssistantArtifactSidebarTab.results => appText('结果', 'Results'), AssistantArtifactSidebarTab.files => appText('全部文件', 'All files'), - AssistantArtifactSidebarTab.changes => appText('变更', 'Changes'), AssistantArtifactSidebarTab.preview => appText('预览', 'Preview'), }; } @@ -709,8 +710,8 @@ class _ArtifactPreviewPanel extends StatelessWidget { icon: Icons.preview_outlined, title: appText('暂无预览对象', 'No preview target'), message: appText( - '从结果或全部文件里选择一个文件后,会在这里轻量渲染。', - 'Select a file from results or all files to preview it here.', + '从全部文件里选择一个文件后,会在这里轻量预览。', + 'Select a file from all files to preview it here.', ), ); } diff --git a/test/features/assistant_page_suite_composer.dart b/test/features/assistant_page_suite_composer.dart index cc8624fc..fdbdd381 100644 --- a/test/features/assistant_page_suite_composer.dart +++ b/test/features/assistant_page_suite_composer.dart @@ -218,6 +218,124 @@ void registerAssistantPageSuiteComposerTestsInternal() { }, ); + testWidgets( + 'AssistantPage submits from the selected task thread workspace after switching tasks', + (WidgetTester tester) async { + late final Directory tempDirectory; + late final SecureConfigStore store; + late final CaptureSendAppControllerInternal controller; + await tester.runAsync(() async { + SharedPreferences.setMockInitialValues({}); + tempDirectory = await Directory.systemTemp.createTemp( + 'xworkmate-assistant-page-thread-cwd-ui-', + ); + store = SecureConfigStore( + enableSecureStorage: false, + databasePathResolver: () async => '${tempDirectory.path}/settings.db', + fallbackDirectoryPathResolver: () async => tempDirectory.path, + defaultSupportDirectoryPathResolver: () async => tempDirectory.path, + ); + await store.initialize(); + await store.saveSettingsSnapshot( + SettingsSnapshot.defaults().copyWith( + assistantExecutionTarget: AssistantExecutionTarget.singleAgent, + workspacePath: '${tempDirectory.path}/workspace-root', + ), + ); + await Directory( + '${tempDirectory.path}/workspace-root', + ).create(recursive: true); + await Directory( + '${tempDirectory.path}/thread-main', + ).create(recursive: true); + await Directory( + '${tempDirectory.path}/thread-task', + ).create(recursive: true); + await store.saveAssistantThreadRecords([ + AssistantThreadRecord( + sessionKey: 'main', + messages: const [], + updatedAtMs: 1, + title: 'Main', + archived: false, + executionTarget: AssistantExecutionTarget.singleAgent, + messageViewMode: AssistantMessageViewMode.rendered, + workspaceRef: '${tempDirectory.path}/thread-main', + workspaceRefKind: WorkspaceRefKind.localPath, + ), + AssistantThreadRecord( + sessionKey: 'draft:artifact-thread', + messages: const [], + updatedAtMs: 2, + title: 'Artifact Thread', + archived: false, + executionTarget: AssistantExecutionTarget.singleAgent, + messageViewMode: AssistantMessageViewMode.rendered, + workspaceRef: '${tempDirectory.path}/thread-task', + workspaceRefKind: WorkspaceRefKind.localPath, + ), + ]); + controller = CaptureSendAppControllerInternal( + store: store, + runtimeCoordinator: RuntimeCoordinator( + gateway: FakeGatewayRuntimeInternal(store: store), + codex: FakeCodexRuntimeInternal(), + ), + ); + final stopwatch = Stopwatch()..start(); + while (controller.initializing) { + if (stopwatch.elapsed > const Duration(seconds: 10)) { + fail('controller did not finish initializing before timeout'); + } + await Future.delayed(const Duration(milliseconds: 20)); + } + }); + addTearDown(() async { + if (await tempDirectory.exists()) { + try { + await tempDirectory.delete(recursive: true); + } catch (_) {} + } + }); + addTearDown(controller.dispose); + + await pumpPage( + tester, + child: AssistantPage(controller: controller, onOpenDetail: (_) {}), + platform: TargetPlatform.macOS, + ); + + await tester.tap( + find.byKey(const ValueKey('assistant-task-group-singleAgent')), + ); + await pumpForUiSyncInternal(tester); + + await tester.tap( + find.byKey( + const ValueKey('assistant-task-item-draft:artifact-thread'), + ), + ); + await pumpForUiSyncInternal(tester); + + expect(controller.currentSessionKey, 'draft:artifact-thread'); + + final composerInput = find.descendant( + of: find.byKey(const Key('assistant-composer-input-area')), + matching: find.byType(TextField), + ); + expect(composerInput, findsOneWidget); + + await tester.enterText(composerInput, '检查线程目录'); + await tester.tap(find.byKey(const Key('assistant-submit-button'))); + await pumpForUiSyncInternal(tester); + + expect(controller.sendCallCount, 1); + expect(controller.lastSentMessage, contains('检查线程目录')); + expect(controller.lastSessionKey, 'draft:artifact-thread'); + expect(controller.lastWorkspaceRef, '${tempDirectory.path}/thread-task'); + }, + ); + testWidgets( 'AssistantPage shows a persistent skill popover in single-agent mode and keeps thread selections isolated', (WidgetTester tester) async { diff --git a/test/features/assistant_page_suite_support.dart b/test/features/assistant_page_suite_support.dart index e38ca708..bd951442 100644 --- a/test/features/assistant_page_suite_support.dart +++ b/test/features/assistant_page_suite_support.dart @@ -207,6 +207,34 @@ class PendingSendAppControllerInternal extends AppController { } } +class CaptureSendAppControllerInternal extends AppController { + CaptureSendAppControllerInternal({ + required SecureConfigStore store, + RuntimeCoordinator? runtimeCoordinator, + }) : super(store: store, runtimeCoordinator: runtimeCoordinator); + + int sendCallCount = 0; + String lastSentMessage = ''; + String lastSessionKey = ''; + String lastWorkspaceRef = ''; + + @override + Future sendChatMessage( + String message, { + String thinking = 'off', + List attachments = + const [], + List localAttachments = + const [], + List selectedSkillLabels = const [], + }) async { + sendCallCount += 1; + lastSentMessage = message; + lastSessionKey = currentSessionKey; + lastWorkspaceRef = assistantWorkspaceRefForSession(currentSessionKey); + } +} + class FakeGatewayRuntimeInternal extends GatewayRuntime { FakeGatewayRuntimeInternal({required super.store}) : super(identityStore: DeviceIdentityStore(store)); diff --git a/test/runtime/app_controller_ai_gateway_chat_suite_single_agent.dart b/test/runtime/app_controller_ai_gateway_chat_suite_single_agent.dart index d453766b..c5e2bc30 100644 --- a/test/runtime/app_controller_ai_gateway_chat_suite_single_agent.dart +++ b/test/runtime/app_controller_ai_gateway_chat_suite_single_agent.dart @@ -495,5 +495,92 @@ void registerAppControllerAiGatewayChatSuiteSingleAgentTestsInternal() { ); }, ); + + test( + 'AppController rebinds remote Single Agent threads to the resolved thread directory', + () async { + final tempDirectory = await createTempDirectoryInternal( + 'xworkmate-single-agent-remote-rebind-cwd-', + ); + final defaultWorkspace = Directory( + '${tempDirectory.path}/default-workspace', + ); + await defaultWorkspace.create(recursive: true); + + final store = createStoreFromTempDirectoryInternal(tempDirectory); + await store.initialize(); + await store.saveSettingsSnapshot( + SettingsSnapshot.defaults().copyWith( + workspacePath: defaultWorkspace.path, + assistantExecutionTarget: AssistantExecutionTarget.singleAgent, + externalAcpEndpoints: normalizeExternalAcpEndpoints( + profiles: [ + ExternalAcpEndpointProfile.defaultsForProvider( + SingleAgentProvider.opencode, + ).copyWith( + enabled: true, + endpoint: 'https://remote.example.com/acp', + ), + ], + ), + ), + ); + + final runner = FakeSingleAgentRunnerInternal( + resolvedProvider: SingleAgentProvider.opencode, + result: const SingleAgentRunResult( + provider: SingleAgentProvider.opencode, + output: 'THREAD_OK', + success: true, + errorMessage: '', + shouldFallbackToAiChat: false, + resolvedWorkingDirectory: '/remote/threads/task-42', + resolvedWorkspaceRefKind: WorkspaceRefKind.remotePath, + ), + ); + final controller = await createAppControllerInternal( + store: store, + availableSingleAgentProvidersOverride: const [ + SingleAgentProvider.opencode, + ], + runtimeCoordinator: RuntimeCoordinator( + gateway: FakeGatewayRuntimeInternal(store: store), + codex: FakeCodexRuntimeInternal(), + ), + singleAgentRunner: runner, + ); + + await controller.setAssistantExecutionTarget( + AssistantExecutionTarget.singleAgent, + ); + await controller.setSingleAgentProvider(SingleAgentProvider.opencode); + controller.initializeAssistantThreadContext( + 'draft:remote-thread', + title: 'Remote Thread', + executionTarget: AssistantExecutionTarget.singleAgent, + ); + await controller.switchSession('draft:remote-thread'); + + await controller.sendChatMessage('第一次运行', thinking: 'low'); + expect( + runner.requests.first.workingDirectory, + '${defaultWorkspace.path}/.xworkmate/threads/draft-remote-thread', + ); + expect( + controller.assistantWorkspaceRefForSession('draft:remote-thread'), + '/remote/threads/task-42', + ); + expect( + controller.assistantWorkspaceRefKindForSession('draft:remote-thread'), + WorkspaceRefKind.remotePath, + ); + + await controller.sendChatMessage('第二次运行', thinking: 'low'); + expect( + runner.requests.last.workingDirectory, + '/remote/threads/task-42', + ); + }, + ); }); } diff --git a/test/widgets/assistant_artifact_sidebar_test.dart b/test/widgets/assistant_artifact_sidebar_test.dart index 9ecb9f2f..2bb21ea9 100644 --- a/test/widgets/assistant_artifact_sidebar_test.dart +++ b/test/widgets/assistant_artifact_sidebar_test.dart @@ -82,6 +82,11 @@ void main() { }, ); + expect(find.text('全部文件'), findsOneWidget); + expect(find.text('预览'), findsOneWidget); + expect(find.text('结果'), findsNothing); + expect(find.text('变更'), findsNothing); + await tester.tap( find.byKey(const ValueKey('assistant-artifact-entry-README.md')), ); @@ -92,7 +97,7 @@ void main() { ); expect(find.text('Markdown Preview'), findsOneWidget); - await tester.tap(find.text('结果')); + await tester.tap(find.text('全部文件')); await tester.pumpAndSettle(); await tester.tap( find.byKey(