fix assistant thread workspace rebinding and simplify artifact tabs
This commit is contained in:
parent
79019f64c6
commit
fbe580eeec
@ -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] =
|
||||
|
||||
@ -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.',
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@ -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 {
|
||||
|
||||
@ -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));
|
||||
|
||||
@ -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',
|
||||
);
|
||||
},
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
@ -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(
|
||||
|
||||
Loading…
Reference in New Issue
Block a user