fix ClideStatusIndicator duplicate-key crash on rapid status flips (T-326)

AnimatedSwitcher cross-fades exiting + entering glyphs for 200ms; with a
fixed per-status ValueKey, flipping a status back (running -> success ->
running within the fade, e.g. two bound Claude panes) left two children
keyed 'running' in the Stack -> "Duplicate keys found" + a cascade of
follow-on framework errors. Make the indicator stateful and fold a
per-change sequence counter into the key, so each appearance is unique and
a same-status rebuild still doesn't re-animate. Regression test flips
status mid-cross-fade with real animations.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-06-10 15:00:58 +02:00
co-authored by Claude Opus 4.8
parent 3097d5f63b
commit 9607124c2b
6 changed files with 85 additions and 5 deletions
@@ -57,5 +57,37 @@ void main() {
await tester.pumpAndSettle();
expect(iconWith(CloseIcon), findsOneWidget);
});
testWidgets('rapid status flips within the cross-fade do not collide (T-326)', (tester) async {
// Real animations (no disableAnimations) so the 200ms cross-fade overlaps
// exiting and entering glyphs — the condition that tripped a duplicate
// 'running' key. Pre-fix this threw "Duplicate keys found".
var status = ClideRunStatus.running;
late StateSetter set;
await tester.pumpWidget(harness(
f,
Center(
child: StatefulBuilder(builder: (ctx, s) {
set = s;
return ClideStatusIndicator(status: status);
}),
),
));
void flip(ClideRunStatus next) => set(() => status = next);
flip(ClideRunStatus.success);
await tester.pump(const Duration(milliseconds: 40));
flip(ClideRunStatus.running); // a 2nd 'running' while the 1st is still exiting
await tester.pump(const Duration(milliseconds: 40));
flip(ClideRunStatus.error);
await tester.pump(const Duration(milliseconds: 40));
flip(ClideRunStatus.success); // land on a static glyph
await tester.pump(const Duration(milliseconds: 40));
expect(tester.takeException(), isNull);
// Unmount so any in-flight spinner controller disposes (no pending timers).
await tester.pumpWidget(const SizedBox());
});
});
}