Stop the keyboard re-laying-out the whole screen, and float the composer
Opening the keyboard was late on 82% of frames on the Pixel while the transcript itself cost 0.25ms of each -- the cost was everywhere else. imePadding() sat on the activity's root box, so every frame of the IME animation resized the entire tree: measured on the emulator (with the new app-root timers) as a full re-measure (4.1ms), re-place (2.5ms) and re-record (1.0ms) of everything, ~34 frames per open, while the newly added recomposition counters read zero -- pure layout traversal, no recomposition to fix. So the keyboard now touches only what actually moves. The session screen's composer (status row, suggestions, attachments, text field, buttons) is a bottom-aligned overlay on its own layer, translated by the IME inset read inside the graphicsLayer block -- a keyboard frame invalidates layer properties only. The transcript box reserves the overlay's measured height plus imePadding, and that modifier is the whole of what the keyboard re-measures: the box's own size never changes, so the header and everything above it are untouched. The overlay is opaque for the one frame between it growing and the reservation catching up. imePadding moved off the activity root onto AppRoot's other screens, which keep the old arrangement -- none of them has a keyboard open over anything that scrolls at 120Hz. Same five-open protocol on the emulator, before and after: the app root is now measured zero times (was 168), per-frame app work 7.6ms -> 1.9ms (transcript measure 1.2 + place 0.5 + record 0.2), draw-phase p90 9.6ms -> 5.0ms, waited p50 2.6ms -> 0.4ms. What is left per frame is the transcript's own one-box remeasure, whose children skip measurement because their width is unchanged. Verified the states the overlay could have broken: keyboard over a long and a two-message conversation (content hangs from the composer in both), a three-line draft growing the composer upward with the reserve following, slash suggestions stacking above the field, and the closed state identical to before. The app-root timers and the recomposition counters stay in: they are the difference between this report saying "draw is high" and saying where. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
1 parent
93ce66c5f0
commit
9477cd288a
4 files changed
+184
-39
No files matched your search
@@ -1,6 +1,8 @@
|
|||||||
package com.example.aiapp
|
package com.example.aiapp
|
||||||
|
|
||||||
import androidx.activity.compose.BackHandler
|
import androidx.activity.compose.BackHandler
|
||||||
|
import androidx.compose.foundation.layout.Box
|
||||||
|
import androidx.compose.foundation.layout.imePadding
|
||||||
import androidx.compose.foundation.layout.padding
|
import androidx.compose.foundation.layout.padding
|
||||||
import androidx.compose.material3.AlertDialog
|
import androidx.compose.material3.AlertDialog
|
||||||
import androidx.compose.material3.MaterialTheme
|
import androidx.compose.material3.MaterialTheme
|
||||||
@@ -93,6 +95,7 @@ fun AppRoot(settingsVersion: Int, openRequest: SessionOpenRequest?) {
|
|||||||
if (current == null) {
|
if (current == null) {
|
||||||
// Not enrolled yet: settings is the only usable screen. The QR
|
// Not enrolled yet: settings is the only usable screen. The QR
|
||||||
// path lands in MainActivity and recomposes from the top.
|
// path lands in MainActivity and recomposes from the top.
|
||||||
|
Box(Modifier.imePadding()) {
|
||||||
SettingsScreen(
|
SettingsScreen(
|
||||||
existing = null,
|
existing = null,
|
||||||
onSaved = { saved ->
|
onSaved = { saved ->
|
||||||
@@ -101,6 +104,7 @@ fun AppRoot(settingsVersion: Int, openRequest: SessionOpenRequest?) {
|
|||||||
},
|
},
|
||||||
onBack = null,
|
onBack = null,
|
||||||
)
|
)
|
||||||
|
}
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -148,8 +152,13 @@ fun AppRoot(settingsVersion: Int, openRequest: SessionOpenRequest?) {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Every screen but the session takes the keyboard as bottom padding here. The session
|
||||||
|
// screen deliberately does not: resizing a whole screen on every frame of the keyboard
|
||||||
|
// animation is the cost that made it lag, so it moves only its composer and transcript --
|
||||||
|
// see the layout note in SessionScreen.
|
||||||
when (val here = screen) {
|
when (val here = screen) {
|
||||||
is Screen.Main ->
|
is Screen.Main ->
|
||||||
|
Box(Modifier.imePadding()) {
|
||||||
MainScreen(
|
MainScreen(
|
||||||
settings = current,
|
settings = current,
|
||||||
reloadToken = reloadToken,
|
reloadToken = reloadToken,
|
||||||
@@ -161,6 +170,7 @@ fun AppRoot(settingsVersion: Int, openRequest: SessionOpenRequest?) {
|
|||||||
},
|
},
|
||||||
onSettings = { screen = Screen.Settings },
|
onSettings = { screen = Screen.Settings },
|
||||||
)
|
)
|
||||||
|
}
|
||||||
is Screen.Session ->
|
is Screen.Session ->
|
||||||
// Keyed on the id, because a different session is a different screen rather than this
|
// Keyed on the id, because a different session is a different screen rather than this
|
||||||
// one showing other rows. SessionScreen remembers a transcript, an open event stream, a
|
// one showing other rows. SessionScreen remembers a transcript, an open event stream, a
|
||||||
@@ -172,6 +182,7 @@ fun AppRoot(settingsVersion: Int, openRequest: SessionOpenRequest?) {
|
|||||||
SessionScreen(settings = current, summary = here.summary, onBack = goToMain)
|
SessionScreen(settings = current, summary = here.summary, onBack = goToMain)
|
||||||
}
|
}
|
||||||
is Screen.Spawn ->
|
is Screen.Spawn ->
|
||||||
|
Box(Modifier.imePadding()) {
|
||||||
SpawnScreen(
|
SpawnScreen(
|
||||||
settings = current,
|
settings = current,
|
||||||
onSpawned = { spawned ->
|
onSpawned = { spawned ->
|
||||||
@@ -180,7 +191,9 @@ fun AppRoot(settingsVersion: Int, openRequest: SessionOpenRequest?) {
|
|||||||
},
|
},
|
||||||
onBack = goToMain,
|
onBack = goToMain,
|
||||||
)
|
)
|
||||||
|
}
|
||||||
is Screen.Settings ->
|
is Screen.Settings ->
|
||||||
|
Box(Modifier.imePadding()) {
|
||||||
SettingsScreen(
|
SettingsScreen(
|
||||||
existing = current,
|
existing = current,
|
||||||
onSaved = { saved ->
|
onSaved = { saved ->
|
||||||
@@ -190,6 +203,7 @@ fun AppRoot(settingsVersion: Int, openRequest: SessionOpenRequest?) {
|
|||||||
onBack = goToMain,
|
onBack = goToMain,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Last, so it draws over the screen above rather than under it: these are stacked in the Box
|
// Last, so it draws over the screen above rather than under it: these are stacked in the Box
|
||||||
// the activity puts around this, and that Box paints in the order it was given. A session
|
// the activity puts around this, and that Box paints in the order it was given. A session
|
||||||
|
|||||||
@@ -11,7 +11,6 @@ import androidx.activity.enableEdgeToEdge
|
|||||||
import androidx.activity.result.contract.ActivityResultContracts
|
import androidx.activity.result.contract.ActivityResultContracts
|
||||||
import androidx.compose.foundation.layout.Box
|
import androidx.compose.foundation.layout.Box
|
||||||
import androidx.compose.foundation.layout.fillMaxSize
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
import androidx.compose.foundation.layout.imePadding
|
|
||||||
import androidx.compose.foundation.layout.navigationBarsPadding
|
import androidx.compose.foundation.layout.navigationBarsPadding
|
||||||
import androidx.compose.foundation.layout.statusBarsPadding
|
import androidx.compose.foundation.layout.statusBarsPadding
|
||||||
import androidx.compose.material3.MaterialTheme
|
import androidx.compose.material3.MaterialTheme
|
||||||
@@ -21,7 +20,9 @@ import androidx.compose.runtime.mutableIntStateOf
|
|||||||
import androidx.compose.runtime.mutableStateOf
|
import androidx.compose.runtime.mutableStateOf
|
||||||
import androidx.compose.runtime.setValue
|
import androidx.compose.runtime.setValue
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.draw.drawWithContent
|
||||||
import androidx.compose.ui.graphics.luminance
|
import androidx.compose.ui.graphics.luminance
|
||||||
|
import androidx.compose.ui.layout.layout
|
||||||
import androidx.core.view.WindowCompat
|
import androidx.core.view.WindowCompat
|
||||||
|
|
||||||
class MainActivity : ComponentActivity() {
|
class MainActivity : ComponentActivity() {
|
||||||
@@ -91,14 +92,52 @@ class MainActivity : ComponentActivity() {
|
|||||||
Surface(modifier = Modifier.fillMaxSize()) {
|
Surface(modifier = Modifier.fillMaxSize()) {
|
||||||
Box(
|
Box(
|
||||||
modifier =
|
modifier =
|
||||||
Modifier.fillMaxSize()
|
// Timed like the transcript times itself, and for the same reason:
|
||||||
|
// the frame's draw phase is where Compose's measurement lands, and
|
||||||
|
// a report saying "draw is high" cannot otherwise say whether the
|
||||||
|
// cost is the transcript or the chrome around it. The keyboard is
|
||||||
|
// the case that made it matter -- every frame of the IME animation
|
||||||
|
// relays out and re-records this whole box.
|
||||||
|
Modifier.layout { measurable, constraints ->
|
||||||
|
val started = System.nanoTime()
|
||||||
|
val placeable = measurable.measure(constraints)
|
||||||
|
DebugStats.record(
|
||||||
|
"measure: the app root",
|
||||||
|
System.nanoTime() - started,
|
||||||
|
)
|
||||||
|
layout(placeable.width, placeable.height) {
|
||||||
|
val placing = System.nanoTime()
|
||||||
|
placeable.place(0, 0)
|
||||||
|
DebugStats.record(
|
||||||
|
"place: the app root",
|
||||||
|
System.nanoTime() - placing,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.drawWithContent {
|
||||||
|
val started = System.nanoTime()
|
||||||
|
drawContent()
|
||||||
|
DebugStats.record(
|
||||||
|
"record: the app root",
|
||||||
|
System.nanoTime() - started,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
.fillMaxSize()
|
||||||
.statusBarsPadding()
|
.statusBarsPadding()
|
||||||
// The gesture strip at the bottom of most
|
// The gesture strip at the bottom of most
|
||||||
// phones. Without it the send row sits under
|
// phones. Without it the send row sits under
|
||||||
// the swipe area, where a tap is as likely to
|
// the swipe area, where a tap is as likely to
|
||||||
// navigate away as to press a button.
|
// navigate away as to press a button.
|
||||||
|
//
|
||||||
|
// No imePadding here, deliberately: applied at the root it
|
||||||
|
// resizes this whole box on every frame of the keyboard
|
||||||
|
// animation, which re-measures, re-places and re-records every
|
||||||
|
// screen's entire tree per frame -- measured above as most of
|
||||||
|
// the frame budget. Each screen takes the keyboard itself
|
||||||
|
// (AppRoot wraps the ordinary ones; the session screen moves
|
||||||
|
// only its composer and transcript), so the per-frame cost is
|
||||||
|
// scoped to what actually moves.
|
||||||
.navigationBarsPadding()
|
.navigationBarsPadding()
|
||||||
.imePadding()
|
|
||||||
) {
|
) {
|
||||||
AppRoot(settingsVersion, openRequest)
|
AppRoot(settingsVersion, openRequest)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,15 +8,20 @@ import androidx.activity.compose.rememberLauncherForActivityResult
|
|||||||
import androidx.activity.result.PickVisualMediaRequest
|
import androidx.activity.result.PickVisualMediaRequest
|
||||||
import androidx.activity.result.contract.ActivityResultContracts
|
import androidx.activity.result.contract.ActivityResultContracts
|
||||||
import androidx.compose.foundation.Image
|
import androidx.compose.foundation.Image
|
||||||
|
import androidx.compose.foundation.background
|
||||||
import androidx.compose.foundation.gestures.awaitEachGesture
|
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.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.fillMaxSize
|
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.imePadding
|
||||||
|
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
|
||||||
import androidx.compose.foundation.layout.width
|
import androidx.compose.foundation.layout.width
|
||||||
@@ -42,6 +47,7 @@ import androidx.compose.runtime.Immutable
|
|||||||
import androidx.compose.runtime.LaunchedEffect
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
import androidx.compose.runtime.derivedStateOf
|
import androidx.compose.runtime.derivedStateOf
|
||||||
import androidx.compose.runtime.getValue
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.runtime.mutableIntStateOf
|
||||||
import androidx.compose.runtime.mutableLongStateOf
|
import androidx.compose.runtime.mutableLongStateOf
|
||||||
import androidx.compose.runtime.mutableStateOf
|
import androidx.compose.runtime.mutableStateOf
|
||||||
import androidx.compose.runtime.remember
|
import androidx.compose.runtime.remember
|
||||||
@@ -53,10 +59,12 @@ import androidx.compose.runtime.snapshots.Snapshot
|
|||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.draw.drawWithContent
|
import androidx.compose.ui.draw.drawWithContent
|
||||||
|
import androidx.compose.ui.graphics.graphicsLayer
|
||||||
import androidx.compose.ui.input.pointer.PointerEventPass
|
import androidx.compose.ui.input.pointer.PointerEventPass
|
||||||
import androidx.compose.ui.input.pointer.pointerInput
|
import androidx.compose.ui.input.pointer.pointerInput
|
||||||
import androidx.compose.ui.layout.onSizeChanged
|
import androidx.compose.ui.layout.onSizeChanged
|
||||||
import androidx.compose.ui.platform.LocalContext
|
import androidx.compose.ui.platform.LocalContext
|
||||||
|
import androidx.compose.ui.platform.LocalDensity
|
||||||
import androidx.compose.ui.semantics.contentDescription
|
import androidx.compose.ui.semantics.contentDescription
|
||||||
import androidx.compose.ui.semantics.semantics
|
import androidx.compose.ui.semantics.semantics
|
||||||
import androidx.compose.ui.text.style.TextOverflow
|
import androidx.compose.ui.text.style.TextOverflow
|
||||||
@@ -624,6 +632,7 @@ private suspend fun warm(replies: ParsedReplies, rows: List<TranscriptItem>) {
|
|||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
fun SessionScreen(settings: ServerSettings, summary: SessionSummary, onBack: () -> Unit) {
|
fun SessionScreen(settings: ServerSettings, summary: SessionSummary, onBack: () -> Unit) {
|
||||||
|
DebugStats.count("session screen recomposed")
|
||||||
val scope = rememberCoroutineScope()
|
val scope = rememberCoroutineScope()
|
||||||
val topEdgeHeld = remember { TopEdgeHold() }
|
val topEdgeHeld = remember { TopEdgeHold() }
|
||||||
var items by remember { mutableStateOf(listOf<TranscriptItem>()) }
|
var items by remember { mutableStateOf(listOf<TranscriptItem>()) }
|
||||||
@@ -1356,6 +1365,19 @@ fun SessionScreen(settings: ServerSettings, summary: SessionSummary, onBack: ()
|
|||||||
var usageOpen by remember { mutableStateOf(false) }
|
var usageOpen by remember { mutableStateOf(false) }
|
||||||
var settingsOpen by remember { mutableStateOf(false) }
|
var settingsOpen by remember { mutableStateOf(false) }
|
||||||
|
|
||||||
|
// The composer floats over the bottom of the screen instead of sitting under the transcript
|
||||||
|
// in one column, and the keyboard moves it by a layer translation rather than by relayout.
|
||||||
|
// With everything in one column under a root imePadding, every frame of the keyboard
|
||||||
|
// animation re-measured, re-placed and re-recorded the entire screen -- measured on the
|
||||||
|
// emulator at ~7.6ms of main-thread work per frame across ~34 frames per open, and on the
|
||||||
|
// Pixel as 82% late frames while the transcript itself cost 0.25ms. Scoped this way, a
|
||||||
|
// keyboard frame costs one layer transform for the composer and one re-measure of the
|
||||||
|
// transcript box, whose children skip measurement (width unchanged) and whose rows are
|
||||||
|
// already layers.
|
||||||
|
var composerHeight by remember { mutableIntStateOf(0) }
|
||||||
|
val imeInsets = WindowInsets.ime
|
||||||
|
val navInsets = WindowInsets.navigationBars
|
||||||
|
Box(Modifier.fillMaxSize()) {
|
||||||
Column(Modifier.fillMaxSize()) {
|
Column(Modifier.fillMaxSize()) {
|
||||||
Row(
|
Row(
|
||||||
verticalAlignment = Alignment.CenterVertically,
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
@@ -1400,7 +1422,8 @@ fun SessionScreen(settings: ServerSettings, summary: SessionSummary, onBack: ()
|
|||||||
// Left of the numbers about the *conversation*, because it is the same kind of
|
// Left of the numbers about the *conversation*, because it is the same kind of
|
||||||
// thing about the *app*: what this session is costing to draw. It copies rather
|
// thing about the *app*: what this session is costing to draw. It copies rather
|
||||||
// than opens, because what it produces is for somewhere else -- a message to
|
// than opens, because what it produces is for somewhere else -- a message to
|
||||||
// whoever is looking at the code -- and a screenful of timings read on the phone
|
// whoever is looking at the code -- and a screenful of timings read on the
|
||||||
|
// phone
|
||||||
// is a screenful nobody can act on.
|
// is a screenful nobody can act on.
|
||||||
GlyphButton(
|
GlyphButton(
|
||||||
SPEED_GLYPH,
|
SPEED_GLYPH,
|
||||||
@@ -1432,17 +1455,20 @@ fun SessionScreen(settings: ServerSettings, summary: SessionSummary, onBack: ()
|
|||||||
context.copyToClipboard("ai-app render report", report)
|
context.copyToClipboard("ai-app render report", report)
|
||||||
// Also to the log, so a session driving the app over adb can read the
|
// Also to the log, so a session driving the app over adb can read the
|
||||||
// same report the button copies. The clipboard is not reachable from a
|
// same report the button copies. The clipboard is not reachable from a
|
||||||
// shell, and a counter nobody can check from here is a counter that only
|
// shell, and a counter nobody can check from here is a counter that
|
||||||
|
// only
|
||||||
// gets checked by asking Iris to press a button and paste.
|
// gets checked by asking Iris to press a button and paste.
|
||||||
Log.i("ai-app", report)
|
Log.i("ai-app", report)
|
||||||
// Only once it is somewhere it can be read from, so a copy that never
|
// Only once it is somewhere it can be read from, so a copy that never
|
||||||
// happened does not throw the stack away with it.
|
// happened does not throw the stack away with it.
|
||||||
clearCrash(context)
|
clearCrash(context)
|
||||||
// Emptied by the copy, so pressing it twice measures two separate stretches
|
// Emptied by the copy, so pressing it twice measures two separate
|
||||||
|
// stretches
|
||||||
// of scrolling rather than one and then the same one again.
|
// of scrolling rather than one and then the same one again.
|
||||||
frames.reset()
|
frames.reset()
|
||||||
DebugStats.reset()
|
DebugStats.reset()
|
||||||
Toast.makeText(context, "Copied render report", Toast.LENGTH_SHORT).show()
|
Toast.makeText(context, "Copied render report", Toast.LENGTH_SHORT)
|
||||||
|
.show()
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
GlyphButton(
|
GlyphButton(
|
||||||
@@ -1452,7 +1478,8 @@ fun SessionScreen(settings: ServerSettings, summary: SessionSummary, onBack: ()
|
|||||||
colour = usageGlyphColour(usage),
|
colour = usageGlyphColour(usage),
|
||||||
)
|
)
|
||||||
// What it opens is about this session, so it sits at the end of the session's
|
// What it opens is about this session, so it sits at the end of the session's
|
||||||
// own row. The name is the whole of what it holds today, which is why it is a cog
|
// own row. The name is the whole of what it holds today, which is why it is a
|
||||||
|
// cog
|
||||||
// and not a word: there will be more, and a bar of words has nowhere to put it.
|
// and not a word: there will be more, and a bar of words has nowhere to put it.
|
||||||
GlyphButton(SETTINGS_GLYPH, "Session settings", { settingsOpen = true })
|
GlyphButton(SETTINGS_GLYPH, "Session settings", { settingsOpen = true })
|
||||||
}
|
}
|
||||||
@@ -1481,7 +1508,17 @@ fun SessionScreen(settings: ServerSettings, summary: SessionSummary, onBack: ()
|
|||||||
// is fully built, and there is no frame in which the transcript is somewhere other
|
// is fully built, and there is no frame in which the transcript is somewhere other
|
||||||
// than where it was left.
|
// than where it was left.
|
||||||
val settled = !restoring
|
val settled = !restoring
|
||||||
Box(Modifier.weight(1f).fillMaxWidth()) {
|
Box(
|
||||||
|
Modifier.weight(1f)
|
||||||
|
.fillMaxWidth()
|
||||||
|
// The room the floating composer needs, measured off it below -- reserving it
|
||||||
|
// here is what lets the composer be an overlay without covering the newest
|
||||||
|
// 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()
|
||||||
|
) {
|
||||||
Box(Modifier.fillMaxSize()) {
|
Box(Modifier.fillMaxSize()) {
|
||||||
TranscriptList(
|
TranscriptList(
|
||||||
units = units,
|
units = units,
|
||||||
@@ -1490,8 +1527,10 @@ fun SessionScreen(settings: ServerSettings, summary: SessionSummary, onBack: ()
|
|||||||
modifier =
|
modifier =
|
||||||
Modifier.fillMaxSize().drawWithContent { if (settled) drawContent() },
|
Modifier.fillMaxSize().drawWithContent { if (settled) drawContent() },
|
||||||
below = {
|
below = {
|
||||||
// The last thing in the transcript, because that is where they are in the
|
// The last thing in the transcript, because that is where they are in
|
||||||
// session's reading of events: after everything it has taken in, and not
|
// the
|
||||||
|
// session's reading of events: after everything it has taken in, and
|
||||||
|
// not
|
||||||
// yet taken in themselves. What the session is *doing* about them is a
|
// yet taken in themselves. What the session is *doing* about them is a
|
||||||
// line below, in [SessionStatusRow].
|
// line below, in [SessionStatusRow].
|
||||||
if (queued.isNotEmpty() || waitingCommands.isNotEmpty()) {
|
if (queued.isNotEmpty() || waitingCommands.isNotEmpty()) {
|
||||||
@@ -1525,7 +1564,8 @@ fun SessionScreen(settings: ServerSettings, summary: SessionSummary, onBack: ()
|
|||||||
Box(
|
Box(
|
||||||
Modifier.holdTopEdge(row.key, topEdgeHeld) { grew ->
|
Modifier.holdTopEdge(row.key, topEdgeHeld) { grew ->
|
||||||
// A *request*, not a raw scroll delta: this runs inside
|
// A *request*, not a raw scroll delta: this runs inside
|
||||||
// the measure pass that discovered the new height, and a
|
// the measure pass that discovered the new height, and
|
||||||
|
// a
|
||||||
// raw delta forces a synchronous remeasure from within
|
// raw delta forces a synchronous remeasure from within
|
||||||
// measure, which is fatal
|
// measure, which is fatal
|
||||||
// ("performMeasureAndLayout called during measure").
|
// ("performMeasureAndLayout called during measure").
|
||||||
@@ -1545,11 +1585,14 @@ fun SessionScreen(settings: ServerSettings, summary: SessionSummary, onBack: ()
|
|||||||
}
|
}
|
||||||
// Which half of this row the touch landed in, for
|
// Which half of this row the touch landed in, for
|
||||||
// [toggleAnchored].
|
// [toggleAnchored].
|
||||||
// On the initial pass and consuming nothing, so every control
|
// On the initial pass and consuming nothing, so every
|
||||||
|
// control
|
||||||
// inside
|
// inside
|
||||||
// still gets the gesture exactly as it would have; only visible
|
// still gets the gesture exactly as it would have; only
|
||||||
|
// visible
|
||||||
// rows
|
// rows
|
||||||
// have one, which is what makes a detector per row affordable.
|
// have one, which is what makes a detector per row
|
||||||
|
// affordable.
|
||||||
.pointerInput(row.key) {
|
.pointerInput(row.key) {
|
||||||
awaitEachGesture {
|
awaitEachGesture {
|
||||||
val down =
|
val down =
|
||||||
@@ -1576,15 +1619,18 @@ fun SessionScreen(settings: ServerSettings, summary: SessionSummary, onBack: ()
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
isToolExpanded = { it in expandedTools },
|
isToolExpanded = { it in expandedTools },
|
||||||
// Anchored on the group, not the call: opening one call
|
// Anchored on the group, not the call: opening one
|
||||||
|
// call
|
||||||
// makes
|
// makes
|
||||||
// the whole group taller, and the heading the reader is
|
// the whole group taller, and the heading the
|
||||||
|
// reader is
|
||||||
// under
|
// under
|
||||||
// is the group's.
|
// is the group's.
|
||||||
onToolToggle = { id ->
|
onToolToggle = { id ->
|
||||||
toggleAnchored(row) {
|
toggleAnchored(row) {
|
||||||
expandedTools =
|
expandedTools =
|
||||||
if (id in expandedTools) expandedTools - id
|
if (id in expandedTools)
|
||||||
|
expandedTools - id
|
||||||
else expandedTools + id
|
else expandedTools + id
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@@ -1614,12 +1660,19 @@ fun SessionScreen(settings: ServerSettings, summary: SessionSummary, onBack: ()
|
|||||||
is TranscriptItem.AssistantMsg ->
|
is TranscriptItem.AssistantMsg ->
|
||||||
// A whole assistant row is only ever the reply
|
// A whole assistant row is only ever the reply
|
||||||
// still
|
// still
|
||||||
// arriving -- every settled reply is flattened into
|
// arriving -- every settled reply is flattened
|
||||||
// block units instead; see [transcriptUnits]. Live
|
// into
|
||||||
|
// block units instead; see [transcriptUnits].
|
||||||
|
// Live
|
||||||
// is
|
// is
|
||||||
// what earns its blocks a layer each while deltas
|
// what earns its blocks a layer each while
|
||||||
|
// deltas
|
||||||
// land.
|
// land.
|
||||||
AssistantMessage(item.text, replies, live = true)
|
AssistantMessage(
|
||||||
|
item.text,
|
||||||
|
replies,
|
||||||
|
live = true,
|
||||||
|
)
|
||||||
is TranscriptItem.ToolRun ->
|
is TranscriptItem.ToolRun ->
|
||||||
ToolCard(
|
ToolCard(
|
||||||
tool = item,
|
tool = item,
|
||||||
@@ -1670,11 +1723,14 @@ fun SessionScreen(settings: ServerSettings, summary: SessionSummary, onBack: ()
|
|||||||
item.text,
|
item.text,
|
||||||
style = MaterialTheme.typography.bodySmall,
|
style = MaterialTheme.typography.bodySmall,
|
||||||
color =
|
color =
|
||||||
MaterialTheme.colorScheme.onSurfaceVariant,
|
MaterialTheme.colorScheme
|
||||||
|
.onSurfaceVariant,
|
||||||
)
|
)
|
||||||
is TranscriptItem.CommandRow -> CommandBubble(item.text)
|
is TranscriptItem.CommandRow ->
|
||||||
|
CommandBubble(item.text)
|
||||||
is TranscriptItem.ClearedNote -> ClearedRow()
|
is TranscriptItem.ClearedNote -> ClearedRow()
|
||||||
is TranscriptItem.CompactedNote -> CompactedRow(item)
|
is TranscriptItem.CompactedNote ->
|
||||||
|
CompactedRow(item)
|
||||||
is TranscriptItem.PeerNote ->
|
is TranscriptItem.PeerNote ->
|
||||||
PeerMessageRow(
|
PeerMessageRow(
|
||||||
item = item,
|
item = item,
|
||||||
@@ -1705,12 +1761,17 @@ fun SessionScreen(settings: ServerSettings, summary: SessionSummary, onBack: ()
|
|||||||
//
|
//
|
||||||
// In the middle of the transcript rather than at either end, because it is not
|
// In the middle of the transcript rather than at either end, because it is not
|
||||||
// reporting on the newest message or the oldest; it is standing in for all of them.
|
// reporting on the newest message or the oldest; it is standing in for all of them.
|
||||||
// `settled` and not `restoring` alone, so the spinner covers the whole wait: fetching
|
// `settled` and not `restoring` alone, so the spinner covers the whole wait:
|
||||||
// the history a saved position needs, and then the frames between those rows arriving
|
// fetching
|
||||||
// and the layout that measures them putting the position back. They are the two halves
|
// the history a saved position needs, and then the frames between those rows
|
||||||
|
// arriving
|
||||||
|
// and the layout that measures them putting the position back. They are the two
|
||||||
|
// halves
|
||||||
// of the same wait and the transcript is not drawn for either.
|
// of the same wait and the transcript is not drawn for either.
|
||||||
if (!ready || !settled) {
|
if (!ready || !settled) {
|
||||||
CircularProgressIndicator(Modifier.align(Alignment.Center).size(LOADING_SPINNER))
|
CircularProgressIndicator(
|
||||||
|
Modifier.align(Alignment.Center).size(LOADING_SPINNER)
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Only while the newest message is off-screen. Reading back
|
// Only while the newest message is off-screen. Reading back
|
||||||
@@ -1730,7 +1791,8 @@ fun SessionScreen(settings: ServerSettings, summary: SessionSummary, onBack: ()
|
|||||||
// whose cost grows with how much there is to skip, which is backwards.
|
// whose cost grows with how much there is to skip, which is backwards.
|
||||||
//
|
//
|
||||||
// Arriving there is all this has to do now. The newest end is where the
|
// Arriving there is all this has to do now. The newest end is where the
|
||||||
// content hangs from, so being at it is the whole of following it, and there
|
// content hangs from, so being at it is the whole of following it, and
|
||||||
|
// there
|
||||||
// is no separate flag to set -- which is what this press used to forget,
|
// is no separate flag to set -- which is what this press used to forget,
|
||||||
// landing the reader at the bottom with new messages not bringing the view
|
// landing the reader at the bottom with new messages not bringing the view
|
||||||
// with them.
|
// with them.
|
||||||
@@ -1738,9 +1800,9 @@ fun SessionScreen(settings: ServerSettings, summary: SessionSummary, onBack: ()
|
|||||||
shape = CircleShape,
|
shape = CircleShape,
|
||||||
color = MaterialTheme.colorScheme.surfaceContainerHigh,
|
color = MaterialTheme.colorScheme.surfaceContainerHigh,
|
||||||
modifier =
|
modifier =
|
||||||
Modifier.align(Alignment.BottomCenter).padding(bottom = 12.dp).semantics {
|
Modifier.align(Alignment.BottomCenter)
|
||||||
contentDescription = "Jump to latest"
|
.padding(bottom = 12.dp)
|
||||||
},
|
.semantics { contentDescription = "Jump to latest" },
|
||||||
) {
|
) {
|
||||||
Chevron(
|
Chevron(
|
||||||
pointingUp = false,
|
pointingUp = false,
|
||||||
@@ -1750,7 +1812,27 @@ fun SessionScreen(settings: ServerSettings, summary: SessionSummary, onBack: ()
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Everything from here down floats: bottom-aligned over the transcript, moved up with
|
||||||
|
// the keyboard by a translation on its own layer. The translation is read inside the
|
||||||
|
// graphicsLayer block, so a keyboard frame invalidates layer properties only -- no
|
||||||
|
// measure, no recomposition, no re-recording of anything. Its height is reported to the
|
||||||
|
// transcript box above, which reserves that much room; the opaque background covers the
|
||||||
|
// one frame between this growing (a suggestion row, a second draft line) and that
|
||||||
|
// reservation catching up.
|
||||||
|
Column(
|
||||||
|
Modifier.align(Alignment.BottomCenter)
|
||||||
|
.fillMaxWidth()
|
||||||
|
.onSizeChanged { composerHeight = it.height }
|
||||||
|
.graphicsLayer {
|
||||||
|
translationY =
|
||||||
|
-(imeInsets.getBottom(this) - navInsets.getBottom(this))
|
||||||
|
.coerceAtLeast(0)
|
||||||
|
.toFloat()
|
||||||
|
}
|
||||||
|
.background(MaterialTheme.colorScheme.background)
|
||||||
|
) {
|
||||||
pendingModel?.let { chosen ->
|
pendingModel?.let { chosen ->
|
||||||
ModelSwitchWarning(
|
ModelSwitchWarning(
|
||||||
from = modelLabel(model),
|
from = modelLabel(model),
|
||||||
@@ -1845,7 +1927,9 @@ fun SessionScreen(settings: ServerSettings, summary: SessionSummary, onBack: ()
|
|||||||
// Asked about first, unless there is nothing to lose by it --
|
// Asked about first, unless there is nothing to lose by it --
|
||||||
// see [ModelSwitchWarning].
|
// see [ModelSwitchWarning].
|
||||||
onPick = { chosen ->
|
onPick = { chosen ->
|
||||||
if (modelLabel(chosen) == modelLabel(model) || items.isEmpty()) {
|
if (
|
||||||
|
modelLabel(chosen) == modelLabel(model) || items.isEmpty()
|
||||||
|
) {
|
||||||
act { setSessionModel(settings, summary.id, chosen) }
|
act { setSessionModel(settings, summary.id, chosen) }
|
||||||
} else {
|
} else {
|
||||||
pendingModel = chosen
|
pendingModel = chosen
|
||||||
@@ -1862,13 +1946,16 @@ fun SessionScreen(settings: ServerSettings, summary: SessionSummary, onBack: ()
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
// The same filled shape as the button beside it, not an outlined one: these are
|
// The same filled shape as the button beside it, not an outlined one: these are
|
||||||
// two things you can do about the session, and weighting one of them as secondary
|
// two things you can do about the session, and weighting one of them as
|
||||||
|
// secondary
|
||||||
// said they were a primary action and its qualifier. What separates them is the
|
// said they were a primary action and its qualifier. What separates them is the
|
||||||
// colour and the mark, which is what they mean.
|
// colour and the mark, which is what they mean.
|
||||||
//
|
//
|
||||||
// Always here, rather than arriving with the turn as it used to. A control that
|
// Always here, rather than arriving with the turn as it used to. A control that
|
||||||
// comes and goes makes its own presence the signal, and its absence could not say
|
// comes and goes makes its own presence the signal, and its absence could not
|
||||||
// whether there was nothing to do; a button that is always in the same place also
|
// say
|
||||||
|
// whether there was nothing to do; a button that is always in the same place
|
||||||
|
// also
|
||||||
// cannot push Send off the end of the row by turning up.
|
// cannot push Send off the end of the row by turning up.
|
||||||
val process =
|
val process =
|
||||||
when {
|
when {
|
||||||
@@ -1895,7 +1982,8 @@ fun SessionScreen(settings: ServerSettings, summary: SessionSummary, onBack: ()
|
|||||||
Spacer(Modifier.width(8.dp))
|
Spacer(Modifier.width(8.dp))
|
||||||
// The paper plane, with a clock on it while a turn is in flight: sending then
|
// The paper plane, with a clock on it while a turn is in flight: sending then
|
||||||
// queues the message for the next tool boundary rather than starting a turn of
|
// queues the message for the next tool boundary rather than starting a turn of
|
||||||
// its own, and the two have to be told apart at a glance. The label says the same
|
// its own, and the two have to be told apart at a glance. The label says the
|
||||||
|
// same
|
||||||
// thing to a screen reader, which has nothing else to read.
|
// thing to a screen reader, which has nothing else to read.
|
||||||
//
|
//
|
||||||
// Disabled while there is nothing to send, rather than pressable and silent:
|
// Disabled while there is nothing to send, rather than pressable and silent:
|
||||||
@@ -1910,12 +1998,14 @@ fun SessionScreen(settings: ServerSettings, summary: SessionSummary, onBack: ()
|
|||||||
Glyph(
|
Glyph(
|
||||||
if (running) QUEUE_GLYPH else SEND_GLYPH,
|
if (running) QUEUE_GLYPH else SEND_GLYPH,
|
||||||
colour = LocalContentColor.current,
|
colour = LocalContentColor.current,
|
||||||
modifier = Modifier.semantics { contentDescription = sendLabel(running) },
|
modifier =
|
||||||
|
Modifier.semantics { contentDescription = sendLabel(running) },
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if (usageOpen) {
|
if (usageOpen) {
|
||||||
UsageDialog(settings = settings, onDismiss = { usageOpen = false })
|
UsageDialog(settings = settings, onDismiss = { usageOpen = false })
|
||||||
@@ -2097,6 +2187,7 @@ private fun SessionStatusRow(
|
|||||||
contextTokens: Long?,
|
contextTokens: Long?,
|
||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
) {
|
) {
|
||||||
|
DebugStats.count("status row recomposed")
|
||||||
Row(
|
Row(
|
||||||
verticalAlignment = Alignment.CenterVertically,
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
modifier = modifier.fillMaxWidth().padding(horizontal = 12.dp, vertical = 4.dp),
|
modifier = modifier.fillMaxWidth().padding(horizontal = 12.dp, vertical = 4.dp),
|
||||||
|
|||||||
@@ -116,6 +116,7 @@ fun usageGlyphColour(usage: SessionUsage): Color =
|
|||||||
*/
|
*/
|
||||||
@Composable
|
@Composable
|
||||||
fun SessionUsageBar(usage: SessionUsage, modifier: Modifier = Modifier) {
|
fun SessionUsageBar(usage: SessionUsage, modifier: Modifier = Modifier) {
|
||||||
|
DebugStats.count("usage bar recomposed")
|
||||||
// The countdown moves even when the numbers do not, so it is driven by a clock of its own
|
// The countdown moves even when the numbers do not, so it is driven by a clock of its own
|
||||||
// rather than recomputed at draw time: a percentage that comes back unchanged is an equal
|
// rather than recomputed at draw time: a percentage that comes back unchanged is an equal
|
||||||
// value, Compose skips the recomposition, and a "left" that only ticked when the quota
|
// value, Compose skips the recomposition, and a "left" that only ticked when the quota
|
||||||
|
|||||||
Reference in new issue
Block a user