Merge remote-tracking branch 'origin/main'

This commit is contained in:
iris committed 2026-09-01 00:32:56 -04:00
commit 7525fc925a
2 files changed
+59 -3

No files matched your search

+17
View File
@@ -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 =
if (imeVisible) {
-(imeInsets.getBottom(this) - navInsets.getBottom(this))
.coerceAtLeast(0)
.toFloat()
} else {
0f
}
}
.background(MaterialTheme.colorScheme.background)
) {