@@ -183,7 +183,7 @@ import soy.iko.opencode.ui.components.DiffView
183183import soy.iko.opencode.ui.components.PaletteAction
184184import soy.iko.opencode.ui.components.LocalReducedMotion
185185import soy.iko.opencode.ui.components.LocalSearchHighlight
186−import soy.iko.opencode.ui.components.reducedMotionAnimateItem
186+
187187import soy.iko.opencode.ui.components.copyToClipboard
188188import soy.iko.opencode.ui.components.LocalRelativeTimeTick
189189import soy.iko.opencode.ui.components.rememberRelativeTimeTick
@@ -1483,30 +1483,28 @@ fun ChatScreen(
14831483 val isFocusedMatch = focusedMatchId != null &&
14841484 item is MessageListItem.Message &&
14851485 item.message.info.id == focusedMatchId
1486− // Default placement animation so inserted/moved rows glide in. Skipped
1487− // entirely under reduced motion so a streaming message growing in place
1488− // doesn't animate on every token (and respects the a11y preference).
1486+ // No animateItem() here. It is backed by SizeAnimationModifierNode, which
1487+ // forces a draw-phase remeasure (the display-list rebuild triggered by
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).
14891493 //
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
1500− // markdown library's animateContentSize and MessageBubble's send-status
1501− // AnimatedContent; animateItem() on a continuously-resizing row is the
1502− // remaining instance. Non-streaming rows (including the one that just
1503− // finished streaming) keep the glide animation since their size is stable.
1504− val isStreamingMessage = item is MessageListItem.Message &&
1505− running &&
1506− item.message.info.id == lastMessageId
1494+ // This is the same crash class previously fixed for the markdown library's
1495+ // animateContentSize and MessageBubble's send-status AnimatedContent. An
1496+ // earlier fix kept animateItem() but skipped it for the actively-streaming
1497+ // row; that was insufficient because the crash also fires when OPENING a
1498+ // chat (running = false, so every row kept animateItem()): historical rows
1499+ // with async-loading content (DiffView's produceState starts empty and
1500+ // fills a frame later; RemoteImage loading; large markdown) change size
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.
15071506 Box(
15081507 Modifier
1509− .then(if (isStreamingMessage) Modifier else reducedMotionAnimateItem())
15101508 .then(
15111509 if (isFocusedMatch) {
15121510 Modifier.background(
@@ -1799,11 +1797,15 @@ fun ChatScreen(
17991797 }
18001798 Row(
18011799 verticalAlignment = Alignment.CenterVertically,
1802− // Animate this slot in/out so the working row doesn't pop in and
1803− // jolt the message list when a run starts/stops — matching the
1804− // AnimatedVisibility used by every other transient banner/FAB here.
1800+ // No animateItem() here for the same reason as the message rows
1801+ // above: the __typing row's size changes (the elapsed-time Text
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.
18051808 modifier = Modifier
1806− .then(reducedMotionAnimateItem())
18071809 .padding(vertical = 4.dp),
18081810 ) {
18091811 // Merge the indicator + label + clock into one TalkBack node so