git.iko.soy/opencode-android
on master help sign in
Fix chat crash by replacing onSizeChanged contentPadding feedback loop with fixed banner heights
14e3ff0iko
app/src/main/java/soy/iko/opencode/data/network/NetworkConfig.kt+15
@@ -431,6 +431,21 @@ object NetworkConfig {
431 /** Fallback height (dp) for the connection banner when no text is available (e.g. exit fade). */
431 /** Fallback height (dp) for the connection banner when no text is available (e.g. exit fade). */
432 const val connectionBannerFallbackHeightDp = 44
432 const val connectionBannerFallbackHeightDp = 44
433
433
434+ /** Fixed height (dp) for the connection banner used for LazyColumn top content padding.
435+ * onSizeChanged was previously used to measure the banner's actual height and feed it
436+ * into the LazyColumn's contentPadding, but that created a feedback loop: the banner
437+ * appears → onSizeChanged fires → contentPadding changes → LazyColumn remeasures all
438+ * visible items during the same frame async content (DiffView, images) is settling
439+ * post-mount → draw-phase remeasure NPE. A fixed height eliminates the loop. At large
440+ * accessibility font scales the banner may slightly exceed this, but the 4dp margin
441+ * above it in topPad covers the common case. */
442+ const val connectionBannerHeightDp = 44
443+
444+ /** Fixed height (dp) for the in-conversation search bar used for LazyColumn top
445+ * content padding. Same rationale as connectionBannerHeightDp: onSizeChanged on the
446+ * search bar fed contentPadding and triggered the draw-phase remeasure crash. */
447+ const val chatSearchBarHeightDp = 56
448+
434 // --- Motion tokens (shared across screens) ---
449 // --- Motion tokens (shared across screens) ---
435
450
436 /** Duration (ms) for horizontal slide push/pop nav transitions. M3 spec recommends 200ms
451 /** Duration (ms) for horizontal slide push/pop nav transitions. M3 spec recommends 200ms
app/src/main/java/soy/iko/opencode/ui/chat/ChatScreen.kt+5−17
@@ -130,7 +130,6 @@ import androidx.compose.ui.input.key.isShiftPressed
130import androidx.compose.ui.input.key.key
130import androidx.compose.ui.input.key.key
131import androidx.compose.ui.input.key.onPreviewKeyEvent
131import androidx.compose.ui.input.key.onPreviewKeyEvent
132import androidx.compose.ui.input.key.type
132import androidx.compose.ui.input.key.type
133−import androidx.compose.ui.layout.onSizeChanged
134import androidx.compose.ui.platform.LocalContext
133import androidx.compose.ui.platform.LocalContext
135import androidx.compose.ui.platform.LocalDensity
134import androidx.compose.ui.platform.LocalDensity
136import androidx.compose.ui.platform.LocalHapticFeedback
135import androidx.compose.ui.platform.LocalHapticFeedback
@@ -1290,28 +1289,18 @@ fun ChatScreen(
1290 // (the same hazard FileViewScreen's overlay calls out). onSizeChanged only
1289 // (the same hazard FileViewScreen's overlay calls out). onSizeChanged only
1291 // fires while the banner is composed (visible), so gate the inset on
1290 // fires while the banner is composed (visible), so gate the inset on
1292 // bannerVisible and fall back to 44dp until the first measurement arrives.
1291 // bannerVisible and fall back to 44dp until the first measurement arrives.
1293− var bannerHeightPx by remember { mutableIntStateOf(0) }
1294− // Track the search bar height so the list's top padding can grow when search
1295− // is active — otherwise a scrolled-to match lands at index 0, hidden behind
1296− // the floating search bar (the "scroll-under-the-search-bar" bug).
1297− var searchbarHeightPx by remember { mutableIntStateOf(0) }
1298− val density = LocalDensity.current
1299 val topPad = if (bannerVisible) {
1292 val topPad = if (bannerVisible) {
1300− if (bannerHeightPx > 0) {
1293+ NetworkConfig.connectionBannerHeightDp.dp + 4.dp
1301− with(density) { bannerHeightPx.toDp() } + 4.dp
1302− } else {
1303− 44.dp
1304− }
1305 } else {
1294 } else {
1306 16.dp
1295 16.dp
1307− } + if (searchActive && searchbarHeightPx > 0) {
1296+ } + if (searchActive) {
1308− with(density) { searchbarHeightPx.toDp() } + 4.dp
1297+ NetworkConfig.chatSearchBarHeightDp.dp + 4.dp
1309 } else {
1298 } else {
1310 0.dp
1299 0.dp
1311 }
1300 }
1312 ConnectionBanner(
1301 ConnectionBanner(
1313 state = connectionState,
1302 state = connectionState,
1314− modifier = Modifier.align(Alignment.TopCenter).onSizeChanged { bannerHeightPx = it.height },
1303+ modifier = Modifier.align(Alignment.TopCenter),
1315 isOnline = isOnline,
1304 isOnline = isOnline,
1316 // On a hard endpoint failure, retry by forcing an SSE reconnect (which
1305 // On a hard endpoint failure, retry by forcing an SSE reconnect (which
1317 // re-seeds from REST). refreshMessages is the right recovery path
1306 // re-seeds from REST). refreshMessages is the right recovery path
@@ -1971,8 +1960,7 @@ fun ChatScreen(
1971 tonalElevation = 3.dp,
1960 tonalElevation = 3.dp,
1972 modifier = Modifier
1961 modifier = Modifier
1973 .padding(horizontal = 12.dp, vertical = 4.dp)
1962 .padding(horizontal = 12.dp, vertical = 4.dp)
1974− .fillMaxWidth()
1963+ .fillMaxWidth(),
1975− .onSizeChanged { searchbarHeightPx = it.height },
1976 ) {
1964 ) {
1977 // Focus the search field when the bar appears (from the overflow menu's
1965 // Focus the search field when the bar appears (from the overflow menu's
1978 // "Find in conversation" or Ctrl+F), so the user can start typing
1966 // "Find in conversation" or Ctrl+F), so the user can start typing