From 858b4148ad9601b93a9f955ae23175eebd14b62d Mon Sep 17 00:00:00 2001 From: iris <2+iris@noreply.localhost> Date: Tue, 1 Sep 2026 00:26:19 -0400 Subject: [PATCH] Stop the composer sticking above the bottom of the screen after the keyboard closes Reported: closing the keyboard on purpose, while a reply was streaming, left the composer floating above the bottom of the screen for the rest of the session -- a bar of background colour under it, nothing that closed it. The composer's position and the transcript's bottom padding are both driven by the raw, animated `WindowInsets.ime` value, read inside a `graphicsLayer` block specifically so a keyboard frame invalidates layer properties only rather than recomposing the whole screen (see the layout note above it). That value is carried by a `WindowInsetsAnimationCallback`, and a callback interrupted mid-flight leaves whatever it was carrying frozen at its last value with nothing left to correct it -- no further keyboard movement is coming to fire the callback again. A streaming reply invalidates the view every frame, which is exactly the condition known to starve a running callback of its `onEnd`, and that is the "actively responding sessions" correlate in the report. `WindowInsets.isImeVisible` doesn't share that failure mode: it is set once, from the platform's own start/end of the transition, over a different path (`onApplyWindowInsets` rather than the animation callback) -- so it cannot get stuck mid-animation the way the interpolated value can. Read once per keyboard toggle and used to force both the composer's translation and the transcript's reserved padding back to exactly zero the moment the platform says the keyboard is gone, whatever the animated value still claims. Checked on the emulator with an actively streaming echo session: opened the keyboard, closed it with the system back gesture while the reply kept growing, and the composer settled flush at the bottom with the transcript filling the freed space, both immediately and after the keyboard was reopened and closed again. --- AGENTS.md | 17 +++++++ .../kotlin/com/example/aiapp/SessionScreen.kt | 51 ++++++++++++++++--- 2 files changed, 62 insertions(+), 6 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 193cce7..daa9847 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -599,6 +599,23 @@ machine belongs in `~/.claude/TOOLCHAIN.md` (toolchain versions) or logged" failures). Keep every exercise of a logging code path under the one capturing subscriber — that's why the auth middleware has a single combined gating+logging test. +- **The composer can get stuck floating above the bottom of the screen after + the keyboard closes, while a reply is streaming.** The composer's position + and the transcript's bottom padding are both driven by the raw, animated + `WindowInsets.ime` value read inside a `graphicsLayer` block, to avoid + recomposing the whole screen every frame of the keyboard's animation (see + the layout note above it). That animation is carried by a + `WindowInsetsAnimationCallback`, and a callback interrupted mid-flight + leaves whatever it was carrying frozen at its last value with nothing + left to correct it, since no further keyboard movement will fire it + again. A streaming reply invalidates the view every frame, which is + exactly the condition known to starve that callback of its `onEnd`. + `WindowInsets.isImeVisible` (`ExperimentalLayoutApi`) does not share the + failure mode -- it is set once, from the platform's own start/end of the + transition over a different path -- so it is read once per keyboard + 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 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 3105593..ce51b22 100644 --- a/app/androidApp/src/main/kotlin/com/example/aiapp/SessionScreen.kt +++ b/app/androidApp/src/main/kotlin/com/example/aiapp/SessionScreen.kt @@ -13,6 +13,7 @@ import androidx.compose.foundation.gestures.awaitEachGesture import androidx.compose.foundation.gestures.awaitFirstDown import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.ExperimentalLayoutApi import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.WindowInsets @@ -20,7 +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 import androidx.compose.foundation.layout.size @@ -211,6 +212,9 @@ private fun Modifier.holdTopEdge(key: Any, held: TopEdgeHold, hold: (Int) -> Uni // TranscriptItems.kt: it is pure event folding with no screen in it, and the two halves changed // for unrelated reasons while they shared this file. +// isImeVisible: see the comment beside `imeVisible` below for why the keyboard's own +// self-correction needs it. +@OptIn(ExperimentalLayoutApi::class) @Composable fun SessionScreen(settings: ServerSettings, summary: SessionSummary, onBack: () -> Unit) { DebugStats.count("session screen recomposed") @@ -1060,6 +1064,23 @@ fun SessionScreen(settings: ServerSettings, summary: SessionSummary, onBack: () var composerHeight by remember { mutableIntStateOf(0) } val imeInsets = WindowInsets.ime val navInsets = WindowInsets.navigationBars + // Ground truth for whether the keyboard is up, independent of `imeInsets` -- which is what + // rescues this from a real fault rather than merely reading the same thing twice. `imeInsets` + // is driven by the animation as it interpolates and is dispatched every frame; `isImeVisible` + // is dispatched once, from the platform's own start/end of the transition, over a different + // path (`onApplyWindowInsets` rather than the animation callback). + // + // Reported from a phone: closing the keyboard on purpose, while a reply was streaming, left + // the composer floating above the bottom of the screen for the rest of the session, with a + // bar of background colour showing under it and nothing that closed it. The likely cause is + // the animation callback that carries `imeInsets` back to zero being interrupted mid-flight -- + // a streaming reply invalidates the view every frame, which is exactly the condition known to + // starve a running `WindowInsetsAnimationCallback` of its `onEnd` -- and once that happens the + // stale, partway value it leaves behind has nothing left to correct it: the keyboard is not + // going to move again on its own. `isImeVisible` does not share that failure mode (it is not + // interpolated, so there is nothing for a dropped frame to interrupt), so it is what both + // places below fall back to. + val imeVisible = WindowInsets.isImeVisible Box(Modifier.fillMaxSize()) { Column(Modifier.fillMaxSize()) { Row( @@ -1199,8 +1220,19 @@ 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() }) - .imePadding() + .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 + } + ) ) { Box(Modifier.fillMaxSize()) { TranscriptList( @@ -1542,10 +1574,17 @@ fun SessionScreen(settings: ServerSettings, summary: SessionSummary, onBack: () .fillMaxWidth() .onSizeChanged { composerHeight = it.height } .graphicsLayer { + // See `imeVisible` above: a callback interrupted mid-close leaves this stuck + // reading a stale height, and without the guard the composer floats above the + // bottom of the screen for good. translationY = - -(imeInsets.getBottom(this) - navInsets.getBottom(this)) - .coerceAtLeast(0) - .toFloat() + if (imeVisible) { + -(imeInsets.getBottom(this) - navInsets.getBottom(this)) + .coerceAtLeast(0) + .toFloat() + } else { + 0f + } } .background(MaterialTheme.colorScheme.background) ) {