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) ) {