From 9477cd288aaa3fa7a0f6063f36536e49762997a4 Mon Sep 17 00:00:00 2001 From: iris <2+iris@noreply.localhost> Date: Mon, 31 Aug 2026 14:49:44 -0400 Subject: [PATCH] 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 --- .../main/kotlin/com/example/aiapp/AppRoot.kt | 84 +- .../kotlin/com/example/aiapp/MainActivity.kt | 45 +- .../kotlin/com/example/aiapp/SessionScreen.kt | 1123 +++++++++-------- .../com/example/aiapp/SessionUsageBar.kt | 1 + 4 files changed, 699 insertions(+), 554 deletions(-) diff --git a/app/androidApp/src/main/kotlin/com/example/aiapp/AppRoot.kt b/app/androidApp/src/main/kotlin/com/example/aiapp/AppRoot.kt index 8121c1f..f2b4e09 100644 --- a/app/androidApp/src/main/kotlin/com/example/aiapp/AppRoot.kt +++ b/app/androidApp/src/main/kotlin/com/example/aiapp/AppRoot.kt @@ -1,6 +1,8 @@ package com.example.aiapp 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.material3.AlertDialog import androidx.compose.material3.MaterialTheme @@ -93,14 +95,16 @@ fun AppRoot(settingsVersion: Int, openRequest: SessionOpenRequest?) { if (current == null) { // Not enrolled yet: settings is the only usable screen. The QR // path lands in MainActivity and recomposes from the top. - SettingsScreen( - existing = null, - onSaved = { saved -> - settings = saved - screen = Screen.Main - }, - onBack = null, - ) + Box(Modifier.imePadding()) { + SettingsScreen( + existing = null, + onSaved = { saved -> + settings = saved + screen = Screen.Main + }, + onBack = null, + ) + } return } @@ -148,19 +152,25 @@ 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) { is Screen.Main -> - MainScreen( - settings = current, - reloadToken = reloadToken, - onOpen = { screen = Screen.Session(it) }, - onSpawn = { screen = Screen.Spawn }, - onImported = { imported -> - reloadToken++ - screen = Screen.Session(imported) - }, - onSettings = { screen = Screen.Settings }, - ) + Box(Modifier.imePadding()) { + MainScreen( + settings = current, + reloadToken = reloadToken, + onOpen = { screen = Screen.Session(it) }, + onSpawn = { screen = Screen.Spawn }, + onImported = { imported -> + reloadToken++ + screen = Screen.Session(imported) + }, + onSettings = { screen = Screen.Settings }, + ) + } is Screen.Session -> // 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 @@ -172,23 +182,27 @@ fun AppRoot(settingsVersion: Int, openRequest: SessionOpenRequest?) { SessionScreen(settings = current, summary = here.summary, onBack = goToMain) } is Screen.Spawn -> - SpawnScreen( - settings = current, - onSpawned = { spawned -> - reloadToken++ - screen = Screen.Session(spawned) - }, - onBack = goToMain, - ) + Box(Modifier.imePadding()) { + SpawnScreen( + settings = current, + onSpawned = { spawned -> + reloadToken++ + screen = Screen.Session(spawned) + }, + onBack = goToMain, + ) + } is Screen.Settings -> - SettingsScreen( - existing = current, - onSaved = { saved -> - settings = saved - goToMain() - }, - onBack = goToMain, - ) + Box(Modifier.imePadding()) { + SettingsScreen( + existing = current, + onSaved = { saved -> + settings = saved + goToMain() + }, + onBack = goToMain, + ) + } } // Last, so it draws over the screen above rather than under it: these are stacked in the Box diff --git a/app/androidApp/src/main/kotlin/com/example/aiapp/MainActivity.kt b/app/androidApp/src/main/kotlin/com/example/aiapp/MainActivity.kt index fc86104..a0e0bd1 100644 --- a/app/androidApp/src/main/kotlin/com/example/aiapp/MainActivity.kt +++ b/app/androidApp/src/main/kotlin/com/example/aiapp/MainActivity.kt @@ -11,7 +11,6 @@ import androidx.activity.enableEdgeToEdge import androidx.activity.result.contract.ActivityResultContracts import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.fillMaxSize -import androidx.compose.foundation.layout.imePadding import androidx.compose.foundation.layout.navigationBarsPadding import androidx.compose.foundation.layout.statusBarsPadding import androidx.compose.material3.MaterialTheme @@ -21,7 +20,9 @@ import androidx.compose.runtime.mutableIntStateOf import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.drawWithContent import androidx.compose.ui.graphics.luminance +import androidx.compose.ui.layout.layout import androidx.core.view.WindowCompat class MainActivity : ComponentActivity() { @@ -91,14 +92,52 @@ class MainActivity : ComponentActivity() { Surface(modifier = Modifier.fillMaxSize()) { Box( 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() // The gesture strip at the bottom of most // phones. Without it the send row sits under // the swipe area, where a tap is as likely to // 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() - .imePadding() ) { AppRoot(settingsVersion, openRequest) } 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 893ecaf..895398f 100644 --- a/app/androidApp/src/main/kotlin/com/example/aiapp/SessionScreen.kt +++ b/app/androidApp/src/main/kotlin/com/example/aiapp/SessionScreen.kt @@ -8,15 +8,20 @@ import androidx.activity.compose.rememberLauncherForActivityResult import androidx.activity.result.PickVisualMediaRequest import androidx.activity.result.contract.ActivityResultContracts import androidx.compose.foundation.Image +import androidx.compose.foundation.background 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.Row import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.WindowInsets 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.navigationBars import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.width @@ -42,6 +47,7 @@ import androidx.compose.runtime.Immutable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.derivedStateOf import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableIntStateOf import androidx.compose.runtime.mutableLongStateOf import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember @@ -53,10 +59,12 @@ import androidx.compose.runtime.snapshots.Snapshot import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier 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.pointerInput import androidx.compose.ui.layout.onSizeChanged import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.semantics.contentDescription import androidx.compose.ui.semantics.semantics import androidx.compose.ui.text.style.TextOverflow @@ -624,6 +632,7 @@ private suspend fun warm(replies: ParsedReplies, rows: List) { @Composable fun SessionScreen(settings: ServerSettings, summary: SessionSummary, onBack: () -> Unit) { + DebugStats.count("session screen recomposed") val scope = rememberCoroutineScope() val topEdgeHeld = remember { TopEdgeHold() } var items by remember { mutableStateOf(listOf()) } @@ -1356,562 +1365,643 @@ fun SessionScreen(settings: ServerSettings, summary: SessionSummary, onBack: () var usageOpen by remember { mutableStateOf(false) } var settingsOpen by remember { mutableStateOf(false) } - Column(Modifier.fillMaxSize()) { - Row( - verticalAlignment = Alignment.CenterVertically, - modifier = Modifier.fillMaxWidth().padding(horizontal = 16.dp), - ) { - GlyphButton(BACK_GLYPH, "Back", onBack) - // A ring's worth, which is what the arrow already keeps on its other three sides -- - // the pair of glyph buttons at the far end of this row get theirs from each other. - Spacer(Modifier.width(GLYPH_BUTTON_MARGIN)) - Column(Modifier.weight(1f)) { - Text(title, style = MaterialTheme.typography.titleMedium) - // Machine first, then what runs on it -- the same order and the same wording - // everywhere this pair appears, so it reads as one fact rather than as two - // sentences with different grammar. The "on" that used to sit in the middle - // made it a phrase, which only works in one order and stops working the moment - // the pair is shown anywhere else. + // 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()) { + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier.fillMaxWidth().padding(horizontal = 16.dp), + ) { + GlyphButton(BACK_GLYPH, "Back", onBack) + // A ring's worth, which is what the arrow already keeps on its other three sides -- + // the pair of glyph buttons at the far end of this row get theirs from each other. + Spacer(Modifier.width(GLYPH_BUTTON_MARGIN)) + Column(Modifier.weight(1f)) { + Text(title, style = MaterialTheme.typography.titleMedium) + // Machine first, then what runs on it -- the same order and the same wording + // everywhere this pair appears, so it reads as one fact rather than as two + // sentences with different grammar. The "on" that used to sit in the middle + // made it a phrase, which only works in one order and stops working the moment + // the pair is shown anywhere else. + // + // No model. The picker in the footer already shows what this session is set to, + // and showing it twice means two things to keep in step -- they disagreed for a + // moment on every model change, since one follows the request and the other the + // session's own answer. + Text( + "${summary.setupName} · ${summary.provider}", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + // Beside the provider it reports on, which is the line directly to its left. // - // No model. The picker in the footer already shows what this session is set to, - // and showing it twice means two things to keep in step -- they disagreed for a - // moment on every model change, since one follows the request and the other the - // session's own answer. + // Its real home is this provider's settings, which do not exist yet; until they do, + // the session is the only place the provider is already named, so it is the only + // place the button can sit without inventing a scope for itself. What it shows is + // the paid service's own numbers, so a session on a provider with no such service + // gets an honest "unavailable" rather than a hidden button -- a control that comes + // and goes makes its absence the signal, and absence cannot say why. + // Coloured by the worst window behind it, so the row says whether the limits are + // worth opening before anybody opens them. Blue at every ordinary level and only + // yellow or red near a limit -- and the theme's plain control colour whenever there + // is no measurement, since blue is the low end of the scale here and would read as + // "checked, and fine" about a machine nobody could reach. + Row { + // 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 + // 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 + // is a screenful nobody can act on. + GlyphButton( + SPEED_GLYPH, + "Copy render timings", + onClick = { + val report = + debugReport( + device = + "device: ${Build.MODEL} (${Build.MANUFACTURER})," + + " Android ${Build.VERSION.RELEASE}", + transcript = + listOf( + " ${items.size} events, ${rows.size} rows," + + " ${units.size} units loaded", + " viewport" + + " ${listState.layoutInfo.viewportSize.height}px," + + " ${listState.layoutInfo.visibleItemsInfo.size}" + + " units visible", + " ${expandedTools.size} tool calls and" + + " ${expandedGroups.size} groups open", + ), + frames = frames.lines(context.refreshHz()), + accounting = + frames.drawPhase().let { (nanos, count) -> + drawAccounting(nanos, count) + }, + crash = lastCrash(context), + ) + context.copyToClipboard("ai-app render report", report) + // 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 + // 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. + Log.i("ai-app", report) + // Only once it is somewhere it can be read from, so a copy that never + // happened does not throw the stack away with it. + clearCrash(context) + // Emptied by the copy, so pressing it twice measures two separate + // stretches + // of scrolling rather than one and then the same one again. + frames.reset() + DebugStats.reset() + Toast.makeText(context, "Copied render report", Toast.LENGTH_SHORT) + .show() + }, + ) + GlyphButton( + USAGE_GLYPH, + "Usage", + { usageOpen = true }, + colour = usageGlyphColour(usage), + ) + // 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 + // 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 }) + } + } + + // Under the header, above everything the session itself says: it is a fact about the + // machine rather than a turn in the conversation, and it is the number that decides + // whether to keep going -- which was a screen away from where that gets decided. + SessionUsageBar(usage) + + (streamError ?: actionError)?.let { message -> Text( - "${summary.setupName} · ${summary.provider}", + message, + color = MaterialTheme.colorScheme.error, style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.padding(horizontal = 16.dp, vertical = 4.dp), ) } - // Beside the provider it reports on, which is the line directly to its left. + + // The transcript, reversed: item zero is the newest message and sits at the bottom, so + // the first frame of a session is already the right one, and following new content is + // where the list is rather than a correction it makes; see [TranscriptList]. // - // Its real home is this provider's settings, which do not exist yet; until they do, - // the session is the only place the provider is already named, so it is the only - // place the button can sit without inventing a scope for itself. What it shows is - // the paid service's own numbers, so a session on a provider with no such service - // gets an honest "unavailable" rather than a hidden button -- a control that comes - // and goes makes its absence the signal, and absence cannot say why. - // Coloured by the worst window behind it, so the row says whether the limits are - // worth opening before anybody opens them. Blue at every ordinary level and only - // yellow or red near a limit -- and the theme's plain control colour whenever there - // is no measurement, since blue is the low end of the scale here and would read as - // "checked, and fine" about a machine nobody could reach. - Row { - // 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 - // 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 - // is a screenful nobody can act on. - GlyphButton( - SPEED_GLYPH, - "Copy render timings", - onClick = { - val report = - debugReport( - device = - "device: ${Build.MODEL} (${Build.MANUFACTURER})," + - " Android ${Build.VERSION.RELEASE}", - transcript = - listOf( - " ${items.size} events, ${rows.size} rows," + - " ${units.size} units loaded", - " viewport" + - " ${listState.layoutInfo.viewportSize.height}px," + - " ${listState.layoutInfo.visibleItemsInfo.size}" + - " units visible", - " ${expandedTools.size} tool calls and" + - " ${expandedGroups.size} groups open", - ), - frames = frames.lines(context.refreshHz()), - accounting = - frames.drawPhase().let { (nanos, count) -> - drawAccounting(nanos, count) - }, - crash = lastCrash(context), - ) - context.copyToClipboard("ai-app render report", report) - // 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 - // 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. - Log.i("ai-app", report) - // Only once it is somewhere it can be read from, so a copy that never - // happened does not throw the stack away with it. - clearCrash(context) - // Emptied by the copy, so pressing it twice measures two separate stretches - // of scrolling rather than one and then the same one again. - frames.reset() - DebugStats.reset() - Toast.makeText(context, "Copied render report", Toast.LENGTH_SHORT).show() - }, - ) - GlyphButton( - USAGE_GLYPH, - "Usage", - { usageOpen = true }, - colour = usageGlyphColour(usage), - ) - // 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 - // 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 }) - } - } - - // Under the header, above everything the session itself says: it is a fact about the - // machine rather than a turn in the conversation, and it is the number that decides - // whether to keep going -- which was a screen away from where that gets decided. - SessionUsageBar(usage) - - (streamError ?: actionError)?.let { message -> - Text( - message, - color = MaterialTheme.colorScheme.error, - style = MaterialTheme.typography.bodySmall, - modifier = Modifier.padding(horizontal = 16.dp, vertical = 4.dp), - ) - } - - // The transcript, reversed: item zero is the newest message and sits at the bottom, so - // the first frame of a session is already the right one, and following new content is - // where the list is rather than a correction it makes; see [TranscriptList]. - // - // Drawn only once there is nothing left to put back. Held out of the drawing rather - // than out of the composition, so the restore's scroll is applied against a list that - // is fully built, and there is no frame in which the transcript is somewhere other - // than where it was left. - val settled = !restoring - Box(Modifier.weight(1f).fillMaxWidth()) { - Box(Modifier.fillMaxSize()) { - TranscriptList( - units = units, - state = listState, - moreHistory = moreHistory, - modifier = - Modifier.fillMaxSize().drawWithContent { if (settled) drawContent() }, - below = { - // The last thing in the transcript, because that is where they are in 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 - // line below, in [SessionStatusRow]. - if (queued.isNotEmpty() || waitingCommands.isNotEmpty()) { - // The gap the arrangement no longer provides: this item sits flush - // against the newest message otherwise. - Column( - Modifier.padding(top = TRANSCRIPT_SPACING), - horizontalAlignment = Alignment.End, - ) { - waitingCommands.forEach { (_, text) -> - CommandBubble(text, waiting = true) - } - queued.forEach { waiting -> - UserBubble( - settings = settings, - sessionId = summary.id, - text = waiting.text, - images = waiting.images, - pending = true, - ) + // Drawn only once there is nothing left to put back. Held out of the drawing rather + // than out of the composition, so the restore's scroll is applied against a list that + // is fully built, and there is no frame in which the transcript is somewhere other + // than where it was left. + val settled = !restoring + 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()) { + TranscriptList( + units = units, + state = listState, + moreHistory = moreHistory, + modifier = + Modifier.fillMaxSize().drawWithContent { if (settled) drawContent() }, + below = { + // The last thing in the transcript, because that is where they are in + // 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 + // line below, in [SessionStatusRow]. + if (queued.isNotEmpty() || waitingCommands.isNotEmpty()) { + // The gap the arrangement no longer provides: this item sits flush + // against the newest message otherwise. + Column( + Modifier.padding(top = TRANSCRIPT_SPACING), + horizontalAlignment = Alignment.End, + ) { + waitingCommands.forEach { (_, text) -> + CommandBubble(text, waiting = true) + } + queued.forEach { waiting -> + UserBubble( + settings = settings, + sessionId = summary.id, + text = waiting.text, + images = waiting.images, + pending = true, + ) + } } } - } - }, - ) { unit -> - when (unit) { - is TranscriptUnit.Block -> MarkdownText(unit.text, replies) - is TranscriptUnit.Memory -> MemoryNote(unit.part, replies) - is TranscriptUnit.Whole -> { - val row = unit.row - Box( - Modifier.holdTopEdge(row.key, topEdgeHeld) { grew -> - // A *request*, not a raw scroll delta: this runs inside - // the measure pass that discovered the new height, and a - // raw delta forces a synchronous remeasure from within - // measure, which is fatal - // ("performMeasureAndLayout called during measure"). - // The request is applied by the same frame's next - // remeasure, so the correction still lands before - // anything is drawn. Reads unobserved, or this row's - // measure would inherit the scroll position as a - // dependency and remeasure on every frame of every - // fling. - Snapshot.withoutReadObservation { - listState.requestScrollToItem( - listState.firstVisibleItemIndex, - (listState.firstVisibleItemScrollOffset + grew) - .coerceAtLeast(0), - ) - } - } - // Which half of this row the touch landed in, for - // [toggleAnchored]. - // On the initial pass and consuming nothing, so every control - // inside - // still gets the gesture exactly as it would have; only visible - // rows - // have one, which is what makes a detector per row affordable. - .pointerInput(row.key) { - awaitEachGesture { - val down = - awaitFirstDown( - requireUnconsumed = false, - pass = PointerEventPass.Initial, + }, + ) { unit -> + when (unit) { + is TranscriptUnit.Block -> MarkdownText(unit.text, replies) + is TranscriptUnit.Memory -> MemoryNote(unit.part, replies) + is TranscriptUnit.Whole -> { + val row = unit.row + Box( + Modifier.holdTopEdge(row.key, topEdgeHeld) { grew -> + // A *request*, not a raw scroll delta: this runs inside + // the measure pass that discovered the new height, and + // a + // raw delta forces a synchronous remeasure from within + // measure, which is fatal + // ("performMeasureAndLayout called during measure"). + // The request is applied by the same frame's next + // remeasure, so the correction still lands before + // anything is drawn. Reads unobserved, or this row's + // measure would inherit the scroll position as a + // dependency and remeasure on every frame of every + // fling. + Snapshot.withoutReadObservation { + listState.requestScrollToItem( + listState.firstVisibleItemIndex, + (listState.firstVisibleItemScrollOffset + grew) + .coerceAtLeast(0), ) - lastTouch.key = row.key - lastTouch.high = down.position.y < size.height / 2f + } } - } - ) { - when (row) { - is TranscriptRow.Tools -> - ToolGroup( - group = row, - expanded = row.id in expandedGroups, - onToggle = { - toggleAnchored(row) { - expandedGroups = - if (row.id in expandedGroups) - expandedGroups - row.id - else expandedGroups + row.id - } - }, - isToolExpanded = { it in expandedTools }, - // Anchored on the group, not the call: opening one call - // makes - // the whole group taller, and the heading the reader is - // under - // is the group's. - onToolToggle = { id -> - toggleAnchored(row) { - expandedTools = - if (id in expandedTools) expandedTools - id - else expandedTools + id - } - }, - onAnswer = { questionId, answers -> - act { - answerQuestion( - settings, - summary.id, - questionId, - answers, + // Which half of this row the touch landed in, for + // [toggleAnchored]. + // On the initial pass and consuming nothing, so every + // control + // inside + // still gets the gesture exactly as it would have; only + // visible + // rows + // have one, which is what makes a detector per row + // affordable. + .pointerInput(row.key) { + awaitEachGesture { + val down = + awaitFirstDown( + requireUnconsumed = false, + pass = PointerEventPass.Initial, ) - } - }, - image = { ref -> - SessionImage(settings, summary.id, ref) - }, - ) - is TranscriptRow.Single -> - when (val item = row.item) { - is TranscriptItem.UserMsg -> - UserBubble( - settings = settings, - sessionId = summary.id, - text = item.text, - images = item.images, - ) - is TranscriptItem.AssistantMsg -> - // A whole assistant row is only ever the reply - // still - // arriving -- every settled reply is flattened into - // block units instead; see [transcriptUnits]. Live - // is - // what earns its blocks a layer each while deltas - // land. - AssistantMessage(item.text, replies, live = true) - is TranscriptItem.ToolRun -> - ToolCard( - tool = item, - expanded = item.id in expandedTools, - onToggle = { - toggleAnchored(row) { - expandedTools = - if (item.id in expandedTools) - expandedTools - item.id - else expandedTools + item.id - } - }, - onAnswer = { questionId, answers -> - act { - answerQuestion( - settings, - summary.id, - questionId, - answers, - ) - } - }, - image = { ref -> - SessionImage(settings, summary.id, ref) - }, - ) - is TranscriptItem.QuestionCard -> - QuestionRow(item) { answers -> + lastTouch.key = row.key + lastTouch.high = down.position.y < size.height / 2f + } + } + ) { + when (row) { + is TranscriptRow.Tools -> + ToolGroup( + group = row, + expanded = row.id in expandedGroups, + onToggle = { + toggleAnchored(row) { + expandedGroups = + if (row.id in expandedGroups) + expandedGroups - row.id + else expandedGroups + row.id + } + }, + isToolExpanded = { it in expandedTools }, + // Anchored on the group, not the call: opening one + // call + // makes + // the whole group taller, and the heading the + // reader is + // under + // is the group's. + onToolToggle = { id -> + toggleAnchored(row) { + expandedTools = + if (id in expandedTools) + expandedTools - id + else expandedTools + id + } + }, + onAnswer = { questionId, answers -> act { answerQuestion( settings, summary.id, - item.id, + questionId, answers, ) } - } - is TranscriptItem.ErrorMsg -> - Text( - item.message, - color = MaterialTheme.colorScheme.error, - style = MaterialTheme.typography.bodyMedium, - ) - is TranscriptItem.ImageItem -> - SessionImage(settings, summary.id, item.ref) - is TranscriptItem.Note -> - Text( - item.text, - style = MaterialTheme.typography.bodySmall, - color = - MaterialTheme.colorScheme.onSurfaceVariant, - ) - is TranscriptItem.CommandRow -> CommandBubble(item.text) - is TranscriptItem.ClearedNote -> ClearedRow() - is TranscriptItem.CompactedNote -> CompactedRow(item) - is TranscriptItem.PeerNote -> - PeerMessageRow( - item = item, - expanded = item.seq in expandedNotes, - replies = replies, - onToggle = { - toggleAnchored(row) { - expandedNotes = - if (item.seq in expandedNotes) - expandedNotes - item.seq - else expandedNotes + item.seq + }, + image = { ref -> + SessionImage(settings, summary.id, ref) + }, + ) + is TranscriptRow.Single -> + when (val item = row.item) { + is TranscriptItem.UserMsg -> + UserBubble( + settings = settings, + sessionId = summary.id, + text = item.text, + images = item.images, + ) + is TranscriptItem.AssistantMsg -> + // A whole assistant row is only ever the reply + // still + // arriving -- every settled reply is flattened + // into + // block units instead; see [transcriptUnits]. + // Live + // is + // what earns its blocks a layer each while + // deltas + // land. + AssistantMessage( + item.text, + replies, + live = true, + ) + is TranscriptItem.ToolRun -> + ToolCard( + tool = item, + expanded = item.id in expandedTools, + onToggle = { + toggleAnchored(row) { + expandedTools = + if (item.id in expandedTools) + expandedTools - item.id + else expandedTools + item.id + } + }, + onAnswer = { questionId, answers -> + act { + answerQuestion( + settings, + summary.id, + questionId, + answers, + ) + } + }, + image = { ref -> + SessionImage(settings, summary.id, ref) + }, + ) + is TranscriptItem.QuestionCard -> + QuestionRow(item) { answers -> + act { + answerQuestion( + settings, + summary.id, + item.id, + answers, + ) } - }, - ) - } + } + is TranscriptItem.ErrorMsg -> + Text( + item.message, + color = MaterialTheme.colorScheme.error, + style = MaterialTheme.typography.bodyMedium, + ) + is TranscriptItem.ImageItem -> + SessionImage(settings, summary.id, item.ref) + is TranscriptItem.Note -> + Text( + item.text, + style = MaterialTheme.typography.bodySmall, + color = + MaterialTheme.colorScheme + .onSurfaceVariant, + ) + is TranscriptItem.CommandRow -> + CommandBubble(item.text) + is TranscriptItem.ClearedNote -> ClearedRow() + is TranscriptItem.CompactedNote -> + CompactedRow(item) + is TranscriptItem.PeerNote -> + PeerMessageRow( + item = item, + expanded = item.seq in expandedNotes, + replies = replies, + onToggle = { + toggleAnchored(row) { + expandedNotes = + if (item.seq in expandedNotes) + expandedNotes - item.seq + else expandedNotes + item.seq + } + }, + ) + } + } } } } } } - } - // Still finding out what this conversation is: the newest page has not arrived, or - // it has and the list is being put back where reading stopped. Both draw no rows at - // all, and a blank page is what this screen otherwise means by "there is nothing - // here" -- so the state that does not know needs its own appearance rather than - // sharing one with the empty answer. - // - // 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. - // `settled` and not `restoring` alone, so the spinner covers the whole wait: fetching - // 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. - if (!ready || !settled) { - CircularProgressIndicator(Modifier.align(Alignment.Center).size(LOADING_SPINNER)) - } - - // Only while the newest message is off-screen. Reading back - // through a conversation is a place to be, not a state to be - // rescued from, so this waits to be wanted. - // - // Down, and the same chevron a tool group collapses with: the - // list is built upside down internally, but nobody reading it - // knows that -- on screen the newest message is at the bottom, - // which is where this goes. The name is carried in the - // description, since an arrow alone says nothing to a screen - // reader and nothing to whoever finds this in six months. - if (!atNewest) { - Surface( - // Instantly. An animated scroll travels the whole transcript, so the - // further back somebody has read the longer this takes -- the one press - // 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 - // 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, - // landing the reader at the bottom with new messages not bringing the view - // with them. - onClick = { scope.launch { listState.scrollToItem(0) } }, - shape = CircleShape, - color = MaterialTheme.colorScheme.surfaceContainerHigh, - modifier = - Modifier.align(Alignment.BottomCenter).padding(bottom = 12.dp).semantics { - contentDescription = "Jump to latest" - }, - ) { - Chevron( - pointingUp = false, - colour = MaterialTheme.colorScheme.onSurface, - modifier = Modifier.padding(horizontal = 16.dp, vertical = 12.dp), + // Still finding out what this conversation is: the newest page has not arrived, or + // it has and the list is being put back where reading stopped. Both draw no rows at + // all, and a blank page is what this screen otherwise means by "there is nothing + // here" -- so the state that does not know needs its own appearance rather than + // sharing one with the empty answer. + // + // 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. + // `settled` and not `restoring` alone, so the spinner covers the whole wait: + // fetching + // 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. + if (!ready || !settled) { + CircularProgressIndicator( + Modifier.align(Alignment.Center).size(LOADING_SPINNER) ) } + + // Only while the newest message is off-screen. Reading back + // through a conversation is a place to be, not a state to be + // rescued from, so this waits to be wanted. + // + // Down, and the same chevron a tool group collapses with: the + // list is built upside down internally, but nobody reading it + // knows that -- on screen the newest message is at the bottom, + // which is where this goes. The name is carried in the + // description, since an arrow alone says nothing to a screen + // reader and nothing to whoever finds this in six months. + if (!atNewest) { + Surface( + // Instantly. An animated scroll travels the whole transcript, so the + // further back somebody has read the longer this takes -- the one press + // 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 + // 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, + // landing the reader at the bottom with new messages not bringing the view + // with them. + onClick = { scope.launch { listState.scrollToItem(0) } }, + shape = CircleShape, + color = MaterialTheme.colorScheme.surfaceContainerHigh, + modifier = + Modifier.align(Alignment.BottomCenter) + .padding(bottom = 12.dp) + .semantics { contentDescription = "Jump to latest" }, + ) { + Chevron( + pointingUp = false, + colour = MaterialTheme.colorScheme.onSurface, + modifier = Modifier.padding(horizontal = 16.dp, vertical = 12.dp), + ) + } + } } } - pendingModel?.let { chosen -> - ModelSwitchWarning( - from = modelLabel(model), - to = modelLabel(chosen), - onDismiss = { pendingModel = null }, - onConfirm = { - pendingModel = null - act { setSessionModel(settings, summary.id, chosen) } - }, - ) - } - - SessionStatusRow( - status = status, - compactingFor = compactingFor, - contextTokens = contextTokens, - ) - - // Between the transcript and the box: above what is being typed, so the list does not - // cover the thing the command is about, and below everything that explains it. - CommandSuggestions( - commands = suggestedCommands(input), - onPick = { command -> input = command.typed() }, - ) - - // Always enabled -- a send while the session is running becomes a - // steering message injected at the next tool boundary, which is - // the point of the whole app. - // - // The field gets a row of its own, above the buttons: sharing one - // put the full width behind three controls, so the thing being - // typed into was the narrowest thing on the row. - Column(Modifier.fillMaxWidth().padding(8.dp)) { - // Directly above the box they will be sent from, so what is attached is visible - // rather than counted: the "+2" on the button below said how many and never which. - PendingAttachments( - settings = settings, - sessionId = summary.id, - refs = pendingAttachments, - onRemove = { pendingAttachments = pendingAttachments - it }, - ) - OutlinedTextField( - value = input, - onValueChange = { - input = it - saveDraft(context, summary.id, it) - }, - modifier = Modifier.fillMaxWidth(), - // No longer "(+image)": the images are on screen above this, and a placeholder - // saying so said it in words beside the thing itself. - placeholder = { Text("Message") }, - maxLines = 4, - ) - Row( - verticalAlignment = Alignment.CenterVertically, - modifier = Modifier.fillMaxWidth(), - ) { - TextButton( - onClick = { - pickImage.launch( - PickVisualMediaRequest( - ActivityResultContracts.PickVisualMedia.ImageOnly - ) - ) - } - ) { - // Just "+" now. The count was standing in for showing them. - Text("+") + // 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() } - // The settings share what is left after the actions have - // taken what they need. A Row hands out intrinsic widths in - // order and clips whatever runs past the edge, so with - // these laid out first the arrival of Stop pushed Send off - // the screen entirely -- the app's central control, gone at - // exactly the moment the app is most in use. + .background(MaterialTheme.colorScheme.background) + ) { + pendingModel?.let { chosen -> + ModelSwitchWarning( + from = modelLabel(model), + to = modelLabel(chosen), + onDismiss = { pendingModel = null }, + onConfirm = { + pendingModel = null + act { setSessionModel(settings, summary.id, chosen) } + }, + ) + } + + SessionStatusRow( + status = status, + compactingFor = compactingFor, + contextTokens = contextTokens, + ) + + // Between the transcript and the box: above what is being typed, so the list does not + // cover the thing the command is about, and below everything that explains it. + CommandSuggestions( + commands = suggestedCommands(input), + onPick = { command -> input = command.typed() }, + ) + + // Always enabled -- a send while the session is running becomes a + // steering message injected at the next tool boundary, which is + // the point of the whole app. + // + // The field gets a row of its own, above the buttons: sharing one + // put the full width behind three controls, so the thing being + // typed into was the narrowest thing on the row. + Column(Modifier.fillMaxWidth().padding(8.dp)) { + // Directly above the box they will be sent from, so what is attached is visible + // rather than counted: the "+2" on the button below said how many and never which. + PendingAttachments( + settings = settings, + sessionId = summary.id, + refs = pendingAttachments, + onRemove = { pendingAttachments = pendingAttachments - it }, + ) + OutlinedTextField( + value = input, + onValueChange = { + input = it + saveDraft(context, summary.id, it) + }, + modifier = Modifier.fillMaxWidth(), + // No longer "(+image)": the images are on screen above this, and a placeholder + // saying so said it in words beside the thing itself. + placeholder = { Text("Message") }, + maxLines = 4, + ) Row( verticalAlignment = Alignment.CenterVertically, - modifier = Modifier.weight(1f), + modifier = Modifier.fillMaxWidth(), ) { - if (offeredModels.isNotEmpty()) { + TextButton( + onClick = { + pickImage.launch( + PickVisualMediaRequest( + ActivityResultContracts.PickVisualMedia.ImageOnly + ) + ) + } + ) { + // Just "+" now. The count was standing in for showing them. + Text("+") + } + // The settings share what is left after the actions have + // taken what they need. A Row hands out intrinsic widths in + // order and clips whatever runs past the edge, so with + // these laid out first the arrival of Stop pushed Send off + // the screen entirely -- the app's central control, gone at + // exactly the moment the app is most in use. + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier.weight(1f), + ) { + if (offeredModels.isNotEmpty()) { + PickerButton( + current = modelLabel(model), + // What the machine offers, plus the state a session is in when it + // has chosen none of them. The button has always been able to say + // "default"; until this the list could not, so leaving it was a + // one-way trip. + options = listOf(DEFAULT_MODEL) + offeredModels, + // Not set here. The button follows what the session reports it + // is set to, which arrives a moment later and is sometimes a + // different answer -- a name the CLI resolved, or no change at all + // on a provider whose model is fixed when it starts. + // Asked about first, unless there is nothing to lose by it -- + // see [ModelSwitchWarning]. + onPick = { chosen -> + if ( + modelLabel(chosen) == modelLabel(model) || items.isEmpty() + ) { + act { setSessionModel(settings, summary.id, chosen) } + } else { + pendingModel = chosen + } + }, + ) + } PickerButton( - current = modelLabel(model), - // What the machine offers, plus the state a session is in when it - // has chosen none of them. The button has always been able to say - // "default"; until this the list could not, so leaving it was a - // one-way trip. - options = listOf(DEFAULT_MODEL) + offeredModels, - // Not set here. The button follows what the session reports it - // is set to, which arrives a moment later and is sometimes a - // different answer -- a name the CLI resolved, or no change at all - // on a provider whose model is fixed when it starts. - // Asked about first, unless there is nothing to lose by it -- - // see [ModelSwitchWarning]. + current = permissionMode, + options = PERMISSION_MODES, onPick = { chosen -> - if (modelLabel(chosen) == modelLabel(model) || items.isEmpty()) { - act { setSessionModel(settings, summary.id, chosen) } - } else { - pendingModel = chosen - } + act { setSessionPermissionMode(settings, summary.id, chosen) } }, ) } - PickerButton( - current = permissionMode, - options = PERMISSION_MODES, - onPick = { chosen -> - act { setSessionPermissionMode(settings, summary.id, chosen) } - }, - ) - } - // 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 - // said they were a primary action and its qualifier. What separates them is the - // colour and the mark, which is what they mean. - // - // 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 - // 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. - val process = - when { - running -> ProcessAction.Pause - status == "exited" -> ProcessAction.Start - else -> ProcessAction.Stop - } - Button( - onClick = { - processInFlight = true - act(onDone = { processInFlight = false }) { - process.perform(settings, summary.id) + // 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 + // said they were a primary action and its qualifier. What separates them is the + // colour and the mark, which is what they mean. + // + // 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 + // 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. + val process = + when { + running -> ProcessAction.Pause + status == "exited" -> ProcessAction.Start + else -> ProcessAction.Stop } - }, - enabled = !processInFlight, - colors = actionButtonColors(process.colour()), - ) { - Glyph( - process.glyph, - colour = LocalContentColor.current, - modifier = Modifier.semantics { contentDescription = process.label }, - ) - } - Spacer(Modifier.width(8.dp)) - // 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 - // 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. - // - // Disabled while there is nothing to send, rather than pressable and silent: - // `send` has always returned early on an empty composer, so the button promised - // something it would not do, and the only feedback was the ripple. Disabled and - // not hidden, for the reason the button beside it is always here. - Button( - onClick = { send() }, - enabled = input.isNotBlank() || pendingAttachments.isNotEmpty(), - colors = actionButtonColors(if (running) queueColor else sendColor), - ) { - Glyph( - if (running) QUEUE_GLYPH else SEND_GLYPH, - colour = LocalContentColor.current, - modifier = Modifier.semantics { contentDescription = sendLabel(running) }, - ) + Button( + onClick = { + processInFlight = true + act(onDone = { processInFlight = false }) { + process.perform(settings, summary.id) + } + }, + enabled = !processInFlight, + colors = actionButtonColors(process.colour()), + ) { + Glyph( + process.glyph, + colour = LocalContentColor.current, + modifier = Modifier.semantics { contentDescription = process.label }, + ) + } + Spacer(Modifier.width(8.dp)) + // 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 + // 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. + // + // Disabled while there is nothing to send, rather than pressable and silent: + // `send` has always returned early on an empty composer, so the button promised + // something it would not do, and the only feedback was the ripple. Disabled and + // not hidden, for the reason the button beside it is always here. + Button( + onClick = { send() }, + enabled = input.isNotBlank() || pendingAttachments.isNotEmpty(), + colors = actionButtonColors(if (running) queueColor else sendColor), + ) { + Glyph( + if (running) QUEUE_GLYPH else SEND_GLYPH, + colour = LocalContentColor.current, + modifier = + Modifier.semantics { contentDescription = sendLabel(running) }, + ) + } } } } @@ -2097,6 +2187,7 @@ private fun SessionStatusRow( contextTokens: Long?, modifier: Modifier = Modifier, ) { + DebugStats.count("status row recomposed") Row( verticalAlignment = Alignment.CenterVertically, modifier = modifier.fillMaxWidth().padding(horizontal = 12.dp, vertical = 4.dp), diff --git a/app/androidApp/src/main/kotlin/com/example/aiapp/SessionUsageBar.kt b/app/androidApp/src/main/kotlin/com/example/aiapp/SessionUsageBar.kt index 1cea984..ada615f 100644 --- a/app/androidApp/src/main/kotlin/com/example/aiapp/SessionUsageBar.kt +++ b/app/androidApp/src/main/kotlin/com/example/aiapp/SessionUsageBar.kt @@ -116,6 +116,7 @@ fun usageGlyphColour(usage: SessionUsage): Color = */ @Composable 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 // 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