Say it in icons, and put the whole backend behind four tabs

Six things Bryan asked for, which turned out to be one change: the app had
no icon set, so every one of them was blocked on having somewhere for icons
to come from.

That somewhere is dev-updater's arrangement, ported: a Nerd Fonts subset
committed as an asset, drawn as text. `Gear.kt`'s hand-drawn canvas gear
argued against icon fonts because a system font may not have the glyph and
whoever gets the empty box is never the person who wrote it. The objection
is right about *relying* on a system font and the answer is to ship the
glyph, so the file is gone and its reasoning is restated in `NerdIcons.kt`
rather than deleted -- otherwise the next reader re-derives it. `md-cog` and
`md-refresh` are dev-updater's own codepoints, because a cog means the same
thing in both apps.

The root screen's four words under the title are now four tabs, and the two
that act on the whole screen -- settings and refresh -- moved up onto the
title row as glyphs. That row's old comment recorded that a fifth word would
have had nowhere to go; tabs also say something the words did not, which is
that sessions, import, models and setups are four views of one backend
rather than four errands. Refresh feeds whichever tab is showing. Import,
models and setups lose their headings and their Back buttons, since the tab
row is now both.

Usage is a dialog. It is checked *against* what you were reading -- "can I
start this" is asked with the transcript still on screen -- and it had no
navigation of its own, so the only thing its Back could mean was "put this
away". The button that opens it is a chart glyph coloured by the worst of
the machine's windows, so the row says whether the limits are worth opening
before anybody opens them.

One `quotaColor` now colours every bar that measures a quota: blue, yellow
at 75%, red at 95%. The session bar escalates where it used to sit blue at
every level, and the dialog's thresholds moved out of it. A download keeps
plain blue at every value -- it has no limit to approach, and colouring it
like one would say the opposite of what is happening. States that are not
measurements take the ordinary control colour, since blue is the low end of
this scale and would read as "checked, and fine" about a machine nobody
could reach.

Send and stop are the filled paper plane and the filled square. Send keeps
the word "Queue" while a turn is in flight, because that is what pressing it
then does, and an icon that does two things while looking identical would
promise something immediate and do something that waits.

Looked at on the emulator: all six glyphs render, the tabs and the system
back gesture between them, the dialog over a live session, and the bar
bands at 82% and 97% forced through a scratch build, since this account is
at 72/31/5 and would only ever have shown blue.
This commit is contained in:
iris committed 2026-08-29 22:27:54 -04:00
1 parent ba71c798f5
commit ff39ef5cf9
17 files changed
+627 -310

No files matched your search

@@ -27,8 +27,8 @@ import androidx.compose.material3.CardDefaults
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.DropdownMenu
import androidx.compose.material3.DropdownMenuItem
import androidx.compose.material3.IconButton
import androidx.compose.material3.LinearProgressIndicator
import androidx.compose.material3.LocalContentColor
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedButton
import androidx.compose.material3.OutlinedTextField
@@ -313,7 +313,6 @@ fun SessionScreen(
settings: ServerSettings,
summary: SessionSummary,
onBack: () -> Unit,
onUsage: () -> Unit,
onSettings: () -> Unit,
) {
val scope = rememberCoroutineScope()
@@ -730,12 +729,18 @@ fun SessionScreen(
}
}
// One poll for this machine's limits, read by the two things that show them: the bar under
// the header, and the colour of the button that opens the dialog.
val usage = rememberSessionUsage(settings, summary.setup)
var usageOpen by remember { mutableStateOf(false) }
Column(Modifier.fillMaxSize()) {
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.fillMaxWidth().padding(horizontal = 8.dp, vertical = 4.dp),
) {
TextButton(onClick = onBack) { Text("Back") }
GlyphButton(BACK_GLYPH, "Back", onBack)
Spacer(Modifier.width(8.dp))
Column(Modifier.weight(1f)) {
Text(title, style = MaterialTheme.typography.titleMedium)
Text(
@@ -759,22 +764,29 @@ fun SessionScreen(
// the paid service's own numbers, so a session on a provider with no such service
// gets an honest "unavailable" rather than a hidden button -- a control that comes
// and goes makes its absence the signal, and absence cannot say why.
TextButton(onClick = onUsage) { Text("Usage") }
// A step down from this session, so it sits at the end of the session's own row.
// The name is the whole of what it holds today, which is why it is a gear and not a
// word: there will be more, and a bar of words has nowhere to put it.
IconButton(
onClick = onSettings,
modifier = Modifier.semantics { contentDescription = "Session settings" },
) {
Gear()
// Coloured by the worst window behind it, so the row says whether the limits are
// worth opening before anybody opens them. Blue at every ordinary level and only
// yellow or red near a limit -- and the theme's plain control colour whenever there
// is no measurement, since blue is the low end of the scale here and would read as
// "checked, and fine" about a machine nobody could reach.
Row(horizontalArrangement = Arrangement.spacedBy(GLYPH_BUTTON_GAP)) {
GlyphButton(
USAGE_GLYPH,
"Usage",
{ usageOpen = true },
colour = usageGlyphColour(usage),
)
// A step down from this session, so it sits at the end of the session's own row.
// The name is the whole of what it holds today, which is why it is a cog and not
// a word: there will be more, and a bar of words has nowhere to put it.
GlyphButton(SETTINGS_GLYPH, "Session settings", onSettings)
}
}
// Under the header, above everything the session itself says: it is a fact about the
// machine rather than a turn in the conversation, and it is the number that decides
// whether to keep going -- which was a screen away from where that gets decided.
SessionUsageBar(settings = settings, setup = summary.setup)
SessionUsageBar(usage)
(streamError ?: actionError)?.let { message ->
Text(
@@ -1045,21 +1057,46 @@ fun SessionScreen(
}
if (running) {
OutlinedButton(onClick = { act { interruptSession(settings, summary.id) } }) {
Text("Stop")
// A filled square, which is what stop has looked like since tape decks.
// Outlined beside the filled Send, so the pair still reads as one primary
// action and one secondary -- the glyphs changed, the weighting did not.
Glyph(
STOP_GLYPH,
colour = LocalContentColor.current,
modifier = Modifier.semantics { contentDescription = "Stop" },
)
}
Spacer(Modifier.width(8.dp))
}
// "Queue" while a turn is in flight, because that is what
// sending then does: the message is injected at the next
// tool boundary rather than starting a turn of its own.
// Naming it Send there would promise something immediate
// and describe something that waits.
Button(onClick = { send() }) { Text(if (running) "Queue" else "Send") }
// The paper plane alone when it means send. While a turn is in flight it keeps
// the word "Queue" beside it, because that is what sending then does -- the
// message is injected at the next tool boundary rather than starting a turn of
// its own -- and an icon that does two things while looking identical would
// promise something immediate and do something that waits. The word is also the
// button's accessible name, which is all a screen reader gets either way.
Button(onClick = { send() }) {
Glyph(
SEND_GLYPH,
colour = LocalContentColor.current,
modifier = Modifier.semantics { contentDescription = sendLabel(running) },
)
if (running) {
Spacer(Modifier.width(8.dp))
Text(sendLabel(running))
}
}
}
}
}
if (usageOpen) {
UsageDialog(settings = settings, onDismiss = { usageOpen = false })
}
}
/** What pressing Send does right now, said the same way to the eye and to a screen reader. */
private fun sendLabel(running: Boolean) = if (running) "Queue" else "Send"
/**
* An inline transcript image, fetched (authenticated, pinned) from the session's files route. The
* bitmap is remembered per ref, so scrolling doesn't refetch.