diff --git a/app/androidApp/src/main/kotlin/com/example/aiapp/Field.kt b/app/androidApp/src/main/kotlin/com/example/aiapp/Field.kt index ec9dd78..8a3c9b4 100644 --- a/app/androidApp/src/main/kotlin/com/example/aiapp/Field.kt +++ b/app/androidApp/src/main/kotlin/com/example/aiapp/Field.kt @@ -35,9 +35,10 @@ import androidx.compose.ui.unit.dp * is the framing rather than the text, and a field whose contents are smaller than the text beside * it is a field the reader has to lean in to check. See UI_RULES on never shrinking text to fit. * - * [hint] is what leaving it blank means, and it goes above the box with the label for the same - * reason the label does: inside, it is gone the moment anybody types, which is exactly when a - * reader looks back to check what they are overriding. + * [hint] is what leaving it blank means, drawn inside the empty box. It had a grey line of its own + * above the box until 2026-09-21: a form of a dozen settings was then mostly explanation, and a + * setting should be a title and a box to type in. Inside, it costs no height and is gone the moment + * anybody types -- which is the trade, since that is also when somebody might look back at it. */ @Composable fun LabelledField( @@ -61,15 +62,7 @@ fun LabelledField( // control beside it is a switch or a picker. Smaller and greyer on the ones that are // fields reads as two ranks of setting where there is one. Text(label, modifier = Modifier.padding(bottom = 2.dp)) - hint?.let { - Text( - it, - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurfaceVariant, - modifier = Modifier.padding(bottom = 2.dp), - ) - } - FieldBox(value, onValueChange, enabled, lines, keyboardOptions, keyboardActions) + FieldBox(value, onValueChange, enabled, lines, keyboardOptions, keyboardActions, hint) } } @@ -82,6 +75,7 @@ private fun FieldBox( lines: Int, keyboardOptions: KeyboardOptions, keyboardActions: KeyboardActions, + hint: String?, ) { val interactions = remember { MutableInteractionSource() } val focused by interactions.collectIsFocusedAsState() @@ -119,6 +113,15 @@ private fun FieldBox( .background(MaterialTheme.colorScheme.surfaceContainerHighest, shape) .border(1.dp, edge, shape) .padding(horizontal = 10.dp, vertical = 8.dp), - decorationBox = { field -> Box { field() } }, + decorationBox = { field -> + Box { + // Under the text rather than beside it: the value is what the box is for, and a + // hint that pushed it sideways would move every character as somebody typed. + if (value.isEmpty() && hint != null) { + Text(hint, color = MaterialTheme.colorScheme.onSurfaceVariant) + } + field() + } + }, ) } 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 996db55..f993e86 100644 --- a/app/androidApp/src/main/kotlin/com/example/aiapp/SessionScreen.kt +++ b/app/androidApp/src/main/kotlin/com/example/aiapp/SessionScreen.kt @@ -2779,8 +2779,9 @@ private fun ModelSwitchWarning( * message ran underneath it, and the working indicator was an item inside the list, so it scrolled * away exactly when somebody reading back wanted to know whether anything was still happening. * - * The row is drawn whether or not it has anything to say. An empty one costs a line; a row that - * came and went would move the text box under the reader's thumb every time a turn started. + * The row is drawn whether or not it has anything to say, and so is the space above it for a wait's + * bar. An empty one costs a line; either coming and going would move the text box under the + * reader's thumb every time a turn started. * * `exited` is here because a session whose process is gone cannot be typed at, and with the * indicator gone from the list nothing else on this screen would say so. @@ -2811,21 +2812,67 @@ private fun SessionStatusRow( subagent: Boolean = false, ) { DebugStats.count("status row recomposed") + Column(modifier.fillMaxWidth()) { + // The wait's bar, the whole width and above the words. In the width left over beside + // them it stood where the context figure goes, which is what a reader wants to keep + // seeing while a turn is read. The height is held whether or not there is a bar: this + // sits above the transcript and the box, and one that came and went would move both + // under the reader's thumb every time a turn started. + Box(Modifier.fillMaxWidth().height(PROGRESS_BAR_HEIGHT)) { + when { + // Indeterminate for a compaction, which is a statement rather than an omission: + // the CLI says one has begun and then nothing at all until it has finished -- + // measured on a real 80,346-to-2,088-token compaction that took 23 seconds and + // produced not one line in between. A bar creeping along at the pace of the last + // one would be this screen inventing the part nobody sent it. + status == "compacting" -> + LinearProgressIndicator( + color = commandColor, + trackColor = MaterialTheme.colorScheme.surfaceContainerHigh, + modifier = Modifier.fillMaxSize(), + ) + progress != null -> + LinearProgressIndicator( + progress = { progress.fraction }, + color = commandColor, + trackColor = MaterialTheme.colorScheme.surfaceContainerHigh, + modifier = Modifier.fillMaxSize(), + ) + } + } + StatusWords( + status, + compactingFor, + progress, + contextTokens, + contextLimit, + backgroundTasks, + subagent, + ) + } +} + +/** How tall a wait's bar is, and the space kept for one when there is no wait. */ +private val PROGRESS_BAR_HEIGHT = 4.dp + +/** The words of [SessionStatusRow]: what the session is doing, and what it is holding. */ +@Composable +private fun StatusWords( + status: String, + compactingFor: Long?, + progress: SessionProgress?, + contextTokens: Long?, + contextLimit: Long?, + backgroundTasks: Int, + subagent: Boolean, +) { Row( verticalAlignment = Alignment.CenterVertically, - modifier = modifier.fillMaxWidth().padding(horizontal = 12.dp, vertical = 4.dp), + modifier = Modifier.fillMaxWidth().padding(horizontal = 12.dp, vertical = 4.dp), ) { when (status) { - // A bar rather than the spinner an ordinary turn gets, and it takes the row's whole - // free width: nothing arrives in the transcript during a compaction, so this is the - // only thing on screen that is moving, and at a spinner's width that reads as a session - // that has hung. - // - // Indeterminate, which is a statement rather than an omission. The CLI says a - // compaction has begun and then nothing at all until it has finished -- measured on a - // real 80,346-to-2,088-token compaction that took 23 seconds and produced not one line - // in between. So there is no fraction to fill, and a bar creeping along at the pace of - // the last one would be this screen inventing the part nobody sent it. + // No spinner: a compaction's bar is above, and nothing arrives in the transcript + // while one is on, so that bar is the whole of what says this is still going. "compacting" -> { Text( compactingLabel(compactingFor), @@ -2842,11 +2889,7 @@ private fun SessionStatusRow( modifier = Modifier.padding(start = 8.dp), ) } - LinearProgressIndicator( - color = commandColor, - trackColor = MaterialTheme.colorScheme.surfaceContainerHigh, - modifier = Modifier.weight(1f).padding(horizontal = 8.dp), - ) + Spacer(Modifier.weight(1f)) } // The three states with something happening in them and nothing wanted from the // reader. One branch, because what they share is the spinner -- the machine is busy -- @@ -2867,7 +2910,14 @@ private fun SessionStatusRow( Text( // "working" rather than "running" for the one that is generating: the word is // there to say the machine is busy, and the other two say what it is busy at. - if (status == "running") "working" else sessionStatusWord(status, subagent), + // How far along joins it as part of the same sentence -- it is what the bar + // above is showing, in figures. + listOfNotNull( + if (status == "running") "working" + else sessionStatusWord(status, subagent), + progress?.let { percentLabel(it) }, + ) + .joinToString(" "), style = MaterialTheme.typography.labelSmall, color = MaterialTheme.colorScheme.onSurfaceVariant, modifier = Modifier.padding(start = 8.dp), @@ -2880,21 +2930,7 @@ private fun SessionStatusRow( modifier = Modifier.padding(start = 8.dp), ) } - // The wait's own measurement, in the row's free width rather than in a line of its - // own: a bar that came and went would move the transcript and the box under the - // reader every time a turn started. The same place a compaction's bar goes, and - // determinate here because unlike a compaction this one is actually being measured - // -- see `SessionProgress`. - if (progress != null) { - LinearProgressIndicator( - progress = { progress.fraction }, - color = commandColor, - trackColor = MaterialTheme.colorScheme.surfaceContainerHigh, - modifier = Modifier.weight(1f).padding(horizontal = 8.dp), - ) - } else { - Spacer(Modifier.weight(1f)) - } + Spacer(Modifier.weight(1f)) } // Every remaining state says which one it is, including the quiet one. The row used to // name only `exited` and leave the rest blank, so a session sitting idle and one whose @@ -2926,13 +2962,8 @@ private fun SessionStatusRow( // and the two used to share an appearance: a session just cleared, one whose provider never // reports usage, and one that has not run a turn all showed nothing at all, which reads as // a conversation with room to spare. - // - // A wait that is being measured takes this place instead, because what the reader is - // asking while one is on is how much longer -- and for the wait that has one, the context - // figure is about to change anyway: a model that has not finished loading is holding - // nothing, and a prompt half read is a context still being counted. Text( - progress?.let { percentLabel(it) } ?: contextLabel(contextTokens, contextLimit), + contextLabel(contextTokens, contextLimit), style = MaterialTheme.typography.labelSmall, color = MaterialTheme.colorScheme.onSurfaceVariant, )