From 69e53c8d5dea8a8d11f3355b3c322e1cbe53b7f1 Mon Sep 17 00:00:00 2001 From: Haitao Pan Date: Sun, 7 Jun 2026 07:38:04 +0800 Subject: [PATCH] fix: keep syncing partial OpenClaw artifacts --- ...pp_controller_desktop_runtime_helpers.dart | 15 ++++ ...pp_controller_desktop_thread_sessions.dart | 20 ++++- lib/widgets/assistant_artifact_sidebar.dart | 42 +++++++++ .../assistant_artifact_sidebar_test.dart | 40 +++++++++ ...troller_thread_workspace_binding_test.dart | 87 +++++++++++++++++++ 5 files changed, 203 insertions(+), 1 deletion(-) diff --git a/lib/app/app_controller_desktop_runtime_helpers.dart b/lib/app/app_controller_desktop_runtime_helpers.dart index b772d0ca..6a6bea6a 100644 --- a/lib/app/app_controller_desktop_runtime_helpers.dart +++ b/lib/app/app_controller_desktop_runtime_helpers.dart @@ -799,7 +799,22 @@ extension AppControllerDesktopRuntimeHelpers on AppController { var wroteArtifact = false; var failedArtifact = false; var skippedArtifact = false; + final previousSyncStatus = + existingThread.lastArtifactSyncStatus?.trim().toLowerCase() ?? ''; + final preserveExistingArtifactPaths = + previousSyncStatus == 'partial' || + previousSyncStatus == 'syncing' || + previousSyncStatus == 'running' || + previousSyncStatus == 'queued'; final currentTaskArtifactPaths = {}; + if (preserveExistingArtifactPaths) { + for (final relativePath in existingThread.lastTaskArtifactRelativePaths) { + final sanitized = _sanitizeArtifactRelativePathInternal(relativePath); + if (sanitized.isNotEmpty && !artifactSyncPolicy.ignores(sanitized)) { + currentTaskArtifactPaths.add(sanitized); + } + } + } for (final artifact in artifacts) { final relativePath = _sanitizeArtifactRelativePathInternal( artifact.relativePath, diff --git a/lib/app/app_controller_desktop_thread_sessions.dart b/lib/app/app_controller_desktop_thread_sessions.dart index 1eb31dfa..fc7b1437 100644 --- a/lib/app/app_controller_desktop_thread_sessions.dart +++ b/lib/app/app_controller_desktop_thread_sessions.dart @@ -388,7 +388,13 @@ extension AppControllerDesktopThreadSessions on AppController { artifactRelativePaths: thread?.lastTaskArtifactRelativePaths ?? const [], ); - if (snapshot.fileEntries.isNotEmpty || thread == null) { + if (thread == null) { + return snapshot; + } + final shouldRefreshRemote = _shouldRefreshRemoteArtifactSnapshotInternal( + thread, + ); + if (snapshot.fileEntries.isNotEmpty && !shouldRefreshRemote) { return snapshot; } final synced = await syncRemoteTaskArtifactsForSessionInternal( @@ -406,6 +412,18 @@ extension AppControllerDesktopThreadSessions on AppController { ); } + bool _shouldRefreshRemoteArtifactSnapshotInternal(TaskThread thread) { + final syncStatus = thread.lastArtifactSyncStatus?.trim().toLowerCase(); + if (syncStatus == 'partial' || + syncStatus == 'syncing' || + syncStatus == 'running' || + syncStatus == 'queued') { + return true; + } + final association = thread.openClawTaskAssociation; + return association != null && !association.isTerminal; + } + Future syncRemoteTaskArtifactsForSessionInternal( String sessionKey, ) async { diff --git a/lib/widgets/assistant_artifact_sidebar.dart b/lib/widgets/assistant_artifact_sidebar.dart index cfc3b0f8..ae315eed 100644 --- a/lib/widgets/assistant_artifact_sidebar.dart +++ b/lib/widgets/assistant_artifact_sidebar.dart @@ -76,11 +76,13 @@ class _AssistantArtifactSidebarState extends State { bool _loadingSnapshot = false; bool _loadingPreview = false; bool _taskContextExpanded = false; + Timer? _refreshTimer; @override void initState() { super.initState(); unawaited(_refreshSnapshot()); + _syncRefreshTimer(); } @override @@ -96,6 +98,13 @@ class _AssistantArtifactSidebarState extends State { _preview = const AssistantArtifactPreview.empty(); unawaited(_refreshSnapshot()); } + _syncRefreshTimer(); + } + + @override + void dispose() { + _refreshTimer?.cancel(); + super.dispose(); } @override @@ -449,6 +458,9 @@ class _AssistantArtifactSidebarState extends State { } Future _refreshSnapshot() async { + if (_loadingSnapshot) { + return; + } setState(() { _loadingSnapshot = true; _loadError = null; @@ -482,6 +494,36 @@ class _AssistantArtifactSidebarState extends State { } } + void _syncRefreshTimer() { + final shouldPoll = _shouldPollArtifactSnapshot(widget.artifactSyncStatus); + if (!shouldPoll) { + _refreshTimer?.cancel(); + _refreshTimer = null; + return; + } + if (_refreshTimer?.isActive == true) { + return; + } + _refreshTimer = Timer.periodic(const Duration(seconds: 3), (_) { + if (!mounted || _loadingSnapshot) { + return; + } + unawaited(_refreshSnapshot()); + }); + } + + bool _shouldPollArtifactSnapshot(String status) { + switch (status.trim().toLowerCase()) { + case 'partial': + case 'syncing': + case 'running': + case 'queued': + return true; + default: + return false; + } + } + AssistantArtifactEntry? _reconcileSelection( AssistantArtifactSnapshot snapshot, { AssistantArtifactEntry? previous, diff --git a/test/features/assistant/assistant_artifact_sidebar_test.dart b/test/features/assistant/assistant_artifact_sidebar_test.dart index 61cc1a68..4e26cbe5 100644 --- a/test/features/assistant/assistant_artifact_sidebar_test.dart +++ b/test/features/assistant/assistant_artifact_sidebar_test.dart @@ -46,6 +46,46 @@ void main() { expect(find.text('artifact-2.txt'), findsAtLeastNWidgets(1)); }); + testWidgets('keeps polling partial artifact snapshots', (tester) async { + var loadCount = 0; + + await tester.pumpWidget( + _buildTestApp( + artifactSyncAtMs: 1, + artifactSyncStatus: 'partial', + loadSnapshot: () async { + loadCount += 1; + return AssistantArtifactSnapshot( + workspacePath: '/tmp/thread', + workspaceKind: WorkspaceRefKind.localPath, + fileEntries: [ + AssistantArtifactEntry( + id: 'entry-$loadCount', + label: 'artifact-$loadCount.txt', + relativePath: 'artifact-$loadCount.txt', + kind: AssistantArtifactEntryKind.file, + mimeType: 'text/plain', + previewable: true, + workspacePath: '/tmp/thread', + ), + ], + ); + }, + ), + ); + await tester.pump(); + + expect(loadCount, 1); + + await tester.pump(const Duration(milliseconds: 3100)); + await tester.pump(); + + expect(loadCount, greaterThanOrEqualTo(2)); + expect(find.text('artifact-2.txt'), findsAtLeastNWidgets(1)); + + await tester.pumpWidget(const SizedBox.shrink()); + }); + testWidgets('explains OpenClaw runs with no exported artifacts', ( tester, ) async { diff --git a/test/runtime/app_controller_thread_workspace_binding_test.dart b/test/runtime/app_controller_thread_workspace_binding_test.dart index 45aeee01..ca5e85ba 100644 --- a/test/runtime/app_controller_thread_workspace_binding_test.dart +++ b/test/runtime/app_controller_thread_workspace_binding_test.dart @@ -1021,6 +1021,93 @@ void main() { }, ); + test( + 'refreshing a partial artifact snapshot keeps backfilling OpenClaw task artifacts', + () async { + var getTaskCount = 0; + late OpenClawTaskAssociation observedAssociation; + final goTaskClient = _ArtifactBackfillGoTaskServiceClient( + onGetTask: (association) { + getTaskCount += 1; + observedAssociation = association; + }, + ); + final controller = AppController( + environmentOverride: const {}, + goTaskServiceClient: goTaskClient, + ); + addTearDown(controller.dispose); + + final taskWorkspace = await Directory.systemTemp.createTemp( + 'xworkmate-partial-association-backfill-', + ); + addTearDown(() async { + if (await taskWorkspace.exists()) { + await taskWorkspace.delete(recursive: true); + } + }); + await Directory( + '${taskWorkspace.path}/assets/images', + ).create(recursive: true); + await File( + '${taskWorkspace.path}/assets/images/09-AI-Agent.v32.png', + ).writeAsBytes([1, 2, 3]); + + const sessionKey = 'draft-partial-sync'; + const runId = 'turn-partial'; + const openClawSessionKey = 'agent:main:draft:partial-sync'; + controller.upsertTaskThreadInternal( + sessionKey, + workspaceBinding: WorkspaceBinding( + workspaceId: sessionKey, + workspaceKind: WorkspaceKind.localFs, + workspacePath: taskWorkspace.path, + displayPath: taskWorkspace.path, + writable: true, + ), + openClawTaskAssociation: const OpenClawTaskAssociation( + sessionId: sessionKey, + threadId: sessionKey, + turnId: runId, + runId: runId, + artifactScope: 'tasks/$openClawSessionKey/$runId', + artifactDirectory: + '/home/ubuntu/.openclaw/workspace/tasks/$openClawSessionKey/$runId', + gatewayProviderId: 'openclaw', + startedAtMs: 1, + status: 'completed', + appThreadKey: 'draft:partial-sync', + openclawSessionKey: openClawSessionKey, + ), + lastArtifactSyncStatus: 'partial', + lastTaskArtifactRelativePaths: const [ + 'assets/images/09-AI-Agent.v32.png', + ], + ); + + final snapshot = await controller.loadAssistantArtifactSnapshot( + sessionKey: sessionKey, + ); + + expect(getTaskCount, 1); + expect(observedAssociation.runId, runId); + expect( + snapshot.fileEntries.map((entry) => entry.relativePath), + containsAll([ + 'assets/images/09-AI-Agent.v32.png', + 'ai-news-report.md', + ]), + ); + expect( + await File('${taskWorkspace.path}/ai-news-report.md').readAsString(), + '# AI news\n', + ); + final thread = controller.requireTaskThreadForSessionInternal(sessionKey); + expect(thread.lastArtifactSyncStatus, 'synced'); + expect(thread.openClawTaskAssociation?.status, 'completed'); + }, + ); + test( 'resumes bridge artifact downloads after a weak network disconnect', () async {