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
@@ -260,8 +260,12 @@ class _ConversationViewState extends State<ConversationView> {
itemCount: groups.length,
itemBuilder: (context, i) {
final g = groups[i];
// Stable keys pin each card's State (collapse/hover, cluster expand)
// to its logical item, so streaming result updates that reshape the
// visible list don't reattach State to the wrong card (T-285).
return switch (g) {
StickyItem(:final item) => _ConversationTurn(
key: ValueKey('turn.${item.uuid}'),
item: item,
tokens: tokens,
toolUseOutcomes: widget.toolUseOutcomes,
@@ -271,6 +275,7 @@ class _ConversationViewState extends State<ConversationView> {
runByToolUseId: fold.runByToolUseId,
),
FoldedCluster(:final items) => _ActivityCard(
key: ValueKey('cluster.${items.first.uuid}'),
items: items,
tokens: tokens,
toolUseOutcomes: widget.toolUseOutcomes,
@@ -310,6 +315,7 @@ void _openRecord(BuildContext context, String id) {
/// One conversation item, rendered by kind.
class _ConversationTurn extends StatelessWidget {
const _ConversationTurn({
super.key,
required this.item,
required this.tokens,
this.toolUseOutcomes = const <String, bool>{},
@@ -543,6 +549,7 @@ class _ConversationTurn extends StatelessWidget {
children: [
for (final r in runItems)
_ConversationTurn(
key: ValueKey('run.${r.uuid}'),
item: r,
tokens: tokens,
toolUseOutcomes: toolUseOutcomes,
@@ -650,6 +657,7 @@ class _ConversationTurn extends StatelessWidget {
/// background toggles collapse. Stateless — the holder owns the expand state.
class _ActivityCard extends StatelessWidget {
const _ActivityCard({
super.key,
required this.items,
required this.tokens,
required this.toolUseOutcomes,
@@ -676,6 +684,7 @@ class _ActivityCard extends StatelessWidget {
children: [
for (final item in items)
_ConversationTurn(
key: ValueKey('step.${item.uuid}'),
item: item,
tokens: tokens,
toolUseOutcomes: toolUseOutcomes,