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. *