From 43db901e6f58b86c6f4b5df60b3edd432978be7d Mon Sep 17 00:00:00 2001 From: Haitao Pan Date: Sun, 7 Jun 2026 12:13:58 +0800 Subject: [PATCH] Sync artifact sidebar with selected task --- lib/widgets/assistant_artifact_sidebar.dart | 44 ++++++++-- .../assistant_artifact_sidebar_test.dart | 87 ++++++++++++++++++- 2 files changed, 120 insertions(+), 11 deletions(-) diff --git a/lib/widgets/assistant_artifact_sidebar.dart b/lib/widgets/assistant_artifact_sidebar.dart index ae315eed..47e1078e 100644 --- a/lib/widgets/assistant_artifact_sidebar.dart +++ b/lib/widgets/assistant_artifact_sidebar.dart @@ -76,6 +76,8 @@ class _AssistantArtifactSidebarState extends State { bool _loadingSnapshot = false; bool _loadingPreview = false; bool _taskContextExpanded = false; + int _snapshotLoadGeneration = 0; + int _previewLoadGeneration = 0; Timer? _refreshTimer; @override @@ -88,14 +90,23 @@ class _AssistantArtifactSidebarState extends State { @override void didUpdateWidget(covariant AssistantArtifactSidebar oldWidget) { super.didUpdateWidget(oldWidget); - if (oldWidget.sessionKey != widget.sessionKey || + final workspaceChanged = + oldWidget.sessionKey != widget.sessionKey || oldWidget.workspacePath != widget.workspacePath || - oldWidget.workspaceKind != widget.workspaceKind || + oldWidget.workspaceKind != widget.workspaceKind; + if (workspaceChanged || oldWidget.artifactSyncAtMs != widget.artifactSyncAtMs || oldWidget.artifactSyncStatus != widget.artifactSyncStatus) { - _activeTab = AssistantArtifactSidebarTab.files; - _selectedEntry = null; - _preview = const AssistantArtifactPreview.empty(); + if (workspaceChanged) { + _activeTab = AssistantArtifactSidebarTab.files; + _snapshot = null; + _selectedEntry = null; + _preview = const AssistantArtifactPreview.empty(); + _loadError = null; + _loadingSnapshot = false; + _loadingPreview = false; + _previewLoadGeneration += 1; + } unawaited(_refreshSnapshot()); } _syncRefreshTimer(); @@ -461,13 +472,17 @@ class _AssistantArtifactSidebarState extends State { if (_loadingSnapshot) { return; } + final generation = ++_snapshotLoadGeneration; + final sessionKey = widget.sessionKey; setState(() { _loadingSnapshot = true; _loadError = null; }); try { final snapshot = await widget.loadSnapshot(); - if (!mounted) { + if (!mounted || + generation != _snapshotLoadGeneration || + sessionKey != widget.sessionKey) { return; } final nextSelected = _reconcileSelection( @@ -484,7 +499,9 @@ class _AssistantArtifactSidebarState extends State { await _loadPreview(nextSelected); } } catch (error) { - if (!mounted) { + if (!mounted || + generation != _snapshotLoadGeneration || + sessionKey != widget.sessionKey) { return; } setState(() { @@ -555,13 +572,19 @@ class _AssistantArtifactSidebarState extends State { } Future _loadPreview(AssistantArtifactEntry entry) async { + final generation = ++_previewLoadGeneration; + final sessionKey = widget.sessionKey; + final relativePath = entry.relativePath; setState(() { _loadingPreview = true; _preview = const AssistantArtifactPreview.empty(); }); try { final preview = await widget.loadPreview(entry); - if (!mounted) { + if (!mounted || + generation != _previewLoadGeneration || + sessionKey != widget.sessionKey || + relativePath != _selectedEntry?.relativePath) { return; } setState(() { @@ -569,7 +592,10 @@ class _AssistantArtifactSidebarState extends State { _loadingPreview = false; }); } catch (error) { - if (!mounted) { + if (!mounted || + generation != _previewLoadGeneration || + sessionKey != widget.sessionKey || + relativePath != _selectedEntry?.relativePath) { return; } setState(() { diff --git a/test/features/assistant/assistant_artifact_sidebar_test.dart b/test/features/assistant/assistant_artifact_sidebar_test.dart index 4e26cbe5..8de53fa7 100644 --- a/test/features/assistant/assistant_artifact_sidebar_test.dart +++ b/test/features/assistant/assistant_artifact_sidebar_test.dart @@ -1,3 +1,5 @@ +import 'dart:async'; + import 'package:flutter/material.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:xworkmate/runtime/assistant_artifacts.dart'; @@ -46,6 +48,85 @@ void main() { expect(find.text('artifact-2.txt'), findsAtLeastNWidgets(1)); }); + testWidgets( + 'clears stale artifacts and ignores late snapshot after task switch', + (tester) async { + final firstSnapshot = Completer(); + var sessionKey = 'task-a'; + var workspacePath = '/tmp/task-a'; + + Future loadSnapshot() { + final capturedSessionKey = sessionKey; + if (capturedSessionKey == 'task-a') { + return firstSnapshot.future; + } + return Future.value( + AssistantArtifactSnapshot( + workspacePath: '/tmp/task-b', + workspaceKind: WorkspaceRefKind.localPath, + fileEntries: const [ + AssistantArtifactEntry( + id: 'task-b-entry', + label: 'task-b.md', + relativePath: 'task-b.md', + kind: AssistantArtifactEntryKind.file, + mimeType: 'text/markdown', + previewable: true, + workspacePath: '/tmp/task-b', + ), + ], + ), + ); + } + + await tester.pumpWidget( + _buildTestApp( + sessionKey: sessionKey, + workspacePath: workspacePath, + artifactSyncAtMs: 1, + loadSnapshot: loadSnapshot, + ), + ); + await tester.pump(); + + sessionKey = 'task-b'; + workspacePath = '/tmp/task-b'; + await tester.pumpWidget( + _buildTestApp( + sessionKey: sessionKey, + workspacePath: workspacePath, + artifactSyncAtMs: 1, + loadSnapshot: loadSnapshot, + ), + ); + await tester.pump(); + + expect(find.text('task-a.md'), findsNothing); + + firstSnapshot.complete( + AssistantArtifactSnapshot( + workspacePath: '/tmp/task-a', + workspaceKind: WorkspaceRefKind.localPath, + fileEntries: const [ + AssistantArtifactEntry( + id: 'task-a-entry', + label: 'task-a.md', + relativePath: 'task-a.md', + kind: AssistantArtifactEntryKind.file, + mimeType: 'text/markdown', + previewable: true, + workspacePath: '/tmp/task-a', + ), + ], + ), + ); + await tester.pumpAndSettle(); + + expect(find.text('task-a.md'), findsNothing); + expect(find.text('task-b.md'), findsAtLeastNWidgets(1)); + }, + ); + testWidgets('keeps polling partial artifact snapshots', (tester) async { var loadCount = 0; @@ -242,6 +323,8 @@ void main() { } Widget _buildTestApp({ + String sessionKey = 'unit-fixture-task-a', + String workspacePath = '/tmp/thread', required double artifactSyncAtMs, String artifactSyncStatus = '', required Future Function() loadSnapshot, @@ -256,9 +339,9 @@ Widget _buildTestApp({ width: 460, height: 640, child: AssistantArtifactSidebar( - sessionKey: 'unit-fixture-task-a', + sessionKey: sessionKey, threadTitle: 'Thread', - workspacePath: '/tmp/thread', + workspacePath: workspacePath, workspaceKind: WorkspaceRefKind.localPath, artifactSyncAtMs: artifactSyncAtMs, artifactSyncStatus: artifactSyncStatus,