A setting is a title and a box, and a wait's bar spans the screen

A field's hint had a grey line of its own between the label and the box, so a
form of a dozen settings was mostly explanation. It is the empty box's
placeholder now, where it costs no height and says the same thing to anybody
who has not typed yet.

The wait's bar was drawn in whatever width the status row had left beside its
words, which is where the context figure goes -- so a session loading a model
or reading a prompt stopped saying how full it was, at the moment somebody is
watching it fill. The bar is the whole width above the row instead, the
compaction's indeterminate one with it, and the percentage joins the status
word ("reading prompt 30%") rather than replacing the context. The row keeps
the bar's height when there is no bar: it 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.

Checked on the emulator against the sandbox's progress rig: /loading and
/reading both draw a full-width bar with "context unknown" still beside them,
and the settings form has no grey lines left between a label and its box.
ktfmt, compile, lint and the unit tests are clean.
This commit is contained in:
iris-ai committed 2026-09-21 12:13:08 -04:00
1 parent 1aac22bfc9
commit 5626a7d595
2 files changed
+87 -53

No files matched your search

@@ -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()
}
},
)
}
@@ -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,
)