fix assistant thread workspace rebinding and simplify artifact tabs

This commit is contained in:
Haitao Pan 2026-03-28 20:39:19 +08:00
parent 79019f64c6
commit fbe580eeec
6 changed files with 277 additions and 23 deletions

View File

@ -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] =

View File

@ -18,7 +18,7 @@ typedef AssistantArtifactSnapshotLoader =
typedef AssistantArtifactPreviewLoader =
Future<AssistantArtifactPreview> 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<AssistantArtifactSidebar> {
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<AssistantArtifactSidebar> {
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<AssistantArtifactSidebar> {
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<AssistantArtifactSidebar> {
);
}
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<AssistantArtifactSidebar> {
.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<void> _refreshSnapshot() async {
setState(() {
_loadingSnapshot = true;
@ -371,9 +374,7 @@ class _AssistantArtifactSidebarState extends State<AssistantArtifactSidebar> {
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.',
),
);
}

View File

@ -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(<String, Object>{});
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>[
AssistantThreadRecord(
sessionKey: 'main',
messages: const <GatewayChatMessage>[],
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 <GatewayChatMessage>[],
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<void>.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<String>('assistant-task-group-singleAgent')),
);
await pumpForUiSyncInternal(tester);
await tester.tap(
find.byKey(
const ValueKey<String>('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 {

View File

@ -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<void> sendChatMessage(
String message, {
String thinking = 'off',
List<GatewayChatAttachmentPayload> attachments =
const <GatewayChatAttachmentPayload>[],
List<CollaborationAttachment> localAttachments =
const <CollaborationAttachment>[],
List<String> selectedSkillLabels = const <String>[],
}) async {
sendCallCount += 1;
lastSentMessage = message;
lastSessionKey = currentSessionKey;
lastWorkspaceRef = assistantWorkspaceRefForSession(currentSessionKey);
}
}
class FakeGatewayRuntimeInternal extends GatewayRuntime {
FakeGatewayRuntimeInternal({required super.store})
: super(identityStore: DeviceIdentityStore(store));

View File

@ -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>[
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>[
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',
);
},
);
});
}

View File

@ -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<String>('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(