diff --git a/AGENTS.md b/AGENTS.md index daa9847..0eb6bb6 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -616,6 +616,21 @@ machine belongs in `~/.claude/TOOLCHAIN.md` (toolchain versions) or toggle and used to force both places back to zero the moment the platform says the keyboard is gone, whatever the animated value still claims. + **The guard is a boolean; the inset itself must never be read in the + composable body.** That correction first shipped as a `padding(bottom = + ... imeInsets.getBottom(this) ...)` computed in `SessionScreen`, which + subscribes the whole screen to a value that changes every frame of the + animation: measured on the emulator at **16 full recompositions of + `SessionScreen` per keyboard open, against 1**, and it put the + transcript's position behind a recomposition while the composer's stayed + a draw-phase read of the same frame, so the two were only together while + that recomposition kept landing inside the frame. It is `.then(if (imeVisible) Modifier.imePadding() else + Modifier)` instead -- `imePadding` reads the inset in the layout phase, + which is what the comment above the transcript box means by "the whole of + what the keyboard re-measures", and dropping the modifier is the same + coercion to zero that the boolean was added for. The counter to check is + `session screen recomposed` in the debug button's report, which should + move by one across a keyboard open, not by the number of frames it took. - **The keyboard pans the window unless the activity opts into resize.** Without `android:windowSoftInputMode="adjustResize"`, opening the IME slides the whole window up (top bar off screen) instead of resizing — diff --git a/app/androidApp/src/main/kotlin/com/example/aiapp/SessionScreen.kt b/app/androidApp/src/main/kotlin/com/example/aiapp/SessionScreen.kt index ce51b22..837612b 100644 --- a/app/androidApp/src/main/kotlin/com/example/aiapp/SessionScreen.kt +++ b/app/androidApp/src/main/kotlin/com/example/aiapp/SessionScreen.kt @@ -21,6 +21,7 @@ import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.ime +import androidx.compose.foundation.layout.imePadding import androidx.compose.foundation.layout.isImeVisible import androidx.compose.foundation.layout.navigationBars import androidx.compose.foundation.layout.padding @@ -1220,19 +1221,20 @@ fun SessionScreen(settings: ServerSettings, summary: SessionSummary, onBack: () // message -- and then the keyboard's, per frame of its animation. This modifier // is the whole of what the keyboard re-measures: the box's own size never // changes, so nothing above it is touched. - .padding( - bottom = - with(LocalDensity.current) { composerHeight.toDp() } + - // Not the `imePadding()` modifier: it trusts the same animated - // value `imeVisible` exists to correct, so it is exactly as prone - // to sticking open. Coerced to zero the moment the platform says - // the keyboard is gone, whatever the animation still claims. - if (imeVisible) { - with(LocalDensity.current) { imeInsets.getBottom(this).toDp() } - } else { - 0.dp - } - ) + .padding(bottom = with(LocalDensity.current) { composerHeight.toDp() }) + // The keyboard's room, and only while the platform says there is a keyboard -- + // dropping the modifier is what coerces the stuck-open animated value to zero, + // the same guard the composer's translation applies below. It has to stay a + // *modifier* rather than a padding computed here: `imePadding` reads the inset + // in the layout phase, so a keyboard frame re-measures this box and nothing + // else, while reading `imeInsets` in this composable body subscribes the whole + // of `SessionScreen` to a value that changes every frame of the animation. + // That cost 16 full recompositions of this screen per keyboard open, against + // one, and it made the transcript's position depend on a recomposition landing + // inside the frame that the inset changed -- which the composer's does not, + // since its translation is re-read in that frame's draw phase. When the + // recomposition misses, the transcript trails the composer up the screen. + .then(if (imeVisible) Modifier.imePadding() else Modifier) ) { Box(Modifier.fillMaxSize()) { TranscriptList(