From 45f249ae91f5210e5cde687522cf088a229488d0 Mon Sep 17 00:00:00 2001 From: iris-ai <4+iris-ai@noreply.localhost> Date: Sat, 19 Sep 2026 14:28:38 -0400 Subject: [PATCH] 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. --- .../main/kotlin/com/example/aiapp/Bubble.kt | 51 ++++++++ .../kotlin/com/example/aiapp/SessionScreen.kt | 112 ++++++++++++------ .../example/aiapp/SessionSettingsDialog.kt | 41 ++++++- 3 files changed, 169 insertions(+), 35 deletions(-) diff --git a/app/androidApp/src/main/kotlin/com/example/aiapp/Bubble.kt b/app/androidApp/src/main/kotlin/com/example/aiapp/Bubble.kt index a2e72f9..f1963a7 100644 --- a/app/androidApp/src/main/kotlin/com/example/aiapp/Bubble.kt +++ b/app/androidApp/src/main/kotlin/com/example/aiapp/Bubble.kt @@ -1,10 +1,15 @@ package com.example.aiapp +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.Button import androidx.compose.material3.ButtonDefaults import androidx.compose.material3.OutlinedButton import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Shape import androidx.compose.ui.unit.dp @@ -49,3 +54,49 @@ val BubbleShape: Shape = RoundedCornerShape(percent = 50) * ends that tall would bow its sides. */ val BubbleMenuShape: Shape = RoundedCornerShape(20.dp) + +/** + * A round button sized to the mark it draws. + * + * The composer's three actions -- attach, stop, send -- are single glyphs, and a pill's word-shaped + * padding around one glyph was width taken from the pickers beside it: with a long model name on + * the row, the permission mode ended up too small to hit. One diameter for all three, and it is the + * platform's minimum touch target rather than a button's shorter default height. + * + * [fill] null draws the outlined form, for the one of the three that does not act on the session. + */ +@Composable +fun CircleButton( + onClick: () -> Unit, + modifier: Modifier = Modifier, + fill: Color? = null, + enabled: Boolean = true, + content: @Composable () -> Unit, +) { + val sized = modifier.size(CircleButtonSize) + if (fill == null) { + OutlinedButton( + onClick = onClick, + enabled = enabled, + shape = CircleShape, + contentPadding = PaddingValues(0.dp), + modifier = sized, + ) { + content() + } + } else { + Button( + onClick = onClick, + enabled = enabled, + shape = CircleShape, + colors = actionButtonColors(fill), + contentPadding = PaddingValues(0.dp), + modifier = sized, + ) { + content() + } + } +} + +/** How wide and tall one of those is; see [CircleButton]. */ +val CircleButtonSize = 48.dp 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 0ab5cfa..154314e 100644 --- a/app/androidApp/src/main/kotlin/com/example/aiapp/SessionScreen.kt +++ b/app/androidApp/src/main/kotlin/com/example/aiapp/SessionScreen.kt @@ -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, onPick: (String) -> Unit) { +fun PickerButton( + current: String, + options: List, + 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, 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 } ) { diff --git a/app/androidApp/src/main/kotlin/com/example/aiapp/SessionSettingsDialog.kt b/app/androidApp/src/main/kotlin/com/example/aiapp/SessionSettingsDialog.kt index 866df57..088f1a0 100644 --- a/app/androidApp/src/main/kotlin/com/example/aiapp/SessionSettingsDialog.kt +++ b/app/androidApp/src/main/kotlin/com/example/aiapp/SessionSettingsDialog.kt @@ -44,8 +44,10 @@ import kotlinx.coroutines.withContext * screen of its own until 2026-08-30, which put a page transition and a back stack around two * controls and hid the thing they act on. * - * The model and the permission mode are deliberately still on the session's own bar, because those - * are changed *while* reading a turn -- "not this model, try that one". + * The model and the permission mode are on the session's own bar as well, because those are changed + * *while* reading a turn -- "not this model, try that one". They are here too because that bar is + * one row shared with three actions: a long model name leaves the other picker a few pixels wide, + * and this is where somebody goes looking for a setting anyway. * * Captions are for what a control costs rather than for what it is. A paragraph under every control * made the dialog longer than the conversation it covers -- so Notifications has none, while Move @@ -74,6 +76,14 @@ fun SessionSettingsDialog( onEffortChanged: (String?) -> Unit, /** Whether a level does anything here; the row is left out entirely where it does not. */ takesEffort: Boolean, + /** + * The settings that are one of a list -- the model and the permission mode. + * + * Owned by the screen behind this, like [title] and [effort]: it is what asked the machine what + * the provider offers. Whichever of them this one has no answer for is not in the list, and + * draws no row. + */ + choices: List, /** * The settings this session's provider takes, and what they are set to. * @@ -422,6 +432,20 @@ fun SessionSettingsDialog( style = MaterialTheme.typography.bodySmall, ) } + choices.forEach { choice -> + Spacer(Modifier.height(8.dp)) + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier.fillMaxWidth(), + ) { + Text(choice.label, modifier = Modifier.weight(1f)) + PickerButton( + current = choice.current, + options = choice.options, + onPick = choice.onPick, + ) + } + } // Left out rather than disabled, the one place this dialog does that: a disabled // control teaches what the thing can do, and a llama session cannot do this at all // -- the row would be teaching something false about it. @@ -550,6 +574,19 @@ fun SessionSettingsDialog( ) } +/** + * One session setting that is a choice from a list, as this dialog draws it. + * + * A shape rather than a pair of parameters each, because a provider may offer either of them, both + * or neither, and they are otherwise the same control. + */ +data class SessionChoice( + val label: String, + val current: String, + val options: List, + val onPick: (String) -> Unit, +) + /** * When the server will next look, as a local time. *