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:
2026-06-08 17:37:38 +02:00
co-authored by Claude Opus 4.8
parent d5ec85932d
commit 1ae5cc609c
5 changed files with 82 additions and 1 deletions
@@ -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);