Sync artifact sidebar with selected task

This commit is contained in:
Haitao Pan 2026-06-07 12:13:58 +08:00
parent 867629900a
commit 43db901e6f
2 changed files with 120 additions and 11 deletions

View File

@ -76,6 +76,8 @@ class _AssistantArtifactSidebarState extends State<AssistantArtifactSidebar> {
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<AssistantArtifactSidebar> {
@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<AssistantArtifactSidebar> {
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<AssistantArtifactSidebar> {
await _loadPreview(nextSelected);
}
} catch (error) {
if (!mounted) {
if (!mounted ||
generation != _snapshotLoadGeneration ||
sessionKey != widget.sessionKey) {
return;
}
setState(() {
@ -555,13 +572,19 @@ class _AssistantArtifactSidebarState extends State<AssistantArtifactSidebar> {
}
Future<void> _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<AssistantArtifactSidebar> {
_loadingPreview = false;
});
} catch (error) {
if (!mounted) {
if (!mounted ||
generation != _previewLoadGeneration ||
sessionKey != widget.sessionKey ||
relativePath != _selectedEntry?.relativePath) {
return;
}
setState(() {

View File

@ -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<AssistantArtifactSnapshot>();
var sessionKey = 'task-a';
var workspacePath = '/tmp/task-a';
Future<AssistantArtifactSnapshot> loadSnapshot() {
final capturedSessionKey = sessionKey;
if (capturedSessionKey == 'task-a') {
return firstSnapshot.future;
}
return Future<AssistantArtifactSnapshot>.value(
AssistantArtifactSnapshot(
workspacePath: '/tmp/task-b',
workspaceKind: WorkspaceRefKind.localPath,
fileEntries: const <AssistantArtifactEntry>[
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>[
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<AssistantArtifactSnapshot> 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,