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:
1 parent
81ab564a09
commit
45f249ae91
3 files changed
+169
-35
No files matched your search
@@ -1,10 +1,15 @@
|
|||||||
package com.example.aiapp
|
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.foundation.shape.RoundedCornerShape
|
||||||
|
import androidx.compose.material3.Button
|
||||||
import androidx.compose.material3.ButtonDefaults
|
import androidx.compose.material3.ButtonDefaults
|
||||||
import androidx.compose.material3.OutlinedButton
|
import androidx.compose.material3.OutlinedButton
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.graphics.Color
|
||||||
import androidx.compose.ui.graphics.Shape
|
import androidx.compose.ui.graphics.Shape
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
|
|
||||||
@@ -49,3 +54,49 @@ val BubbleShape: Shape = RoundedCornerShape(percent = 50)
|
|||||||
* ends that tall would bow its sides.
|
* ends that tall would bow its sides.
|
||||||
*/
|
*/
|
||||||
val BubbleMenuShape: Shape = RoundedCornerShape(20.dp)
|
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
|
||||||
@@ -14,6 +14,7 @@ import androidx.compose.foundation.background
|
|||||||
import androidx.compose.foundation.clickable
|
import androidx.compose.foundation.clickable
|
||||||
import androidx.compose.foundation.gestures.awaitEachGesture
|
import androidx.compose.foundation.gestures.awaitEachGesture
|
||||||
import androidx.compose.foundation.gestures.awaitFirstDown
|
import androidx.compose.foundation.gestures.awaitFirstDown
|
||||||
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
import androidx.compose.foundation.layout.Box
|
import androidx.compose.foundation.layout.Box
|
||||||
import androidx.compose.foundation.layout.Column
|
import androidx.compose.foundation.layout.Column
|
||||||
import androidx.compose.foundation.layout.ExperimentalLayoutApi
|
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.shape.CircleShape
|
||||||
import androidx.compose.foundation.text.selection.rememberSelectionState
|
import androidx.compose.foundation.text.selection.rememberSelectionState
|
||||||
import androidx.compose.material3.AlertDialog
|
import androidx.compose.material3.AlertDialog
|
||||||
import androidx.compose.material3.Button
|
|
||||||
import androidx.compose.material3.Card
|
import androidx.compose.material3.Card
|
||||||
import androidx.compose.material3.CardDefaults
|
import androidx.compose.material3.CardDefaults
|
||||||
import androidx.compose.material3.CircularProgressIndicator
|
import androidx.compose.material3.CircularProgressIndicator
|
||||||
@@ -1436,6 +1436,33 @@ fun SessionScreen(
|
|||||||
var usageOpen by remember { mutableStateOf(false) }
|
var usageOpen by remember { mutableStateOf(false) }
|
||||||
var settingsOpen 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
|
// 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
|
// 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-
|
// everything in one column under a root imePadding, every frame of the keyboard animation re-
|
||||||
@@ -2012,6 +2039,7 @@ fun SessionScreen(
|
|||||||
)
|
)
|
||||||
Row(
|
Row(
|
||||||
verticalAlignment = Alignment.CenterVertically,
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
horizontalArrangement = Arrangement.spacedBy(4.dp),
|
||||||
modifier = Modifier.fillMaxWidth(),
|
modifier = Modifier.fillMaxWidth(),
|
||||||
) {
|
) {
|
||||||
// Photo or file, asked here rather than by two buttons: the row is full,
|
// Photo or file, asked here rather than by two buttons: the row is full,
|
||||||
@@ -2021,7 +2049,7 @@ fun SessionScreen(
|
|||||||
Box {
|
Box {
|
||||||
// Just "+". The count it used to carry was standing in for showing
|
// Just "+". The count it used to carry was standing in for showing
|
||||||
// them.
|
// them.
|
||||||
BubbleButton(onClick = { attaching = true }) { Text("+") }
|
CircleButton(onClick = { attaching = true }) { Text("+") }
|
||||||
DropdownMenu(
|
DropdownMenu(
|
||||||
expanded = attaching,
|
expanded = attaching,
|
||||||
onDismissRequest = { attaching = false },
|
onDismissRequest = { attaching = false },
|
||||||
@@ -2055,12 +2083,19 @@ fun SessionScreen(
|
|||||||
// past the edge, so with these laid out first the arrival of Stop pushed
|
// 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
|
// Send off the screen entirely -- the app's central control, gone at the
|
||||||
// moment it is most in use.
|
// 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(
|
Row(
|
||||||
verticalAlignment = Alignment.CenterVertically,
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
horizontalArrangement = Arrangement.spacedBy(4.dp),
|
||||||
modifier = Modifier.weight(1f),
|
modifier = Modifier.weight(1f),
|
||||||
) {
|
) {
|
||||||
if (offeredModels.isNotEmpty()) {
|
if (offeredModels.isNotEmpty()) {
|
||||||
PickerButton(
|
PickerButton(
|
||||||
|
modifier = Modifier.weight(1f),
|
||||||
current = label(model),
|
current = label(model),
|
||||||
// What the machine offers, plus the state a session is in when
|
// What the machine offers, plus the state a session is in when
|
||||||
// it has chosen none of them. The button has always been able
|
// 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,
|
// all on a provider whose model is fixed. Asked about first,
|
||||||
// unless there is nothing to lose by it -- see
|
// unless there is nothing to lose by it -- see
|
||||||
// [ModelSwitchWarning].
|
// [ModelSwitchWarning].
|
||||||
onPick = { picked ->
|
onPick = ::chooseModel,
|
||||||
// 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
|
|
||||||
}
|
|
||||||
},
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
if (offeredPermissionModes.isNotEmpty()) {
|
if (offeredPermissionModes.isNotEmpty()) {
|
||||||
PickerButton(
|
PickerButton(
|
||||||
|
modifier = Modifier.weight(1f),
|
||||||
current = permissionMode,
|
current = permissionMode,
|
||||||
options = offeredPermissionModes,
|
options = offeredPermissionModes,
|
||||||
onPick = { chosen ->
|
onPick = ::choosePermissionMode,
|
||||||
act {
|
|
||||||
setSessionPermissionMode(
|
|
||||||
settings,
|
|
||||||
summary.id,
|
|
||||||
chosen,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
},
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -2124,7 +2138,7 @@ fun SessionScreen(
|
|||||||
status == "exited" -> ProcessAction.Start
|
status == "exited" -> ProcessAction.Start
|
||||||
else -> ProcessAction.Stop
|
else -> ProcessAction.Stop
|
||||||
}
|
}
|
||||||
Button(
|
CircleButton(
|
||||||
onClick = {
|
onClick = {
|
||||||
processInFlight = true
|
processInFlight = true
|
||||||
act(onDone = { processInFlight = false }) {
|
act(onDone = { processInFlight = false }) {
|
||||||
@@ -2132,7 +2146,7 @@ fun SessionScreen(
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
enabled = !processInFlight,
|
enabled = !processInFlight,
|
||||||
colors = actionButtonColors(process.colour()),
|
fill = process.colour(),
|
||||||
) {
|
) {
|
||||||
Glyph(
|
Glyph(
|
||||||
process.glyph,
|
process.glyph,
|
||||||
@@ -2141,7 +2155,6 @@ fun SessionScreen(
|
|||||||
Modifier.semantics { contentDescription = process.label },
|
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
|
// 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
|
// 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
|
// 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
|
// `send` has always returned early on an empty composer, so the button
|
||||||
// promised something it would not do. Disabled and not hidden, for the
|
// promised something it would not do. Disabled and not hidden, for the
|
||||||
// reason above.
|
// reason above.
|
||||||
Button(
|
CircleButton(
|
||||||
onClick = { send() },
|
onClick = { send() },
|
||||||
enabled = input.text.isNotBlank() || pendingAttachments.isNotEmpty(),
|
enabled = input.text.isNotBlank() || pendingAttachments.isNotEmpty(),
|
||||||
colors = actionButtonColors(if (running) queueColor else sendColor),
|
fill = if (running) queueColor else sendColor,
|
||||||
) {
|
) {
|
||||||
Glyph(
|
Glyph(
|
||||||
if (running) QUEUE_GLYPH else SEND_GLYPH,
|
if (running) QUEUE_GLYPH else SEND_GLYPH,
|
||||||
@@ -2233,6 +2246,32 @@ fun SessionScreen(
|
|||||||
title = title,
|
title = title,
|
||||||
effort = effort.takeIf { summary.takesEffort },
|
effort = effort.takeIf { summary.takesEffort },
|
||||||
takesEffort = 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 },
|
onEffortChanged = { effort = it },
|
||||||
paramSpecs = paramSpecs,
|
paramSpecs = paramSpecs,
|
||||||
params = params,
|
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.
|
* session is set to without spending a second line on saying it.
|
||||||
*/
|
*/
|
||||||
@Composable
|
@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) }
|
var open by remember { mutableStateOf(false) }
|
||||||
// When an outside touch last closed the menu.
|
// 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
|
// 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.
|
// click would reopen what the same finger had just closed.
|
||||||
var closedAt by remember { mutableLongStateOf(0L) }
|
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(
|
BubbleButton(
|
||||||
onClick = { if (SystemClock.uptimeMillis() - closedAt > ONE_TAP_MS) open = true }
|
onClick = { if (SystemClock.uptimeMillis() - closedAt > ONE_TAP_MS) open = true }
|
||||||
) {
|
) {
|
||||||
|
|||||||
@@ -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
|
* 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.
|
* 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
|
* The model and the permission mode are on the session's own bar as well, because those are changed
|
||||||
* are changed *while* reading a turn -- "not this model, try that one".
|
* *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
|
* 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
|
* made the dialog longer than the conversation it covers -- so Notifications has none, while Move
|
||||||
@@ -74,6 +76,14 @@ fun SessionSettingsDialog(
|
|||||||
onEffortChanged: (String?) -> Unit,
|
onEffortChanged: (String?) -> Unit,
|
||||||
/** Whether a level does anything here; the row is left out entirely where it does not. */
|
/** Whether a level does anything here; the row is left out entirely where it does not. */
|
||||||
takesEffort: Boolean,
|
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<SessionChoice>,
|
||||||
/**
|
/**
|
||||||
* The settings this session's provider takes, and what they are set to.
|
* The settings this session's provider takes, and what they are set to.
|
||||||
*
|
*
|
||||||
@@ -422,6 +432,20 @@ fun SessionSettingsDialog(
|
|||||||
style = MaterialTheme.typography.bodySmall,
|
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
|
// 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
|
// 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.
|
// -- 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<String>,
|
||||||
|
val onPick: (String) -> Unit,
|
||||||
|
)
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* When the server will next look, as a local time.
|
* When the server will next look, as a local time.
|
||||||
*
|
*
|
||||||
|
|||||||
Reference in new issue
Block a user