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
|
logged" failures). Keep every exercise of a logging code path under the
|
||||||
one capturing subscriber — that's why the auth middleware has a single
|
one capturing subscriber — that's why the auth middleware has a single
|
||||||
combined gating+logging test.
|
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.**
|
- **The keyboard pans the window unless the activity opts into resize.**
|
||||||
Without `android:windowSoftInputMode="adjustResize"`, opening the IME
|
Without `android:windowSoftInputMode="adjustResize"`, opening the IME
|
||||||
slides the whole window up (top bar off screen) instead of resizing —
|
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.gestures.awaitFirstDown
|
||||||
import androidx.compose.foundation.layout.Box
|
import androidx.compose.foundation.layout.Box
|
||||||
import androidx.compose.foundation.layout.Column
|
import androidx.compose.foundation.layout.Column
|
||||||
|
import androidx.compose.foundation.layout.ExperimentalLayoutApi
|
||||||
import androidx.compose.foundation.layout.Row
|
import androidx.compose.foundation.layout.Row
|
||||||
import androidx.compose.foundation.layout.Spacer
|
import androidx.compose.foundation.layout.Spacer
|
||||||
import androidx.compose.foundation.layout.WindowInsets
|
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.fillMaxWidth
|
||||||
import androidx.compose.foundation.layout.height
|
import androidx.compose.foundation.layout.height
|
||||||
import androidx.compose.foundation.layout.ime
|
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.navigationBars
|
||||||
import androidx.compose.foundation.layout.padding
|
import androidx.compose.foundation.layout.padding
|
||||||
import androidx.compose.foundation.layout.size
|
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
|
// 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.
|
// 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
|
@Composable
|
||||||
fun SessionScreen(settings: ServerSettings, summary: SessionSummary, onBack: () -> Unit) {
|
fun SessionScreen(settings: ServerSettings, summary: SessionSummary, onBack: () -> Unit) {
|
||||||
DebugStats.count("session screen recomposed")
|
DebugStats.count("session screen recomposed")
|
||||||
@@ -1060,6 +1064,23 @@ fun SessionScreen(settings: ServerSettings, summary: SessionSummary, onBack: ()
|
|||||||
var composerHeight by remember { mutableIntStateOf(0) }
|
var composerHeight by remember { mutableIntStateOf(0) }
|
||||||
val imeInsets = WindowInsets.ime
|
val imeInsets = WindowInsets.ime
|
||||||
val navInsets = WindowInsets.navigationBars
|
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()) {
|
Box(Modifier.fillMaxSize()) {
|
||||||
Column(Modifier.fillMaxSize()) {
|
Column(Modifier.fillMaxSize()) {
|
||||||
Row(
|
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
|
// 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
|
// is the whole of what the keyboard re-measures: the box's own size never
|
||||||
// changes, so nothing above it is touched.
|
// changes, so nothing above it is touched.
|
||||||
.padding(bottom = with(LocalDensity.current) { composerHeight.toDp() })
|
.padding(
|
||||||
.imePadding()
|
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()) {
|
Box(Modifier.fillMaxSize()) {
|
||||||
TranscriptList(
|
TranscriptList(
|
||||||
@@ -1542,10 +1574,17 @@ fun SessionScreen(settings: ServerSettings, summary: SessionSummary, onBack: ()
|
|||||||
.fillMaxWidth()
|
.fillMaxWidth()
|
||||||
.onSizeChanged { composerHeight = it.height }
|
.onSizeChanged { composerHeight = it.height }
|
||||||
.graphicsLayer {
|
.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 =
|
translationY =
|
||||||
-(imeInsets.getBottom(this) - navInsets.getBottom(this))
|
if (imeVisible) {
|
||||||
.coerceAtLeast(0)
|
-(imeInsets.getBottom(this) - navInsets.getBottom(this))
|
||||||
.toFloat()
|
.coerceAtLeast(0)
|
||||||
|
.toFloat()
|
||||||
|
} else {
|
||||||
|
0f
|
||||||
|
}
|
||||||
}
|
}
|
||||||
.background(MaterialTheme.colorScheme.background)
|
.background(MaterialTheme.colorScheme.background)
|
||||||
) {
|
) {
|
||||||
|
|||||||
Reference in new issue
Block a user