Even out the composer's row, and put its two pickers in settings

The composer's row gave the model and the permission mode whatever width
their words asked for, after three word-shaped action buttons had taken
theirs. A llama session's model names run long, so the permission mode
was squeezed to a chip too small to tap.

The three actions are circles now -- one diameter, the platform's minimum
touch target -- so they take what their glyph needs and nothing more, and
the two pickers share what is left evenly rather than by the length of
what they say. Every gap on the row is the same.

Both pickers are also rows in the session settings dialog, for every
provider that offers them: the dialog has a line each, so the whole model
name is readable there. Choosing goes through the same two functions as
the composer's pickers, so the model switch warning cannot be skipped by
picking from one of the two places -- and it closes the dialog rather than
stacking a question behind it.

Looked at on the emulator against the sandbox: a llama session with the
27B loaded, and a claude-cli one, both composer and dialog, with the
keyboard up and down.
This commit is contained in:
iris-ai committed 2026-09-19 14:28:38 -04:00
1 parent 81ab564a09
commit 45f249ae91
3 files changed
+169 -35

No files matched your search

@@ -14,6 +14,7 @@ import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.gestures.awaitEachGesture
import androidx.compose.foundation.gestures.awaitFirstDown
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.ExperimentalLayoutApi
@@ -35,7 +36,6 @@ import androidx.compose.foundation.lazy.LazyListState
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.text.selection.rememberSelectionState
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.Button
import androidx.compose.material3.Card
import androidx.compose.material3.CardDefaults
import androidx.compose.material3.CircularProgressIndicator
@@ -1436,6 +1436,33 @@ fun SessionScreen(
var usageOpen by remember { mutableStateOf(false) }
var settingsOpen by remember { mutableStateOf(false) }
/**
* Chooses a model, from the composer's picker or from the settings dialog.
*
* One function because both have to ask the same question first: what a switch costs is
* invisible -- see [ModelSwitchWarning] -- and a second copy of this is how one of the two
* comes to skip it. It takes the label the menu drew, which is what a picker hands back.
*/
fun chooseModel(picked: String) {
// Back to the id, because that is what the server resolves and it is not always the word
// on the chip.
val chosen = offeredModels.firstOrNull { it.label == picked }?.id ?: picked
if (label(chosen) == label(model) || !worthWarningAbout(status, contextTokens, items)) {
act { setSessionModel(settings, summary.id, chosen) }
return
}
// The warning is a dialog and so is the settings screen it may have been chosen from.
// Stacking the two would put the question behind the thing that asked it, so the settings
// close and the confirmation is what is left on screen.
settingsOpen = false
pendingModel = chosen
}
/** The same for the permission mode, which costs nothing and so asks nothing. */
fun choosePermissionMode(chosen: String) {
act { setSessionPermissionMode(settings, summary.id, chosen) }
}
// 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-
@@ -2012,6 +2039,7 @@ fun SessionScreen(
)
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(4.dp),
modifier = Modifier.fillMaxWidth(),
) {
// Photo or file, asked here rather than by two buttons: the row is full,
@@ -2021,7 +2049,7 @@ fun SessionScreen(
Box {
// Just "+". The count it used to carry was standing in for showing
// them.
BubbleButton(onClick = { attaching = true }) { Text("+") }
CircleButton(onClick = { attaching = true }) { Text("+") }
DropdownMenu(
expanded = attaching,
onDismissRequest = { attaching = false },
@@ -2055,12 +2083,19 @@ fun SessionScreen(
// 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 the
// moment it is most in use.
//
// Between themselves they share it evenly rather than by the length of
// what they say: a model name runs long and a permission mode does not,
// and giving each what it asks for shrank the permission mode to a chip
// too small to hit.
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(4.dp),
modifier = Modifier.weight(1f),
) {
if (offeredModels.isNotEmpty()) {
PickerButton(
modifier = Modifier.weight(1f),
current = label(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
@@ -2075,36 +2110,15 @@ fun SessionScreen(
// all on a provider whose model is fixed. Asked about first,
// unless there is nothing to lose by it -- see
// [ModelSwitchWarning].
onPick = { picked ->
// Back to the id, because that is what the server resolves
// and it is not always the word on the chip.
val chosen =
offeredModels.firstOrNull { it.label == picked }?.id
?: picked
if (
label(chosen) == label(model) ||
!worthWarningAbout(status, contextTokens, items)
) {
act { setSessionModel(settings, summary.id, chosen) }
} else {
pendingModel = chosen
}
},
onPick = ::chooseModel,
)
}
if (offeredPermissionModes.isNotEmpty()) {
PickerButton(
modifier = Modifier.weight(1f),
current = permissionMode,
options = offeredPermissionModes,
onPick = { chosen ->
act {
setSessionPermissionMode(
settings,
summary.id,
chosen,
)
}
},
onPick = ::choosePermissionMode,
)
}
}
@@ -2124,7 +2138,7 @@ fun SessionScreen(
status == "exited" -> ProcessAction.Start
else -> ProcessAction.Stop
}
Button(
CircleButton(
onClick = {
processInFlight = true
act(onDone = { processInFlight = false }) {
@@ -2132,7 +2146,7 @@ fun SessionScreen(
}
},
enabled = !processInFlight,
colors = actionButtonColors(process.colour()),
fill = process.colour(),
) {
Glyph(
process.glyph,
@@ -2141,7 +2155,6 @@ fun SessionScreen(
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
@@ -2152,10 +2165,10 @@ fun SessionScreen(
// `send` has always returned early on an empty composer, so the button
// promised something it would not do. Disabled and not hidden, for the
// reason above.
Button(
CircleButton(
onClick = { send() },
enabled = input.text.isNotBlank() || pendingAttachments.isNotEmpty(),
colors = actionButtonColors(if (running) queueColor else sendColor),
fill = if (running) queueColor else sendColor,
) {
Glyph(
if (running) QUEUE_GLYPH else SEND_GLYPH,
@@ -2233,6 +2246,32 @@ fun SessionScreen(
title = title,
effort = effort.takeIf { summary.takesEffort },
takesEffort = summary.takesEffort,
// The same two pickers the composer's row carries. Built inside the branch that
// draws them, so a screen recomposing on every streaming event is not rebuilding a
// list nothing is looking at.
choices =
buildList {
if (offeredModels.isNotEmpty()) {
add(
SessionChoice(
label = "Model",
current = label(model),
options = listOf(DEFAULT_MODEL) + offeredModels.map { it.label },
onPick = ::chooseModel,
)
)
}
if (offeredPermissionModes.isNotEmpty()) {
add(
SessionChoice(
label = "Permissions",
current = permissionMode,
options = offeredPermissionModes,
onPick = ::choosePermissionMode,
)
)
}
},
onEffortChanged = { effort = it },
paramSpecs = paramSpecs,
params = params,
@@ -2672,7 +2711,12 @@ private const val ONE_TAP_MS = 250L
* session is set to without spending a second line on saying it.
*/
@Composable
fun PickerButton(current: String, options: List<String>, onPick: (String) -> Unit) {
fun PickerButton(
current: String,
options: List<String>,
modifier: Modifier = Modifier,
onPick: (String) -> Unit,
) {
var open by remember { mutableStateOf(false) }
// When an outside touch last closed the menu.
//
@@ -2682,7 +2726,9 @@ fun PickerButton(current: String, options: List<String>, onPick: (String) -> Uni
// the release, measured 3ms apart on the emulator, so a button that simply opened on every
// click would reopen what the same finger had just closed.
var closedAt by remember { mutableLongStateOf(0L) }
Box {
// Minimum constraints propagated, which is what lets a caller give this a weight: without it
// the button sizes itself to its word inside a box the row made wider.
Box(modifier, propagateMinConstraints = true) {
BubbleButton(
onClick = { if (SystemClock.uptimeMillis() - closedAt > ONE_TAP_MS) open = true }
) {