@@ -183,7 +183,7 @@ import soy.iko.opencode.ui.components.DiffView
183import soy.iko.opencode.ui.components.PaletteAction
183import soy.iko.opencode.ui.components.PaletteAction
184import soy.iko.opencode.ui.components.LocalReducedMotion
184import soy.iko.opencode.ui.components.LocalReducedMotion
185import soy.iko.opencode.ui.components.LocalSearchHighlight
185import soy.iko.opencode.ui.components.LocalSearchHighlight
186−import soy.iko.opencode.ui.components.reducedMotionAnimateItem
186+
187import soy.iko.opencode.ui.components.copyToClipboard
187import soy.iko.opencode.ui.components.copyToClipboard
188import soy.iko.opencode.ui.components.LocalRelativeTimeTick
188import soy.iko.opencode.ui.components.LocalRelativeTimeTick
189import soy.iko.opencode.ui.components.rememberRelativeTimeTick
189import soy.iko.opencode.ui.components.rememberRelativeTimeTick
@@ -1483,30 +1483,28 @@ fun ChatScreen(
1483 val isFocusedMatch = focusedMatchId != null &&
1483 val isFocusedMatch = focusedMatchId != null &&
1484 item is MessageListItem.Message &&
1484 item is MessageListItem.Message &&
1485 item.message.info.id == focusedMatchId
1485 item.message.info.id == focusedMatchId
1486− // Default placement animation so inserted/moved rows glide in. Skipped
1486+ // No animateItem() here. It is backed by SizeAnimationModifierNode, which
1487− // entirely under reduced motion so a streaming message growing in place
1487+ // forces a draw-phase remeasure (the display-list rebuild triggered by
1488− // doesn't animate on every token (and respects the a11y preference).
1488+ // AndroidComposeView.dispatchDraw) when its target's size changes. That
1489+ // remeasure runs inside the draw pass and reads Placeable.getWidth()/
1490+ // getHeight() of a child whose measured size hasn't been committed yet —
1491+ // an NPE in PaddingValuesModifier.measure / FillNode.measure (the R8 map
1492+ // marks both sites with a throws(NullPointerException) rewriteFrame).
1490− // Also skipped for the actively-streaming message even at full motion.
1491− // animateItem() is backed by SizeAnimationModifierNode, which forces a
1492− // draw-phase remeasure (the display-list rebuild triggered by
1493− // AndroidComposeView.dispatchDraw) when its target's size changes. While
1494− // the streaming bubble grows token-by-token, that remeasure runs inside
1495− // the draw pass and reads Placeable.getWidth()/getHeight() of a child
1496− // whose measured size hasn't been committed yet — an NPE in
1497− // PaddingValuesModifier.measure / FillNode.measure (the R8 map marks both
1498− // sites with a throws(NullPointerException) rewriteFrame). This is the
1499− // same class of draw-phase-remeasure crash previously fixed for the
1494+ // This is the same crash class previously fixed for the markdown library's
1500− // markdown library's animateContentSize and MessageBubble's send-status
1495+ // animateContentSize and MessageBubble's send-status AnimatedContent. An
1501− // AnimatedContent; animateItem() on a continuously-resizing row is the
1496+ // earlier fix kept animateItem() but skipped it for the actively-streaming
1502− // remaining instance. Non-streaming rows (including the one that just
1497+ // row; that was insufficient because the crash also fires when OPENING a
1503− // finished streaming) keep the glide animation since their size is stable.
1498+ // chat (running = false, so every row kept animateItem()): historical rows
1504− val isStreamingMessage = item is MessageListItem.Message &&
1499+ // with async-loading content (DiffView's produceState starts empty and
1505− running &&
1500+ // fills a frame later; RemoteImage loading; large markdown) change size
1506− item.message.info.id == lastMessageId
1501+ // after mount and trigger the same draw-phase remeasure. animateItem() is
1502+ // fundamentally unsafe on any chat row whose size can change post-mount,
1503+ // so it is removed entirely. The glide-in animation it provided is
1504+ // decorative (the list already auto-scrolls to new messages) and is not
1505+ // worth a 4th round of this crash.
1508 Modifier
1507 Modifier
1509− .then(if (isStreamingMessage) Modifier else reducedMotionAnimateItem())
1511 if (isFocusedMatch) {
1509 if (isFocusedMatch) {
1512 Modifier.background(
1510 Modifier.background(
@@ -1799,11 +1797,15 @@ fun ChatScreen(
1801 verticalAlignment = Alignment.CenterVertically,
1799 verticalAlignment = Alignment.CenterVertically,
1802− // Animate this slot in/out so the working row doesn't pop in and
1800+ // No animateItem() here for the same reason as the message rows
1803− // jolt the message list when a run starts/stops — matching the
1801+ // above: the __typing row's size changes (the elapsed-time Text
1804− // AnimatedVisibility used by every other transient banner/FAB here.
1802+ // grows from "0:00" to "M:SS" and the spinner mounts on first
1803+ // frame), and animateItem()'s SizeAnimationModifierNode forces a
1804+ // draw-phase remeasure that NPEs on Placeable.getWidth(). The
1805+ // working row is mounted/unmounted as a whole item (key="__typing")
1806+ // by LazyColumn, which already crossfades the appearance; the
1807+ // placement animation is not needed.
1805 modifier = Modifier
1808 modifier = Modifier
1806− .then(reducedMotionAnimateItem())
1807 .padding(vertical = 4.dp),
1809 .padding(vertical = 4.dp),
1809 // Merge the indicator + label + clock into one TalkBack node so
1811 // Merge the indicator + label + clock into one TalkBack node so