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