Merge remote-tracking branch 'origin/main'
This commit is contained in:
commit
7525fc925a
2 files changed
+62
-6
No files matched your search
@@ -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 —
|
||||
|
||||
@@ -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)
|
||||
) {
|
||||
|
||||
Reference in new issue
Block a user