diff --git a/lib/app/app_controller_desktop_runtime_helpers.dart b/lib/app/app_controller_desktop_runtime_helpers.dart index cff64550..fa1cacc7 100644 --- a/lib/app/app_controller_desktop_runtime_helpers.dart +++ b/lib/app/app_controller_desktop_runtime_helpers.dart @@ -729,6 +729,7 @@ extension AppControllerDesktopRuntimeHelpers on AppController { normalizedSessionKey, lastArtifactSyncAtMs: syncedAtMs, lastArtifactSyncStatus: 'syncing', + lastTaskArtifactRelativePaths: const [], updatedAtMs: syncedAtMs, ); recomputeTasksInternal(); @@ -762,6 +763,7 @@ extension AppControllerDesktopRuntimeHelpers on AppController { var wroteArtifact = false; var failedArtifact = false; var skippedArtifact = false; + final currentTaskArtifactRelativePaths = []; for (final artifact in artifacts) { final relativePath = _sanitizeArtifactRelativePathInternal( artifact.relativePath, @@ -791,6 +793,14 @@ extension AppControllerDesktopRuntimeHelpers on AppController { continue; } wroteArtifact = true; + final writtenRelativePath = + DesktopThreadArtifactService.relativePathInternal( + root.path, + target.path, + ); + if (writtenRelativePath != null && writtenRelativePath.isNotEmpty) { + currentTaskArtifactRelativePaths.add(writtenRelativePath); + } } final syncStatus = wroteArtifact @@ -802,6 +812,7 @@ extension AppControllerDesktopRuntimeHelpers on AppController { normalizedSessionKey, lastArtifactSyncAtMs: syncedAtMs, lastArtifactSyncStatus: syncStatus, + lastTaskArtifactRelativePaths: currentTaskArtifactRelativePaths, updatedAtMs: syncedAtMs, ); } diff --git a/lib/app/app_controller_desktop_skill_permissions.dart b/lib/app/app_controller_desktop_skill_permissions.dart index b7478a1c..b9b96d6c 100644 --- a/lib/app/app_controller_desktop_skill_permissions.dart +++ b/lib/app/app_controller_desktop_skill_permissions.dart @@ -242,6 +242,7 @@ extension AppControllerDesktopSkillPermissions on AppController { WorkspaceRefKind? lastRemoteWorkspaceRefKind, double? lastArtifactSyncAtMs, String? lastArtifactSyncStatus, + List? lastTaskArtifactRelativePaths, }) { final normalizedSessionKey = normalizedAssistantSessionKeyInternal( sessionKey, @@ -359,6 +360,7 @@ extension AppControllerDesktopSkillPermissions on AppController { lastRemoteWorkspaceRefKind: null, lastArtifactSyncAtMs: null, lastArtifactSyncStatus: null, + lastTaskArtifactRelativePaths: const [], )) .copyWith( messages: nextMessages, @@ -382,6 +384,7 @@ extension AppControllerDesktopSkillPermissions on AppController { lastRemoteWorkspaceRefKind: lastRemoteWorkspaceRefKind, lastArtifactSyncAtMs: lastArtifactSyncAtMs, lastArtifactSyncStatus: lastArtifactSyncStatus, + lastTaskArtifactRelativePaths: lastTaskArtifactRelativePaths, ); final nextStatus = lifecycleStatus ?? diff --git a/lib/app/app_controller_desktop_thread_sessions.dart b/lib/app/app_controller_desktop_thread_sessions.dart index 1b13cd0a..04fb7714 100644 --- a/lib/app/app_controller_desktop_thread_sessions.dart +++ b/lib/app/app_controller_desktop_thread_sessions.dart @@ -223,9 +223,7 @@ extension AppControllerDesktopThreadSessions on AppController { ?.selectedSkillKeys ?? const []; final availableKeys = skills.map((item) => item.skillKey).toSet(); - return selected - .where(availableKeys.contains) - .toList(growable: false); + return selected.where(availableKeys.contains).toList(growable: false); } String assistantModelForSession(String sessionKey) { @@ -327,9 +325,12 @@ extension AppControllerDesktopThreadSessions on AppController { final resolvedSessionKey = normalizedAssistantSessionKeyInternal( sessionKey ?? currentSessionKey, ); + final thread = taskThreadForSessionInternal(resolvedSessionKey); return threadArtifactServiceInternal.loadSnapshot( workspacePath: assistantWorkspacePathForSession(resolvedSessionKey), workspaceKind: assistantWorkspaceKindForSession(resolvedSessionKey), + artifactRelativePaths: + thread?.lastTaskArtifactRelativePaths ?? const [], ); } @@ -340,10 +341,13 @@ extension AppControllerDesktopThreadSessions on AppController { final resolvedSessionKey = normalizedAssistantSessionKeyInternal( sessionKey ?? currentSessionKey, ); + final thread = taskThreadForSessionInternal(resolvedSessionKey); return threadArtifactServiceInternal.loadPreview( entry: entry, workspacePath: assistantWorkspacePathForSession(resolvedSessionKey), workspaceKind: assistantWorkspaceKindForSession(resolvedSessionKey), + artifactRelativePaths: + thread?.lastTaskArtifactRelativePaths ?? const [], ); } diff --git a/lib/runtime/desktop_thread_artifact_service.dart b/lib/runtime/desktop_thread_artifact_service.dart index ae4489b9..42581250 100644 --- a/lib/runtime/desktop_thread_artifact_service.dart +++ b/lib/runtime/desktop_thread_artifact_service.dart @@ -19,6 +19,7 @@ class DesktopThreadArtifactService { Future loadSnapshot({ required String workspacePath, required WorkspaceRefKind workspaceKind, + List artifactRelativePaths = const [], }) async { final normalizedRef = workspacePath.trim(); if (normalizedRef.isEmpty) { @@ -56,9 +57,24 @@ class DesktopThreadArtifactService { ); } - final files = await collectFilesInternal(root); + final taskArtifactPaths = normalizeTaskArtifactPathsInternal( + artifactRelativePaths, + ); + final files = taskArtifactPaths.isEmpty + ? const [] + : await collectTaskArtifactFilesInternal( + root, + normalizedRef, + taskArtifactPaths, + ); final fileEntries = await buildEntriesInternal(files, normalizedRef); - final changes = await readGitChangesInternal(root, normalizedRef); + final changes = taskArtifactPaths.isEmpty + ? const [] + : await readGitChangesInternal( + root, + normalizedRef, + artifactRelativePaths: taskArtifactPaths, + ); final results = await buildResultEntriesInternal( changes: changes, fileEntries: fileEntries, @@ -93,6 +109,7 @@ class DesktopThreadArtifactService { required AssistantArtifactEntry entry, required String workspacePath, required WorkspaceRefKind workspaceKind, + List artifactRelativePaths = const [], }) async { if (workspaceKind != WorkspaceRefKind.localPath) { return const AssistantArtifactPreview.empty( @@ -106,9 +123,19 @@ class DesktopThreadArtifactService { 'The recorded working directory is not available on this machine.', ); } + final taskArtifactPaths = normalizeTaskArtifactPathsInternal( + artifactRelativePaths, + ); + final entryRelativePath = normalizeArtifactPathInternal(entry.relativePath); + if (entryRelativePath.isEmpty || + !taskArtifactPaths.contains(entryRelativePath)) { + return const AssistantArtifactPreview.empty( + message: 'The selected file is not part of the current task artifacts.', + ); + } final targetPath = resolveAbsolutePathInternal( workspacePath, - entry.relativePath, + entryRelativePath, ); final file = File(targetPath); if (!await file.exists()) { @@ -117,8 +144,15 @@ class DesktopThreadArtifactService { 'The selected file is no longer available: ${entry.relativePath}', ); } + final resolvedRelativePath = relativePathInternal(workspacePath, file.path); + if (resolvedRelativePath == null || + resolvedRelativePath != entryRelativePath) { + return const AssistantArtifactPreview.empty( + message: 'The selected file is not part of the current task artifacts.', + ); + } - final extension = fileExtensionInternal(entry.relativePath); + final extension = fileExtensionInternal(entryRelativePath); final content = await file.readAsString(); final title = entry.label; if (extension == 'md' || extension == 'markdown') { @@ -171,6 +205,33 @@ class DesktopThreadArtifactService { return files; } + Future> collectTaskArtifactFilesInternal( + Directory root, + String workspacePath, + List artifactRelativePaths, + ) async { + final files = []; + for (final relativePath in artifactRelativePaths) { + final target = File(resolveAbsolutePathInternal(root.path, relativePath)); + try { + if (!await target.exists()) { + continue; + } + final resolvedRelativePath = relativePathInternal( + workspacePath, + target.path, + ); + if (resolvedRelativePath == null || resolvedRelativePath.isEmpty) { + continue; + } + files.add(target); + } on FileSystemException { + continue; + } + } + return files; + } + Future> buildEntriesInternal( List files, String workspacePath, @@ -232,8 +293,12 @@ class DesktopThreadArtifactService { Future> readGitChangesInternal( Directory workspaceRoot, - String workspacePath, - ) async { + String workspacePath, { + List artifactRelativePaths = const [], + }) async { + final allowedPaths = normalizeTaskArtifactPathsInternal( + artifactRelativePaths, + ).toSet(); String? repositoryRoot; try { final revParse = await Process.run('git', [ @@ -279,6 +344,9 @@ class DesktopThreadArtifactService { if (relativePath == null || relativePath.isEmpty) { continue; } + if (allowedPaths.isNotEmpty && !allowedPaths.contains(relativePath)) { + continue; + } items.add( AssistantArtifactChangeEntry( path: relativePath, @@ -369,6 +437,39 @@ class DesktopThreadArtifactService { return normalizedPath.substring(prefix.length); } + static List normalizeTaskArtifactPathsInternal( + List relativePaths, + ) { + final seen = {}; + final normalized = []; + for (final relativePath in relativePaths) { + final item = normalizeArtifactPathInternal(relativePath); + if (item.isEmpty || !seen.add(item)) { + continue; + } + normalized.add(item); + } + return normalized; + } + + static String normalizeArtifactPathInternal(String relativePath) { + final trimmed = relativePath.trim().replaceAll('\\', '/'); + if (trimmed.isEmpty || + trimmed.startsWith('/') || + trimmed.startsWith('~') || + trimmed.contains(':')) { + return ''; + } + final segments = trimmed + .split('/') + .where((segment) => segment.isNotEmpty && segment != '.') + .toList(growable: false); + if (segments.isEmpty || segments.any((segment) => segment == '..')) { + return ''; + } + return segments.join('/'); + } + static String normalizePathInternal(String path) { try { final type = FileSystemEntity.typeSync(path, followLinks: true); diff --git a/lib/runtime/runtime_models_runtime_payloads.dart b/lib/runtime/runtime_models_runtime_payloads.dart index 6cc21176..41314d26 100644 --- a/lib/runtime/runtime_models_runtime_payloads.dart +++ b/lib/runtime/runtime_models_runtime_payloads.dart @@ -741,6 +741,7 @@ class ThreadContextState { this.lastRemoteWorkspaceRefKind, this.lastArtifactSyncAtMs, this.lastArtifactSyncStatus, + this.lastTaskArtifactRelativePaths = const [], }); final List messages; @@ -758,6 +759,7 @@ class ThreadContextState { final WorkspaceRefKind? lastRemoteWorkspaceRefKind; final double? lastArtifactSyncAtMs; final String? lastArtifactSyncStatus; + final List lastTaskArtifactRelativePaths; ThreadContextState copyWith({ List? messages, @@ -776,6 +778,7 @@ class ThreadContextState { WorkspaceRefKind? lastRemoteWorkspaceRefKind, double? lastArtifactSyncAtMs, String? lastArtifactSyncStatus, + List? lastTaskArtifactRelativePaths, }) { return ThreadContextState( messages: messages ?? this.messages, @@ -800,6 +803,9 @@ class ThreadContextState { lastArtifactSyncAtMs: lastArtifactSyncAtMs ?? this.lastArtifactSyncAtMs, lastArtifactSyncStatus: lastArtifactSyncStatus ?? this.lastArtifactSyncStatus, + lastTaskArtifactRelativePaths: lastTaskArtifactRelativePaths == null + ? this.lastTaskArtifactRelativePaths + : _stringListFromJson(lastTaskArtifactRelativePaths), ); } @@ -822,6 +828,7 @@ class ThreadContextState { 'lastRemoteWorkspaceRefKind': lastRemoteWorkspaceRefKind?.name, 'lastArtifactSyncAtMs': lastArtifactSyncAtMs, 'lastArtifactSyncStatus': lastArtifactSyncStatus, + 'lastTaskArtifactRelativePaths': lastTaskArtifactRelativePaths, }; } @@ -897,10 +904,34 @@ class ThreadContextState { })(), lastArtifactSyncAtMs: asDouble(json['lastArtifactSyncAtMs']), lastArtifactSyncStatus: json['lastArtifactSyncStatus']?.toString(), + lastTaskArtifactRelativePaths: _stringListFromJson( + json['lastTaskArtifactRelativePaths'], + ), ); } } +List _stringListFromJson(Object? value) { + if (value is! List) { + return const []; + } + final seen = {}; + final items = []; + for (final item in value) { + final normalized = item?.toString().trim().replaceAll('\\', '/') ?? ''; + if (normalized.isEmpty || normalized.startsWith('/')) { + continue; + } + if (normalized.split('/').any((segment) => segment == '..')) { + continue; + } + if (seen.add(normalized)) { + items.add(normalized); + } + } + return items; +} + class ThreadLifecycleState { const ThreadLifecycleState({ required this.archived, @@ -984,6 +1015,7 @@ class TaskThread { WorkspaceRefKind? lastRemoteWorkspaceRefKind, double? lastArtifactSyncAtMs, String? lastArtifactSyncStatus, + List? lastTaskArtifactRelativePaths, }) : threadId = _resolveThreadId(threadId), title = title ?? '', ownerScope = @@ -1029,6 +1061,9 @@ class TaskThread { lastArtifactSyncStatus?.trim().isNotEmpty == true ? lastArtifactSyncStatus!.trim() : null, + lastTaskArtifactRelativePaths: _stringListFromJson( + lastTaskArtifactRelativePaths, + ), ), lifecycleState = lifecycleState ?? @@ -1067,6 +1102,8 @@ class TaskThread { contextState.lastRemoteWorkspaceRefKind; double? get lastArtifactSyncAtMs => contextState.lastArtifactSyncAtMs; String? get lastArtifactSyncStatus => contextState.lastArtifactSyncStatus; + List get lastTaskArtifactRelativePaths => + contextState.lastTaskArtifactRelativePaths; String get latestResolvedRuntimeModel => contextState.latestResolvedRuntimeModel; String get latestResolvedProviderId => contextState.latestResolvedProviderId; @@ -1110,6 +1147,7 @@ class TaskThread { WorkspaceRefKind? lastRemoteWorkspaceRefKind, double? lastArtifactSyncAtMs, String? lastArtifactSyncStatus, + List? lastTaskArtifactRelativePaths, }) { return TaskThread( threadId: threadId ?? this.threadId, @@ -1133,6 +1171,7 @@ class TaskThread { lastRemoteWorkspaceRefKind: lastRemoteWorkspaceRefKind, lastArtifactSyncAtMs: lastArtifactSyncAtMs, lastArtifactSyncStatus: lastArtifactSyncStatus, + lastTaskArtifactRelativePaths: lastTaskArtifactRelativePaths, ), lifecycleState: (lifecycleState ?? this.lifecycleState).copyWith( archived: archived, @@ -1249,6 +1288,7 @@ class TaskThread { 'lastRemoteWorkspaceRefKind': json['lastRemoteWorkspaceRefKind'], 'lastArtifactSyncAtMs': json['lastArtifactSyncAtMs'], 'lastArtifactSyncStatus': json['lastArtifactSyncStatus'], + 'lastTaskArtifactRelativePaths': json['lastTaskArtifactRelativePaths'], }; } diff --git a/test/runtime/app_controller_thread_workspace_binding_test.dart b/test/runtime/app_controller_thread_workspace_binding_test.dart index 392f5572..d3276809 100644 --- a/test/runtime/app_controller_thread_workspace_binding_test.dart +++ b/test/runtime/app_controller_thread_workspace_binding_test.dart @@ -5,6 +5,7 @@ import 'package:flutter_test/flutter_test.dart'; import 'package:xworkmate/app/app_controller.dart'; import 'package:xworkmate/app/app_controller_desktop_runtime_coordination_impl.dart'; import 'package:xworkmate/app/app_controller_desktop_thread_binding.dart'; +import 'package:xworkmate/runtime/assistant_artifacts.dart'; import 'package:xworkmate/runtime/go_task_service_client.dart'; import 'package:xworkmate/runtime/runtime_models.dart'; @@ -196,14 +197,9 @@ void main() { final snapshot = await controller.loadAssistantArtifactSnapshot( sessionKey: 'session-1', ); - expect( - snapshot.fileEntries.map((entry) => entry.relativePath), - contains('notes/hello.txt'), - ); - expect( - snapshot.fileEntries.map((entry) => entry.relativePath), - contains('notes/hello.v2.txt'), - ); + expect(snapshot.fileEntries.map((entry) => entry.relativePath), [ + 'notes/hello.v2.txt', + ]); expect( controller .requireTaskThreadForSessionInternal('session-1') @@ -212,6 +208,83 @@ void main() { ); }); + test( + 'limits the artifact panel to files produced by the current task sync', + () async { + final controller = AppController( + environmentOverride: const {}, + ); + addTearDown(controller.dispose); + + final localWorkspace = await Directory.systemTemp.createTemp( + 'xworkmate-isolated-artifact-workspace-', + ); + addTearDown(() async { + if (await localWorkspace.exists()) { + await localWorkspace.delete(recursive: true); + } + }); + final staleArtifact = File('${localWorkspace.path}/old-task-report.md'); + await staleArtifact.writeAsString('stale task output'); + + controller.upsertTaskThreadInternal( + 'session-1', + workspaceBinding: WorkspaceBinding( + workspaceId: 'session-1', + workspaceKind: WorkspaceKind.localFs, + workspacePath: localWorkspace.path, + displayPath: localWorkspace.path, + writable: true, + ), + ); + + final result = GoTaskServiceResult( + success: true, + message: 'hello', + turnId: 'turn-2', + raw: { + 'artifacts': >[ + { + 'relativePath': 'current-task-report.md', + 'content': 'current task output', + 'contentType': 'text/markdown', + }, + ], + }, + errorMessage: '', + resolvedModel: '', + route: GoTaskServiceRoute.externalAcpSingle, + ); + + await controller.persistGoTaskArtifactsForSessionInternal( + 'session-1', + result, + ); + + final snapshot = await controller.loadAssistantArtifactSnapshot( + sessionKey: 'session-1', + ); + final relativePaths = snapshot.fileEntries + .map((entry) => entry.relativePath) + .toList(growable: false); + expect(relativePaths, ['current-task-report.md']); + + final stalePreview = await controller.loadAssistantArtifactPreview( + AssistantArtifactEntry( + id: '${localWorkspace.path}::old-task-report.md', + label: 'old-task-report.md', + relativePath: 'old-task-report.md', + kind: AssistantArtifactEntryKind.file, + mimeType: 'text/markdown', + previewable: true, + workspacePath: localWorkspace.path, + ), + sessionKey: 'session-1', + ); + expect(stalePreview.kind, AssistantArtifactPreviewKind.empty); + }, + ); + test( 'downloads bridge URL artifacts into the local thread workspace', () async { @@ -895,6 +968,8 @@ void main() { await localWorkspace.delete(recursive: true); } }); + final staleArtifact = File('${localWorkspace.path}/old-task-report.md'); + await staleArtifact.writeAsString('stale task output'); controller.upsertTaskThreadInternal( 'session-1', workspaceBinding: WorkspaceBinding( @@ -927,6 +1002,10 @@ void main() { .lastArtifactSyncStatus, 'no-artifacts', ); + final snapshot = await controller.loadAssistantArtifactSnapshot( + sessionKey: 'session-1', + ); + expect(snapshot.fileEntries, isEmpty); }); test('skips download URL artifacts outside the bridge host', () async {