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
+26 -5
View File
@@ -17,19 +17,40 @@ import 'package:flutter/widgets.dart';
enum ClideRunStatus { running, success, error }
class ClideStatusIndicator extends StatelessWidget {
class ClideStatusIndicator extends StatefulWidget {
const ClideStatusIndicator({super.key, required this.status, this.size = 14});
final ClideRunStatus status;
final double size;
@override
State<ClideStatusIndicator> createState() => _ClideStatusIndicatorState();
}
class _ClideStatusIndicatorState extends State<ClideStatusIndicator> {
/// Monotonic id bumped on every status change, folded into the child key. A
/// per-status-only key collides inside [AnimatedSwitcher]'s Stack when a
/// status re-appears (running → success → running within the cross-fade)
/// while its previous glyph is still animating out — the exiting and entering
/// children share `ValueKey('running')` and trip the duplicate-key assertion
/// (T-326). The sequence makes each appearance's key unique; a same-status
/// rebuild keeps the key, so it still doesn't re-animate.
int _seq = 0;
@override
void didUpdateWidget(ClideStatusIndicator old) {
super.didUpdateWidget(old);
if (old.status != widget.status) _seq++;
}
@override
Widget build(BuildContext context) {
final tokens = ClideTheme.of(context).surface;
final (Widget glyph, String label) = switch (status) {
ClideRunStatus.running => (ClideSpinner(size: size, color: tokens.globalTextMuted, key: const ValueKey('running')), 'running'),
ClideRunStatus.success => (ClideIcon(const CheckIcon(), size: size, color: tokens.statusSuccess, key: const ValueKey('success')), 'succeeded'),
ClideRunStatus.error => (ClideIcon(const CloseIcon(), size: size, color: tokens.statusError, key: const ValueKey('error')), 'failed'),
final key = ValueKey('${widget.status.name}-$_seq');
final (Widget glyph, String label) = switch (widget.status) {
ClideRunStatus.running => (ClideSpinner(size: widget.size, color: tokens.globalTextMuted, key: key), 'running'),
ClideRunStatus.success => (ClideIcon(const CheckIcon(), size: widget.size, color: tokens.statusSuccess, key: key), 'succeeded'),
ClideRunStatus.error => (ClideIcon(const CloseIcon(), size: widget.size, color: tokens.statusError, key: key), 'failed'),
};
return Semantics(
label: label,