key conversation list items so card state survives result reshapes (T-285)
The conversation ListView.builder built its items (_ConversationTurn, _ActivityCard) with no keys, so Flutter matched the stateful subtrees inside them (ConversationCard collapse/hover/focus; ClideHolderCard expand state) to widgets by POSITION. The visible list reshapes exactly when a tool result lands — T-262 folds a success result into its call card and suppresses the standalone result, errors append a sticky card, clusters re-fold — so after a read/write completed, a card's collapse/hover state (or a cluster's identity) could reattach to the wrong card. Give each list item a stable ValueKey from its identity: sticky item by item.uuid, folded cluster by its first item's uuid (namespaced turn./cluster./ run./step. so the four call sites can't collide), plus super.key on the _ConversationTurn/_ActivityCard constructors. Tests: unfolded cards carry per-item keys; a folded cluster carries its first-item key. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -15,7 +15,7 @@ import 'package:clide/builtin/claude/src/transcript_reader.dart';
|
||||
import 'package:clide/kernel/src/events/message_bus.dart';
|
||||
import 'package:clide/widgets/widgets.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:flutter/widgets.dart' show Image, FileImage;
|
||||
import 'package:flutter/widgets.dart' show Image, FileImage, ValueKey;
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
|
||||
import '../../helpers/kernel_fixture.dart';
|
||||
@@ -172,6 +172,41 @@ void main() {
|
||||
expect(find.text('Waiting for Claude…'), findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets('unfolded conversation cards carry stable per-item identity keys (T-285)', (tester) async {
|
||||
tester.view.physicalSize = const Size(900, 800);
|
||||
tester.view.devicePixelRatio = 1.0;
|
||||
addTearDown(tester.view.resetPhysicalSize);
|
||||
addTearDown(tester.view.resetDevicePixelRatio);
|
||||
final stream = StreamController<ConversationItem>.broadcast();
|
||||
final c = ConversationController(stream: stream.stream);
|
||||
addTearDown(c.dispose);
|
||||
await tester.pumpWidget(harness(f, ConversationView(controller: c, foldLevel: FoldLevel.none)));
|
||||
stream.add(AssistantToolUse(uuid: 'A', timestamp: _t, isSidechain: false, toolUseId: 'A', name: 'Bash', input: const {'command': 'echo a'}));
|
||||
stream.add(AssistantThinkingMessage(uuid: 'B', timestamp: _t, isSidechain: false, thinking: 'thinking body'));
|
||||
await tester.pumpAndSettle();
|
||||
// Each top-level card is keyed by its item uuid so a streaming reshape
|
||||
// pins card State (collapse/hover) to its logical item, not its position.
|
||||
expect(find.byKey(const ValueKey('turn.A')), findsOneWidget);
|
||||
expect(find.byKey(const ValueKey('turn.B')), findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets('a folded activity cluster carries a stable identity key (T-285)', (tester) async {
|
||||
tester.view.physicalSize = const Size(900, 800);
|
||||
tester.view.devicePixelRatio = 1.0;
|
||||
addTearDown(tester.view.resetPhysicalSize);
|
||||
addTearDown(tester.view.resetDevicePixelRatio);
|
||||
final stream = StreamController<ConversationItem>.broadcast();
|
||||
final c = ConversationController(stream: stream.stream);
|
||||
addTearDown(c.dispose);
|
||||
await tester.pumpWidget(harness(f, ConversationView(controller: c, foldLevel: FoldLevel.tools)));
|
||||
stream.add(AssistantToolUse(uuid: 'A', timestamp: _t, isSidechain: false, toolUseId: 'A', name: 'Bash', input: const {'command': 'echo a'}));
|
||||
stream.add(AssistantToolUse(uuid: 'B', timestamp: _t, isSidechain: false, toolUseId: 'B', name: 'Read', input: const {'file_path': '/a'}));
|
||||
await tester.pumpAndSettle();
|
||||
// The cluster is keyed by its first item's uuid, so re-folding never
|
||||
// reattaches the holder's expand State to the wrong cluster by position.
|
||||
expect(find.byKey(const ValueKey('cluster.A')), findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets('clicking a bare T-ref in a message opens the tickets reader (T-279)', (tester) async {
|
||||
Message? opened;
|
||||
final sub = f.services.messages.subscribe(publisher: 'builtin.tickets', channel: 'selection').listen((m) => opened = m);
|
||||
|
||||
Reference in New Issue
Block a user