@@ -46,7 +46,6 @@ import androidx.compose.runtime.key
4646import androidx.compose.runtime.mutableStateOf
4747import androidx.compose.runtime.remember
4848import androidx.compose.runtime.setValue
49−import androidx.compose.animation.togetherWith
5049import androidx.compose.ui.Alignment
5150import androidx.compose.ui.Modifier
5251import androidx.compose.ui.draw.clip
@@ -640,20 +639,31 @@ private fun UserBubble(
640639 verticalAlignment = Alignment.CenterVertically,
641640 ) {
642641 Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(6.dp)) {
643− // Crossfade the send-status slot so SENDING → FAILED → null transitions
644− // don't snap; a subtle fade reads as a polish detail rather than a pop.
645− // Only mounted when there's an actual status to show: a null status (the
642+ // Crossfade the send-status slot so SENDING → FAILED transitions don't
643+ // snap; a subtle fade reads as a polish detail rather than a pop. Only
644+ // mounted when there's an actual status to show: a null status (the
646645 // common case for any historical/confirmed message) renders nothing at all.
647− // Passing null into AnimatedContent crashes its measure policy (an NPE in
648− // the SubcomposeLayout's ChildData lookup when the null-state content is
649− // empty), so gate the whole composable on sendStatus != null.
646+ //
647+ // Crossfade is used instead of AnimatedContent deliberately. AnimatedContent
648+ // animates size transitions via SizeAnimationModifierNode, whose measure
649+ // policy reads AnimatedContentTransitionScopeImpl$ChildData.isTarget — a
650+ // MutableState<Boolean> that can be null at the moment the whole
651+ // AnimatedContent subtree is removed from composition mid-transition (which
652+ // is exactly what happens here when sendStatus flips SENDING/FAILED → null
653+ // on confirmation/dismiss). That null read throws an NPE inside
654+ // SizeAnimationModifierNode.measure during the next draw-phase display-list
655+ // rebuild, crashing the app when opening a chat or sending a message. The
656+ // prior fix (gating AnimatedContent on sendStatus != null) only avoided
657+ // passing null as targetState; it did not avoid the removal-mid-animation
658+ // tear. Crossfade has no SizeModifier/ChildData path, so removing it
659+ // mid-fade (when sendStatus → null) is safe — the fade simply ends early,
660+ // which is the intended visual (the indicator vanishes on confirmation).
650661 sendStatus?.let { status ->
651− androidx.compose.animation.AnimatedContent(
662+ androidx.compose.animation.Crossfade(
652663 targetState = status,
653− transitionSpec = {
654− androidx.compose.animation.fadeIn() togetherWith
655− androidx.compose.animation.fadeOut()
656− },
664+ animationSpec = androidx.compose.animation.core.tween(
665+ NetworkConfig.motionFadeDurationMs,
666+ ),
657667 label = "send_status",
658668 ) { current ->
659669 when (current) {