diff --git a/AGENTS.md b/AGENTS.md index a45b4bd..cdd7e0f 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -73,9 +73,18 @@ repo is in PLAN.md's "Backend layout" section. `EchoDriver`, and transcripts. - `app/` — Compose Android app, single `:androidApp` module, package `com.example.aiapp`, label "AI Sessions". `AppRoot.kt` is the navigation - `when`; `Api.kt`/`EventStream.kt` the REST + SSE clients; `Events.kt` the - event model mirror; `ServerConfig.kt` settings + Keystore-sealed token; - screens in `SessionListScreen/SessionScreen/SpawnScreen/SettingsScreen`. + `when`; `MainScreen.kt` the root's four tabs (sessions, import, models, + setups) with settings and refresh on the title row; `Api.kt`/`EventStream.kt` + the REST + SSE clients; `Events.kt` the event model mirror; + `ServerConfig.kt` settings + Keystore-sealed token; screens in + `SessionListScreen/SessionScreen/SpawnScreen/SettingsScreen`. + **Icons are Nerd Fonts glyphs from a committed subset**, not vector assets + and not ordinary Unicode — `NerdIcons.kt` declares each codepoint and + `app/build-icon-font.sh` subsets the font. The two lists have to agree: a + codepoint in the Kotlin that the script did not subset is a glyph that + silently isn't there. Rerun the script and commit its output when adding + one; it needs network access. `md-cog` and `md-refresh` are deliberately + the same codepoints dev-updater uses and must not drift from it. - `.dev-updater.ron` — what Dev Updater is asked to do with this checkout: the server (built in `server/`, run as `service: Managed(...)`) and the APK (built in `app/`), built in parallel. The project it serves is the diff --git a/app/androidApp/src/main/kotlin/com/example/aiapp/AppRoot.kt b/app/androidApp/src/main/kotlin/com/example/aiapp/AppRoot.kt index 7baa05d..a4c08a8 100644 --- a/app/androidApp/src/main/kotlin/com/example/aiapp/AppRoot.kt +++ b/app/androidApp/src/main/kotlin/com/example/aiapp/AppRoot.kt @@ -16,37 +16,27 @@ import androidx.compose.ui.unit.dp import com.example.wgapplink.localNetworkAllowed /** - * One `when` rather than a navigation library: four screens, with the list as the root and the back - * button the only other way between them. + * One `when` rather than a navigation library: a handful of screens, with [Screen.Main] as the root + * and the back button the only other way between them. + * + * Import, models and setups are not here any more. They are tabs inside [MainScreen] -- four views + * of the same backend, none of them a step down from another -- and what is left in this `when` is + * only what genuinely is a step down: one session, spawning one, and settings. */ private sealed class Screen { - data object SessionList : Screen() + data object Main : Screen() data class Session(val summary: SessionSummary) : Screen() data object Spawn : Screen() - /** Continuing a session the machine already had, rather than starting an empty one. */ - data object Import : Screen() - /** - * Reached from a session rather than from the list, because usage belongs to the provider - * running that session and not to the app. It carries the session back with it so Back returns - * where it came from -- see [Screen.Session]. - */ - data class Usage(val from: SessionSummary) : Screen() - - /** - * What can be changed about one session. Carries the session back with it for the same reason - * [Screen.Usage] does, and carries it *out* renamed, so the session behind it shows the new - * name without waiting for a list refresh. + * What can be changed about one session. Carries the session back with it so Back returns where + * it came from, and carries it *out* renamed, so the session behind it shows the new name + * without waiting for a list refresh. */ data class SessionSettings(val from: SessionSummary) : Screen() - data object Models : Screen() - - data object Setups : Screen() - data object Settings : Screen() } @@ -58,7 +48,7 @@ private sealed class Screen { fun AppRoot(settingsVersion: Int) { val context = LocalContext.current var settings by remember(settingsVersion) { mutableStateOf(loadServerSettings(context)) } - var screen by remember { mutableStateOf(Screen.SessionList) } + var screen by remember { mutableStateOf(Screen.Main) } // Bumped whenever another screen changes something the list shows, so // returning to it refetches instead of showing a stale list. var reloadToken by remember { mutableIntStateOf(0) } @@ -86,7 +76,7 @@ fun AppRoot(settingsVersion: Int) { existing = null, onSaved = { saved -> settings = saved - screen = Screen.SessionList + screen = Screen.Main }, onBack = null, ) @@ -97,32 +87,32 @@ fun AppRoot(settingsVersion: Int) { // reached by the system back gesture or a screen's own Back button. // Every leaf screen can have changed something the list shows, so it // always refetches. - val goToList = { + val goToMain = { reloadToken++ - screen = Screen.SessionList + screen = Screen.Main } - if (screen !is Screen.SessionList) { - BackHandler(onBack = goToList) + if (screen !is Screen.Main) { + BackHandler(onBack = goToMain) } when (val here = screen) { - is Screen.SessionList -> - SessionListScreen( + is Screen.Main -> + MainScreen( settings = current, reloadToken = reloadToken, onOpen = { screen = Screen.Session(it) }, onSpawn = { screen = Screen.Spawn }, - onImport = { screen = Screen.Import }, - onModels = { screen = Screen.Models }, - onSetups = { screen = Screen.Setups }, + onImported = { imported -> + reloadToken++ + screen = Screen.Session(imported) + }, onSettings = { screen = Screen.Settings }, ) is Screen.Session -> SessionScreen( settings = current, summary = here.summary, - onBack = goToList, - onUsage = { screen = Screen.Usage(here.summary) }, + onBack = goToMain, onSettings = { screen = Screen.SessionSettings(here.summary) }, ) is Screen.Spawn -> @@ -132,23 +122,7 @@ fun AppRoot(settingsVersion: Int) { reloadToken++ screen = Screen.Session(spawned) }, - onBack = goToList, - ) - is Screen.Import -> - ImportScreen( - settings = current, - onImported = { imported -> - reloadToken++ - screen = Screen.Session(imported) - }, - onBack = goToList, - ) - is Screen.Usage -> - UsageScreen( - settings = current, - // Back to the session it was opened from, not to the list: this is a step down - // from that session, so stepping back is the one thing Back can mean here. - onBack = { screen = Screen.Session(here.from) }, + onBack = goToMain, ) is Screen.SessionSettings -> SessionSettingsScreen( @@ -162,16 +136,14 @@ fun AppRoot(settingsVersion: Int) { }, onBack = { screen = Screen.Session(here.from) }, ) - is Screen.Models -> ModelsScreen(settings = current, onBack = goToList) - is Screen.Setups -> SetupsScreen(settings = current, onBack = goToList) is Screen.Settings -> SettingsScreen( existing = current, onSaved = { saved -> settings = saved - goToList() + goToMain() }, - onBack = goToList, + onBack = goToMain, ) } } diff --git a/app/androidApp/src/main/kotlin/com/example/aiapp/Gear.kt b/app/androidApp/src/main/kotlin/com/example/aiapp/Gear.kt deleted file mode 100644 index 39a7b23..0000000 --- a/app/androidApp/src/main/kotlin/com/example/aiapp/Gear.kt +++ /dev/null @@ -1,59 +0,0 @@ -package com.example.aiapp - -import androidx.compose.foundation.Canvas -import androidx.compose.foundation.layout.size -import androidx.compose.material3.MaterialTheme -import androidx.compose.runtime.Composable -import androidx.compose.ui.Modifier -import androidx.compose.ui.geometry.Offset -import androidx.compose.ui.graphics.Color -import androidx.compose.ui.graphics.StrokeCap -import androidx.compose.ui.graphics.drawscope.Stroke -import androidx.compose.ui.unit.dp -import kotlin.math.PI -import kotlin.math.cos -import kotlin.math.sin - -/** - * A gear: settings for the thing it sits beside. - * - * Drawn rather than set in a font, for the reason [Chevron] gives -- an icon glyph is one a system - * font may not have, and whoever gets the empty box instead is never the person who wrote it. The - * app has no icon set otherwise, and one dependency for one gear is a poor trade. - * - * A thick ring with eight blunt teeth cut into its edge. The proportions are the whole of whether - * this reads as a gear: drawn first with thin teeth standing clear of a thin hub, it was a sun -- - * unmistakably, and only once it was looked at on a screen. What separates the two shapes is that a - * gear's teeth are as heavy as its body and barely longer than they are wide, and that its centre - * is a hole rather than a dot. - * - * It draws no label, so every caller owes it a `contentDescription`: that is all assistive - * technology has, and it is also the answer to "what was that button for" six months from now. - */ -@Composable -fun Gear(modifier: Modifier = Modifier, colour: Color = MaterialTheme.colorScheme.primary) { - Canvas(modifier.size(20.dp)) { - val centre = Offset(size.width / 2, size.height / 2) - val tooth = 4.dp.toPx() - // The teeth end at the edge, so the body has to leave room for half a tooth's width - // where they meet the ring -- otherwise the widest part of the drawing is clipped. - val tip = size.minDimension / 2 - val body = tip - tooth * 0.62f - drawCircle(colour, radius = body, centre, style = Stroke(3.dp.toPx())) - repeat(TEETH) { index -> - val angle = 2 * PI * index / TEETH - val direction = Offset(cos(angle).toFloat(), sin(angle).toFloat()) - drawLine( - colour, - centre + direction * (body - 1.dp.toPx()), - centre + direction * tip, - strokeWidth = tooth, - // Square-ended, because a rounded tooth on a shape this small rounds away - // most of the tooth. - cap = StrokeCap.Butt, - ) - } - } -} - -private const val TEETH = 8 diff --git a/app/androidApp/src/main/kotlin/com/example/aiapp/ImportScreen.kt b/app/androidApp/src/main/kotlin/com/example/aiapp/ImportScreen.kt index 683ce5c..839c4bd 100644 --- a/app/androidApp/src/main/kotlin/com/example/aiapp/ImportScreen.kt +++ b/app/androidApp/src/main/kotlin/com/example/aiapp/ImportScreen.kt @@ -39,11 +39,7 @@ import kotlinx.coroutines.withContext * screen into a file reader. */ @Composable -fun ImportScreen( - settings: ServerSettings, - onImported: (SessionSummary) -> Unit, - onBack: () -> Unit, -) { +fun ImportScreen(settings: ServerSettings, reloadToken: Int, onImported: (SessionSummary) -> Unit) { val scope = rememberCoroutineScope() var setups by remember { mutableStateOf>>(LoadState.Loading) } var chosen by remember { mutableStateOf(null) } @@ -73,7 +69,7 @@ fun ImportScreen( } } - LaunchedEffect(Unit) { + LaunchedEffect(reloadToken) { setups = try { val found = withContext(Dispatchers.IO) { fetchSetups(settings) } @@ -88,14 +84,8 @@ fun ImportScreen( } Column(Modifier.fillMaxSize().padding(16.dp)) { - Row(verticalAlignment = Alignment.CenterVertically, modifier = Modifier.fillMaxWidth()) { - Text( - "Import a session", - style = MaterialTheme.typography.headlineSmall, - modifier = Modifier.weight(1f), - ) - TextButton(onClick = onBack) { Text("Back") } - } + // No heading: the tab that selected this one already says "Import". The sentence below + // stays, because it says what importing *does*, which the tab label cannot. Text( "Sessions Claude Code already has on the machine. Importing continues one where it " + "left off; the transcript here shows its recent history.", diff --git a/app/androidApp/src/main/kotlin/com/example/aiapp/MainScreen.kt b/app/androidApp/src/main/kotlin/com/example/aiapp/MainScreen.kt new file mode 100644 index 0000000..76e0954 --- /dev/null +++ b/app/androidApp/src/main/kotlin/com/example/aiapp/MainScreen.kt @@ -0,0 +1,110 @@ +package com.example.aiapp + +import androidx.activity.compose.BackHandler +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.PrimaryTabRow +import androidx.compose.material3.Tab +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableIntStateOf +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.dp + +/** + * The app's root: one title, and four views of the backend behind it. + * + * These were four screens reached by four words in a row under the title, and the row was already + * full -- the comment it replaced recorded that a fifth would have to go somewhere else. Tabs say + * the same thing in less space and say one more thing besides: that these are places to be rather + * than errands to run. Sessions, the machine's importable history, the models on it and the + * machines themselves are all *the same backend*, looked at four ways, and none of them is a step + * down from another. Settings still is a step down, which is why it stays a pushed screen and keeps + * its own Back. + */ +private enum class MainTab(val label: String) { + Sessions("Sessions"), + Import("Import"), + Models("Models"), + Setups("Setups"), +} + +@Composable +fun MainScreen( + settings: ServerSettings, + reloadToken: Int, + onOpen: (SessionSummary) -> Unit, + onSpawn: () -> Unit, + onImported: (SessionSummary) -> Unit, + onSettings: () -> Unit, +) { + var tab by remember { mutableStateOf(MainTab.Sessions) } + var refreshToken by remember { mutableIntStateOf(0) } + + // A tab the app put over the list has to step back to it rather than fall through to the + // system default, which closes the app -- that reads as a crash to somebody who only meant to + // get back to their sessions. Nested inside AppRoot's handler, so it wins while it is enabled. + BackHandler(enabled = tab != MainTab.Sessions) { tab = MainTab.Sessions } + + Column(Modifier.fillMaxSize()) { + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier.fillMaxWidth().padding(start = 16.dp, end = 16.dp, top = 16.dp), + ) { + Text( + "AI Sessions", + style = MaterialTheme.typography.headlineSmall, + modifier = Modifier.weight(1f), + ) + // Glyphs rather than the words they replaced: neither ever changes, both are read + // faster than they are spelled, and together they take the width that let the title + // keep its own line. They sit on the title's row because they act on the whole + // screen -- everything below this row is one tab's business, and a control belongs + // with the thing it acts on. + Row(horizontalArrangement = Arrangement.spacedBy(GLYPH_BUTTON_GAP)) { + GlyphButton(REFRESH_GLYPH, "Refresh", { refreshToken++ }) + GlyphButton(SETTINGS_GLYPH, "Settings", onSettings) + } + } + // Primary rather than the plain TabRow, which is deprecated in favour of the two that + // say where they sit: these are the app's top-level destinations. + PrimaryTabRow(selectedTabIndex = tab.ordinal) { + MainTab.entries.forEach { entry -> + Tab( + selected = tab == entry, + onClick = { tab = entry }, + text = { Text(entry.label) }, + ) + } + } + + // Refreshing means "ask again about what I am looking at", so the button feeds the tab + // that is showing. The token from above means something else already changed what these + // show; the two are the same instruction to the tab below, so they are summed rather than + // tracked apart -- either one moving moves the sum, which is all a tab watches. + val token = reloadToken + refreshToken + when (tab) { + MainTab.Sessions -> + SessionListScreen( + settings = settings, + reloadToken = token, + onOpen = onOpen, + onSpawn = onSpawn, + ) + MainTab.Import -> + ImportScreen(settings = settings, reloadToken = token, onImported = onImported) + MainTab.Models -> ModelsScreen(settings = settings, reloadToken = token) + MainTab.Setups -> SetupsScreen(settings = settings, reloadToken = token) + } + } +} diff --git a/app/androidApp/src/main/kotlin/com/example/aiapp/ModelsScreen.kt b/app/androidApp/src/main/kotlin/com/example/aiapp/ModelsScreen.kt index 263a09d..43e2816 100644 --- a/app/androidApp/src/main/kotlin/com/example/aiapp/ModelsScreen.kt +++ b/app/androidApp/src/main/kotlin/com/example/aiapp/ModelsScreen.kt @@ -40,7 +40,7 @@ import kotlinx.coroutines.withContext * going when this screen closes. */ @Composable -fun ModelsScreen(settings: ServerSettings, onBack: () -> Unit) { +fun ModelsScreen(settings: ServerSettings, reloadToken: Int) { val scope = rememberCoroutineScope() var state by remember { mutableStateOf>(LoadState.Loading) } var query by remember { mutableStateOf("") } @@ -61,7 +61,10 @@ fun ModelsScreen(settings: ServerSettings, onBack: () -> Unit) { // Polled rather than pushed: a download belongs to the machine, not to // any session, so it has no event stream of its own. Slow enough not // to matter, frequent enough that a bar moves. - LaunchedEffect(Unit) { + // Keyed on the token as well, so the header's Refresh restarts the loop with a read now + // rather than leaving the reader watching for up to a second and a half to see whether + // anything happened. + LaunchedEffect(reloadToken) { while (true) { reload() delay(1500) @@ -69,16 +72,6 @@ fun ModelsScreen(settings: ServerSettings, onBack: () -> Unit) { } Column(Modifier.fillMaxSize().padding(16.dp)) { - Row(verticalAlignment = Alignment.CenterVertically, modifier = Modifier.fillMaxWidth()) { - Text( - "Models", - style = MaterialTheme.typography.headlineSmall, - modifier = Modifier.weight(1f), - ) - TextButton(onClick = onBack) { Text("Back") } - } - Spacer(Modifier.height(8.dp)) - actionError?.let { Text(it, color = MaterialTheme.colorScheme.error) Spacer(Modifier.height(8.dp)) @@ -270,6 +263,10 @@ private fun DownloadCard(download: Download, onCancel: () -> Unit) { if (download.total != null && download.total > 0) { LinearProgressIndicator( progress = { download.done.toFloat() / download.total.toFloat() }, + // Blue at every value, unlike a quota bar: a download nearing its end is + // nearing success, and colouring it like a limit being approached would say + // the opposite of what is happening. + color = progressColor, modifier = Modifier.fillMaxWidth(), ) Text( @@ -277,7 +274,7 @@ private fun DownloadCard(download: Download, onCancel: () -> Unit) { style = MaterialTheme.typography.bodySmall, ) } else { - LinearProgressIndicator(Modifier.fillMaxWidth()) + LinearProgressIndicator(color = progressColor, modifier = Modifier.fillMaxWidth()) Text( "${gigabytes(download.done)} so far, total size unknown", style = MaterialTheme.typography.bodySmall, diff --git a/app/androidApp/src/main/kotlin/com/example/aiapp/NerdIcons.kt b/app/androidApp/src/main/kotlin/com/example/aiapp/NerdIcons.kt new file mode 100644 index 0000000..3c21f2d --- /dev/null +++ b/app/androidApp/src/main/kotlin/com/example/aiapp/NerdIcons.kt @@ -0,0 +1,134 @@ +package com.example.aiapp + +import androidx.compose.foundation.layout.size +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.semantics.contentDescription +import androidx.compose.ui.semantics.semantics +import androidx.compose.ui.text.font.Font +import androidx.compose.ui.text.font.FontFamily +import androidx.compose.ui.unit.TextUnit +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp + +/** + * The icons the app draws, as glyphs in a Nerd Fonts subset rather than as vector assets. + * + * Drawing them as *text* is what makes them cheap: an icon beside a line of text wants that line's + * size, colour and baseline, and a `Text` gets all three for free where an `Icon` needs each one + * set and kept in step by hand. + * + * This replaced a hand-drawn canvas gear, whose doc comment argued against icon fonts on the + * grounds that a system font may not have the glyph and whoever gets the empty box instead is never + * the person who wrote it. That objection is about *relying* on a system font, and it is exactly + * right: the answer is not to avoid glyphs but to ship them. The font here is + * `app/build-icon-font.sh`'s output -- five glyphs, 1.4 KB, subset out of the 3 MB symbols font and + * committed -- so the codepoints below are resolved by an asset in the APK and cannot come back as + * tofu. Adding one means adding its codepoint in *both* places; a codepoint here that the script + * did not subset is a glyph that silently isn't there. + * + * The same arrangement as dev-updater, down to the cog and the refresh arrow being the same two + * Material Design codepoints. Those two must not drift: an icon that means "settings" in one app + * and something else in the other is the failure this is worth preventing. The script is copied + * rather than shared because most of what looks like duplication is the `GLYPHS` list, which has + * to differ -- the point of subsetting is to ship only the codepoints one app draws. All Material Design + * bar one, so they read as one family; the exception is noted where it is declared. + */ +val NerdIcons = FontFamily(Font(R.font.nerd_icons)) + +/** Nerd Fonts puts these in plane 15, so each is a surrogate pair. */ +private fun glyph(codePoint: Int) = String(Character.toChars(codePoint)) + +/** `md-cog` -- settings for the thing it sits beside. */ +val SETTINGS_GLYPH = glyph(0xF0493) + +/** `md-refresh` -- ask the server again for whatever is on screen. */ +val REFRESH_GLYPH = glyph(0xF0450) + +/** `md-send` -- the filled paper plane: submit what is in the composer. */ +val SEND_GLYPH = glyph(0xF048A) + +/** `md-stop` -- a filled square: interrupt the turn that is running. */ +val STOP_GLYPH = glyph(0xF04DB) + +/** `md-arrow_left` -- back one level, to whatever this was opened from. */ +val BACK_GLYPH = glyph(0xF004D) + +/** + * `fa-line_chart` -- how much of the account's rate limits is gone. + * + * Font Awesome's rather than Material's, which is the one break in the family above: it was asked + * for by name, and Material's chart glyphs are a bare line where this one has its axes, which is + * what makes it read as a measurement rather than as a trend. + */ +val USAGE_GLYPH = glyph(0xF201) + +/** + * The size of a glyph button's box, which is the size of the glyph itself. + * + * Not the 48dp of a default `IconButton`: that box centres its drawing inside itself, so aligning + * the box against a title aligns nothing a reader can see, and the pressed-state ripple lands on + * the box rather than on the mark. A box that *is* the glyph aligns like any other content and + * takes its ripple with it. The cost is the touch target -- `minimumInteractiveComponentSize` is + * applied inside the caller's modifier, so a size set here wins over it. + */ +private val GLYPH_BUTTON_SIZE = 28.dp + +/** + * Restores the separation the 48dp boxes used to provide, now that the boxes are the size of what + * they draw: two of these plus the gap come to the same 48dp centre-to-centre spacing. + */ +val GLYPH_BUTTON_GAP = 48.dp - GLYPH_BUTTON_SIZE + +/** + * A glyph you can press: the icon equivalent of a `TextButton`. + * + * Its own composable so that every icon button in the app is one size and one colour without each + * caller saying so, and so the [label] none of them displays is still there for a screen reader -- + * which is all assistive technology has to go on, and also the answer to "what was that button for" + * six months from now. + * + * [enabled] is passed through rather than left to callers hiding the button: a control that comes + * and goes makes its own absence the signal, and absence cannot say whether there was nothing to do + * or nobody checked. + */ +@Composable +fun GlyphButton( + glyph: String, + label: String, + onClick: () -> Unit, + modifier: Modifier = Modifier, + enabled: Boolean = true, + colour: Color = MaterialTheme.colorScheme.primary, +) { + IconButton( + onClick = onClick, + enabled = enabled, + modifier = modifier.size(GLYPH_BUTTON_SIZE).semantics { contentDescription = label }, + ) { + Glyph(glyph, colour = if (enabled) colour else MaterialTheme.colorScheme.outline) + } +} + +/** + * One icon, drawn as text. + * + * Callers that are already inside something pressable use this; [GlyphButton] is the one that adds + * the press. Either way the caller owes it a description, since neither draws a word. + */ +@Composable +fun Glyph( + glyph: String, + modifier: Modifier = Modifier, + colour: Color = MaterialTheme.colorScheme.primary, + size: TextUnit = GLYPH_SIZE, +) { + Text(glyph, fontFamily = NerdIcons, fontSize = size, color = colour, modifier = modifier) +} + +/** The size an icon draws at beside a line of text. */ +private val GLYPH_SIZE = 20.sp diff --git a/app/androidApp/src/main/kotlin/com/example/aiapp/SessionListScreen.kt b/app/androidApp/src/main/kotlin/com/example/aiapp/SessionListScreen.kt index de72668..8b1c383 100644 --- a/app/androidApp/src/main/kotlin/com/example/aiapp/SessionListScreen.kt +++ b/app/androidApp/src/main/kotlin/com/example/aiapp/SessionListScreen.kt @@ -39,8 +39,11 @@ import kotlinx.coroutines.withContext // as in dev-updater. /** - * The session list -- the app's root screen. Sessions awaiting an answer sort to the top: that's - * the "your turn" inbox. + * The sessions tab: sessions awaiting an answer sort to the top, which is the "your turn" inbox. + * + * No title and no Back of its own -- [MainScreen] owns the header and the tab that names this one. + * What stays here is the button that adds a session, because that acts on this list and nothing + * else. */ @Composable fun SessionListScreen( @@ -48,10 +51,6 @@ fun SessionListScreen( reloadToken: Int, onOpen: (SessionSummary) -> Unit, onSpawn: () -> Unit, - onImport: () -> Unit, - onModels: () -> Unit, - onSetups: () -> Unit, - onSettings: () -> Unit, ) { val scope = rememberCoroutineScope() var listState by remember { mutableStateOf>>(LoadState.Loading) } @@ -92,25 +91,6 @@ fun SessionListScreen( Box(Modifier.fillMaxSize()) { Column(Modifier.fillMaxSize().padding(16.dp)) { - // Title and actions on separate rows. Sharing one row worked - // with three actions and broke with the fourth: the title took - // whatever was left and wrapped "AI Sessions" onto three - // lines. Giving the actions their own row means adding a fifth - // costs nothing, and the title is never the thing that gives. - Text("AI Sessions", style = MaterialTheme.typography.headlineSmall) - Row( - verticalAlignment = Alignment.CenterVertically, - modifier = Modifier.fillMaxWidth(), - ) { - TextButton(onClick = onImport) { Text("Import") } - TextButton(onClick = onModels) { Text("Models") } - TextButton(onClick = onSetups) { Text("Setups") } - TextButton(onClick = onSettings) { Text("Settings") } - Spacer(Modifier.weight(1f)) - TextButton(onClick = { refresh() }) { Text("Refresh") } - } - Spacer(Modifier.height(8.dp)) - when (val state = listState) { is LoadState.Loading -> CircularProgressIndicator() // The message as Api.kt wrote it, with nothing added: it is 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 0f5bda4..a4d0378 100644 --- a/app/androidApp/src/main/kotlin/com/example/aiapp/SessionScreen.kt +++ b/app/androidApp/src/main/kotlin/com/example/aiapp/SessionScreen.kt @@ -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 @@ -316,7 +316,6 @@ fun SessionScreen( settings: ServerSettings, summary: SessionSummary, onBack: () -> Unit, - onUsage: () -> Unit, onSettings: () -> Unit, ) { val scope = rememberCoroutineScope() @@ -737,12 +736,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( @@ -766,22 +771,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( @@ -1052,21 +1064,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. diff --git a/app/androidApp/src/main/kotlin/com/example/aiapp/SessionSettingsScreen.kt b/app/androidApp/src/main/kotlin/com/example/aiapp/SessionSettingsScreen.kt index 6117c18..20fe27d 100644 --- a/app/androidApp/src/main/kotlin/com/example/aiapp/SessionSettingsScreen.kt +++ b/app/androidApp/src/main/kotlin/com/example/aiapp/SessionSettingsScreen.kt @@ -7,6 +7,7 @@ import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.width import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.text.KeyboardActions import androidx.compose.foundation.text.KeyboardOptions @@ -15,7 +16,6 @@ import androidx.compose.material3.Button import androidx.compose.material3.MaterialTheme import androidx.compose.material3.OutlinedTextField import androidx.compose.material3.Text -import androidx.compose.material3.TextButton import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf @@ -77,12 +77,13 @@ fun SessionSettingsScreen( Column(Modifier.fillMaxSize().verticalScroll(rememberScrollState()).padding(16.dp)) { Row(verticalAlignment = Alignment.CenterVertically, modifier = Modifier.fillMaxWidth()) { + GlyphButton(BACK_GLYPH, "Back", onBack) + Spacer(Modifier.width(8.dp)) Text( "Session settings", style = MaterialTheme.typography.headlineSmall, modifier = Modifier.weight(1f), ) - TextButton(onClick = onBack) { Text("Back") } } Spacer(Modifier.height(16.dp)) diff --git a/app/androidApp/src/main/kotlin/com/example/aiapp/SessionUsageBar.kt b/app/androidApp/src/main/kotlin/com/example/aiapp/SessionUsageBar.kt index fe75331..b14e389 100644 --- a/app/androidApp/src/main/kotlin/com/example/aiapp/SessionUsageBar.kt +++ b/app/androidApp/src/main/kotlin/com/example/aiapp/SessionUsageBar.kt @@ -14,6 +14,7 @@ import androidx.compose.runtime.remember import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color import androidx.compose.ui.unit.dp import java.time.Duration import java.time.OffsetDateTime @@ -21,12 +22,13 @@ import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.delay import kotlinx.coroutines.withContext -/** How much of the five-hour window is gone, or why that isn't known. */ -sealed class FiveHourUsage { +/** What one machine's rate limits came back as, or why they didn't. */ +sealed class SessionUsage { /** Nothing has come back yet. Distinct from every answer, including an empty one. */ - data object Waiting : FiveHourUsage() + data object Waiting : SessionUsage() - data class Known(val percent: Double, val resetsAt: String?) : FiveHourUsage() + /** Every window the machine reported, in the order it reported them. */ + data class Known(val windows: List) : SessionUsage() /** * This machine meters nothing, so there is no window to show. @@ -37,7 +39,7 @@ sealed class FiveHourUsage { * snapshot for it -- and reading that silence as "couldn't find out" is exactly the mistake of * answering with the nearest available word. Drawn as nothing, because there is nothing. */ - data object NotMetered : FiveHourUsage() + data object NotMetered : SessionUsage() /** * The question could not be answered, and why. @@ -46,18 +48,66 @@ sealed class FiveHourUsage { * never share an appearance: a bar sitting at zero because a machine is unreachable reads as * plenty of headroom, which is the opposite of the truth. */ - data class Unavailable(val why: String) : FiveHourUsage() + data class Unavailable(val why: String) : SessionUsage() } /** How often to ask again. The backend caches, so this re-reads its cache rather than the API. */ private const val REFRESH_MS = 60_000L +/** + * One machine's rate limits, polled. + * + * Hoisted out of [SessionUsageBar] because two things on a session's screen show this same answer + * -- the bar, and the colour of the button that opens the usage dialog. Fetching it twice would + * cost two round trips to say one thing, and the two copies would disagree for up to a minute at a + * time, which is the interface contradicting itself about a number somebody is deciding on. + */ +@Composable +fun rememberSessionUsage(settings: ServerSettings, setup: String): SessionUsage { + var usage by remember(setup) { mutableStateOf(SessionUsage.Waiting) } + LaunchedEffect(setup) { + while (true) { + usage = + try { + usageFor(withContext(Dispatchers.IO) { fetchUsage(settings) }, setup) + } catch (e: ApiException) { + SessionUsage.Unavailable(e.message ?: "couldn't reach the backend") + } + delay(REFRESH_MS) + } + } + return usage +} + +/** + * The colour for a control that reports on [usage] as a whole: the worst window's. + * + * Worst rather than the five-hour one, because the button it colours opens *all* of them, and a + * blue icon over a weekly quota at 97% would be the interface answering a question nobody asked. + * Taken over however many windows came back rather than the three Claude sends today -- the backend + * deliberately passes windows it does not recognise straight through, so a fourth one is a thing + * that happens rather than a thing to notice later. + * + * Every state that is not a measurement takes the ordinary control colour instead. That is the + * point where colour stops being able to help: blue is the low end of a scale here, so colouring an + * unknown blue would say "measured, and fine" about a machine nobody could reach. The dialog behind + * the button is where those say, in words, which one they are. + */ +@Composable +fun usageGlyphColour(usage: SessionUsage): Color = + when (usage) { + is SessionUsage.Known -> + usage.windows.maxOfOrNull { it.percent }?.let { quotaColor(it) } + ?: MaterialTheme.colorScheme.primary + else -> MaterialTheme.colorScheme.primary + } + /** * The five-hour window for the machine this session runs on, under the session's own header. * - * Here rather than only on the usage screen because it is the number that decides whether to keep + * Here rather than only in the usage dialog because it is the number that decides whether to keep * going, and it was a screen away from the place that decision gets made. It reports on this - * session's machine alone -- the usage screen is still where every machine is compared. + * session's machine alone -- the dialog is still where every machine is compared. * * What it shows is the paid service's own metering, fetched from the machine that holds the * account. It is never derived from what this app has watched go past: the transcript's token @@ -65,31 +115,22 @@ private const val REFRESH_MS = 60_000L * out of them would be a guess wearing a measurement's clothes. */ @Composable -fun SessionUsageBar(settings: ServerSettings, setup: String, modifier: Modifier = Modifier) { - var usage by remember(setup) { mutableStateOf(FiveHourUsage.Waiting) } - // The countdown moves even when the numbers do not, so it is driven by a clock this loop - // advances rather than recomputed at draw time: a percentage that comes back unchanged is an - // equal value, Compose skips the recomposition, and a "left" that only ticks when the quota - // happens to move would sit at a stale figure for hours. +fun SessionUsageBar(usage: SessionUsage, modifier: Modifier = Modifier) { + // The countdown moves even when the numbers do not, so it is driven by a clock of its own + // rather than recomputed at draw time: a percentage that comes back unchanged is an equal + // value, Compose skips the recomposition, and a "left" that only ticked when the quota + // happened to move would sit at a stale figure for hours. var now by remember { mutableStateOf(OffsetDateTime.now()) } - - LaunchedEffect(setup) { + LaunchedEffect(Unit) { while (true) { - usage = - try { - val snapshots = withContext(Dispatchers.IO) { fetchUsage(settings) } - fiveHourFor(snapshots, setup) - } catch (e: ApiException) { - FiveHourUsage.Unavailable(e.message ?: "couldn't reach the backend") - } - now = OffsetDateTime.now() delay(REFRESH_MS) + now = OffsetDateTime.now() } } // Nothing at all for a machine that meters nothing: a row saying "unknown" there would // report a problem about a setup somebody chose, on every screen, forever. - if (usage is FiveHourUsage.NotMetered) { + if (usage is SessionUsage.NotMetered) { return } @@ -97,39 +138,47 @@ fun SessionUsageBar(settings: ServerSettings, setup: String, modifier: Modifier verticalAlignment = Alignment.CenterVertically, modifier = modifier.fillMaxWidth().padding(horizontal = 16.dp, vertical = 2.dp), ) { + // Words, not a colour and not an empty bar: every one of these is a different kind of + // answer from "this much is used", and only words carry a difference in kind. when (val state = usage) { - FiveHourUsage.NotMetered -> Unit - // Words, not a colour and not an empty bar: "couldn't check" is a different kind of - // answer from "this much is used", and only words carry a difference in kind. - is FiveHourUsage.Unavailable -> - Text( - "5-hour usage unknown -- ${state.why}", - style = MaterialTheme.typography.labelSmall, - color = MaterialTheme.colorScheme.onSurfaceVariant, - ) - FiveHourUsage.Waiting -> - Text( - "5-hour usage: checking", - style = MaterialTheme.typography.labelSmall, - color = MaterialTheme.colorScheme.onSurfaceVariant, - ) - is FiveHourUsage.Known -> { - LinearProgressIndicator( - progress = { (state.percent / 100.0).toFloat().coerceIn(0f, 1f) }, - color = usageColor, - modifier = Modifier.weight(1f), - ) - Text( - fiveHourLabel(state, now), - style = MaterialTheme.typography.labelSmall, - color = MaterialTheme.colorScheme.onSurfaceVariant, - modifier = Modifier.padding(start = 8.dp), - ) + SessionUsage.NotMetered -> Unit + is SessionUsage.Unavailable -> UsageNote("5-hour usage unknown -- ${state.why}") + SessionUsage.Waiting -> UsageNote("5-hour usage: checking") + is SessionUsage.Known -> { + val window = state.windows.firstOrNull { it.kind == "session" } + if (window == null) { + UsageNote("5-hour usage unknown -- no five-hour window reported") + } else { + LinearProgressIndicator( + progress = { (window.percent / 100.0).toFloat().coerceIn(0f, 1f) }, + // The same step at the same percentages as the dialog's bars: this is the + // same measurement, and a reader who learned the colour there has to be + // able to read it here without checking which screen they are on. + color = quotaColor(window.percent), + modifier = Modifier.weight(1f), + ) + Text( + fiveHourLabel(window, now), + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.padding(start = 8.dp), + ) + } } } } } +/** Anything this row says instead of drawing a bar, so all of them look the same. */ +@Composable +private fun UsageNote(text: String) { + Text( + text, + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) +} + /** * "42% -- 2h 15m left": how much is gone, then how long what is left has to last. * @@ -140,43 +189,32 @@ fun SessionUsageBar(settings: ServerSettings, setup: String, modifier: Modifier * no reset time, and saying "refresh soon" there would put a recommendation on the screen that * nothing measured. The percentage is still known, so it is still shown. */ -private fun fiveHourLabel(state: FiveHourUsage.Known, now: OffsetDateTime): String { - val percent = "${state.percent.toInt()}%" - val until = state.resetsAt?.let { remainingUntil(it, now) } +private fun fiveHourLabel(window: UsageWindow, now: OffsetDateTime): String { + val percent = "${window.percent.toInt()}%" + val until = window.resetsAt?.let { remainingUntil(it, now) } return when { - until == null -> "$percent \u00b7 reset time unknown" + until == null -> "$percent · reset time unknown" // Under a minute, including past the end: the number would round to "0m left", which reads // as a measurement rather than as the window having run out. - until < Duration.ofMinutes(1) -> "$percent \u00b7 refresh soon" - else -> "$percent \u00b7 ${formatSpan(until)} left" + until < Duration.ofMinutes(1) -> "$percent · refresh soon" + else -> "$percent · ${formatSpan(until)} left" } } /** - * The five-hour window for one machine, out of every machine's snapshot. + * One machine's snapshot, out of every machine's. * - * Selects the window by `kind`, which is the API's own word, rather than by the label beside it -- - * the label is written to be read and would stop matching the day its wording changes, silently - * leaving the bar with nothing to show. - * - * Every way of having *failed* to get a number is [FiveHourUsage.Unavailable] with the reason in - * it: a machine nobody logged into, one that could not be reached, a snapshot that came back - * without the window. None of them may look like zero, and none of them may look like - * [FiveHourUsage.NotMetered], which is the machine having no quota rather than the question going - * unanswered. + * Every way of having *failed* to get numbers is [SessionUsage.Unavailable] with the reason in it: + * a machine nobody logged into, one that could not be reached, a snapshot that came back empty. + * None of them may look like zero, and none may look like [SessionUsage.NotMetered], which is the + * machine having no quota rather than the question going unanswered. */ -fun fiveHourFor(snapshots: List, setup: String): FiveHourUsage { - val mine = snapshots.firstOrNull { it.setup == setup } +fun usageFor(snapshots: List, setup: String): SessionUsage { // No snapshot at all means the backend never asked, which it only does for a machine with // nothing metered on it. That is a different answer from having asked and failed. - if (mine == null) { - return FiveHourUsage.NotMetered - } + val mine = snapshots.firstOrNull { it.setup == setup } ?: return SessionUsage.NotMetered if (mine.state != "ok") { - return FiveHourUsage.Unavailable(mine.detail ?: mine.state) + return SessionUsage.Unavailable(mine.detail ?: mine.state) } - val window = - mine.windows.firstOrNull { it.kind == "session" } - ?: return FiveHourUsage.Unavailable("no five-hour window reported") - return FiveHourUsage.Known(window.percent, window.resetsAt) + return SessionUsage.Known(mine.windows) } diff --git a/app/androidApp/src/main/kotlin/com/example/aiapp/SettingsScreen.kt b/app/androidApp/src/main/kotlin/com/example/aiapp/SettingsScreen.kt index b733ca1..3714478 100644 --- a/app/androidApp/src/main/kotlin/com/example/aiapp/SettingsScreen.kt +++ b/app/androidApp/src/main/kotlin/com/example/aiapp/SettingsScreen.kt @@ -11,12 +11,12 @@ import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.width import androidx.compose.material3.Button import androidx.compose.material3.MaterialTheme import androidx.compose.material3.OutlinedButton import androidx.compose.material3.OutlinedTextField import androidx.compose.material3.Text -import androidx.compose.material3.TextButton import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf @@ -79,14 +79,21 @@ fun SettingsScreen( Column(Modifier.fillMaxSize().padding(16.dp)) { Row(verticalAlignment = Alignment.CenterVertically, modifier = Modifier.fillMaxWidth()) { + // Leading, where a back arrow points at what it returns to. Trailing it would put a + // left-pointing arrow at the right edge, aimed across the title it sits beside. + // + // Absent rather than disabled on first run, which is the one place this app lets a + // control come and go: there is no screen underneath yet, so a Back here would not be + // a capability being withheld but a promise it could not keep. + if (onBack != null) { + GlyphButton(BACK_GLYPH, "Back", onBack) + Spacer(Modifier.width(8.dp)) + } Text( "Server", style = MaterialTheme.typography.headlineSmall, modifier = Modifier.weight(1f), ) - if (onBack != null) { - TextButton(onClick = onBack) { Text("Back") } - } } Spacer(Modifier.height(8.dp)) Text( diff --git a/app/androidApp/src/main/kotlin/com/example/aiapp/SetupsScreen.kt b/app/androidApp/src/main/kotlin/com/example/aiapp/SetupsScreen.kt index 7aad7d8..c85da27 100644 --- a/app/androidApp/src/main/kotlin/com/example/aiapp/SetupsScreen.kt +++ b/app/androidApp/src/main/kotlin/com/example/aiapp/SetupsScreen.kt @@ -38,7 +38,7 @@ import kotlinx.coroutines.withContext * which is what keeps the enrolled token from being able to introduce commands. */ @Composable -fun SetupsScreen(settings: ServerSettings, onBack: () -> Unit) { +fun SetupsScreen(settings: ServerSettings, reloadToken: Int) { val scope = rememberCoroutineScope() var state by remember { mutableStateOf>>(LoadState.Loading) } var adding by remember { mutableStateOf(false) } @@ -56,14 +56,13 @@ fun SetupsScreen(settings: ServerSettings, onBack: () -> Unit) { } } - LaunchedEffect(Unit) { reload() } + LaunchedEffect(reloadToken) { reload() } Column(Modifier.fillMaxSize().padding(16.dp)) { - Text("Setups", style = MaterialTheme.typography.headlineSmall) + // The heading and Back are the tab row's now; adding a machine is this tab's own work + // and stays with the list it adds to. Row(verticalAlignment = Alignment.CenterVertically, modifier = Modifier.fillMaxWidth()) { TextButton(onClick = { adding = true }) { Text("Add machine") } - Spacer(Modifier.weight(1f)) - TextButton(onClick = onBack) { Text("Back") } } Spacer(Modifier.height(8.dp)) diff --git a/app/androidApp/src/main/kotlin/com/example/aiapp/Theme.kt b/app/androidApp/src/main/kotlin/com/example/aiapp/Theme.kt index e59a2c7..0846b30 100644 --- a/app/androidApp/src/main/kotlin/com/example/aiapp/Theme.kt +++ b/app/androidApp/src/main/kotlin/com/example/aiapp/Theme.kt @@ -130,16 +130,43 @@ val warningColor: Color @Composable get() = Mocha.Yellow /** - * How much of a quota window is gone, on the bar under a session's header. + * The fill of a progress bar that is only reporting how far along something is. * - * Blue because that bar reports a quantity rather than a verdict: it sits on a screen the reader - * opened to do something else, and the scheme's primary made it the loudest thing there. The usage - * screen is where the same number turns [warningColor] and then [overLimitColor] -- somebody - * looking at that screen came to be told where the limits are. + * Blue because a bar like this reports a quantity rather than a verdict, and the scheme's primary + * made it the loudest thing on a screen the reader opened to do something else. A download, or a + * compaction, has no limit to be near: it finishes. Only a bar measuring a *quota* escalates, and + * that one is [quotaColor]. */ -val usageColor: Color +val progressColor: Color @Composable get() = Mocha.Blue +/** + * The fill of a bar measuring how much of a quota is gone: blue, then yellow, then red. + * + * One function rather than the same `when` written beside each bar, because the whole point of + * colouring by consequence is that the reader learns the step once -- two bars showing the same 80% + * in different colours teaches nothing except that the colour cannot be trusted. It reads as a + * difference in degree, which is all colour can carry: the states that differ in *kind* from this + * -- a window nobody could read, a machine that meters nothing -- are said in words elsewhere, + * because a reader has no way to tell those from an ordinary low number by colour alone. + * + * [percent] is the API's own 0-100 rather than a fraction, so callers pass what the server sent + * without each converting it first and one of them getting it wrong by a factor of a hundred. + */ +@Composable +fun quotaColor(percent: Double): Color = + when { + percent >= OVER_LIMIT_PERCENT -> overLimitColor + percent >= WARNING_PERCENT -> warningColor + else -> progressColor + } + +/** Close enough to the limit to be worth seeing before starting something big. */ +private const val WARNING_PERCENT = 75.0 + +/** Close enough that the next turn may be the one that is refused. */ +private const val OVER_LIMIT_PERCENT = 95.0 + /** * Code: a fenced block, an inline span, a tool's input. * diff --git a/app/androidApp/src/main/kotlin/com/example/aiapp/UsageScreen.kt b/app/androidApp/src/main/kotlin/com/example/aiapp/UsageDialog.kt similarity index 74% rename from app/androidApp/src/main/kotlin/com/example/aiapp/UsageScreen.kt rename to app/androidApp/src/main/kotlin/com/example/aiapp/UsageDialog.kt index 682f89c..74daf3e 100644 --- a/app/androidApp/src/main/kotlin/com/example/aiapp/UsageScreen.kt +++ b/app/androidApp/src/main/kotlin/com/example/aiapp/UsageDialog.kt @@ -3,12 +3,12 @@ package com.example.aiapp import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer -import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.verticalScroll +import androidx.compose.material3.AlertDialog import androidx.compose.material3.Card import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.LinearProgressIndicator @@ -30,9 +30,18 @@ import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch import kotlinx.coroutines.withContext -/** Window bars for the account's rate limits, with reset times. */ +/** + * Window bars for the account's rate limits, with reset times. + * + * A dialog rather than a screen. Usage is something you check *against* what you were reading -- + * "can I start this" is asked with the transcript still on screen -- and pushing a whole screen for + * it took the session away to answer a question about the session. It also has no navigation of its + * own: there is nothing here to open, so the only thing its Back could ever have meant was "put + * this away", which is what dismissing does. The system back gesture dismisses it, since a `Dialog` + * handles that itself. + */ @Composable -fun UsageScreen(settings: ServerSettings, onBack: () -> Unit) { +fun UsageDialog(settings: ServerSettings, onDismiss: () -> Unit) { val scope = rememberCoroutineScope() var state by remember { mutableStateOf>>(LoadState.Loading) } @@ -49,24 +58,35 @@ fun UsageScreen(settings: ServerSettings, onBack: () -> Unit) { } LaunchedEffect(Unit) { refresh() } - Column(Modifier.fillMaxSize().verticalScroll(rememberScrollState()).padding(16.dp)) { - Row(verticalAlignment = Alignment.CenterVertically, modifier = Modifier.fillMaxWidth()) { - // Deliberately not subtitled with the provider this was opened from. These numbers - // belong to an account on a particular machine, reported by whichever paid service - // answered there -- naming the session's provider here made an echo session's - // screen read "echo" above a card reading "claude", which is a claim about echo - // that nothing measured. Each card names the machine and the service it came from, - // which is the true scope. - Text( - "Usage", - style = MaterialTheme.typography.headlineSmall, - modifier = Modifier.weight(1f), - ) - TextButton(onClick = onBack) { Text("Back") } - TextButton(onClick = { refresh() }) { Text("Refresh") } - } - Spacer(Modifier.height(16.dp)) + AlertDialog( + onDismissRequest = onDismiss, + title = { + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier.fillMaxWidth(), + ) { + // Deliberately not subtitled with the provider this was opened from. These + // numbers belong to an account on a particular machine, reported by whichever + // paid service answered there -- naming the session's provider here made an echo + // session's screen read "echo" above a card reading "claude", which is a claim + // about echo that nothing measured. Each card names the machine and the service + // it came from, which is the true scope. + Text("Usage", modifier = Modifier.weight(1f)) + GlyphButton(REFRESH_GLYPH, "Refresh usage", { refresh() }) + } + }, + // Scrolls here rather than being trimmed: a machine can report any number of windows and + // there can be any number of machines, and a dialog is the one place where running out of + // room is silent. + text = { Column(Modifier.verticalScroll(rememberScrollState())) { UsageBody(state) } }, + confirmButton = { TextButton(onClick = onDismiss) { Text("Close") } }, + ) +} +/** What came back, or why nothing did. Split out so the dialog above reads as its own shape. */ +@Composable +private fun UsageBody(state: LoadState>) { + Column { when (val current = state) { is LoadState.Loading -> CircularProgressIndicator() is LoadState.Error -> Text(current.message, color = MaterialTheme.colorScheme.error) @@ -146,12 +166,6 @@ private fun SnapshotState(snapshot: UsageSnapshot) { @Composable private fun WindowBar(window: UsageWindow) { - val color = - when { - window.percent >= 95 -> overLimitColor - window.percent >= 75 -> warningColor - else -> MaterialTheme.colorScheme.primary - } Column { Row(modifier = Modifier.fillMaxWidth()) { Text( @@ -164,7 +178,7 @@ private fun WindowBar(window: UsageWindow) { Spacer(Modifier.height(4.dp)) LinearProgressIndicator( progress = { (window.percent / 100.0).toFloat().coerceIn(0f, 1f) }, - color = color, + color = quotaColor(window.percent), modifier = Modifier.fillMaxWidth(), ) window.resetsAt?.let { diff --git a/app/androidApp/src/main/res/font/nerd_icons.ttf b/app/androidApp/src/main/res/font/nerd_icons.ttf new file mode 100644 index 0000000..c3708c7 Binary files /dev/null and b/app/androidApp/src/main/res/font/nerd_icons.ttf differ diff --git a/app/build-icon-font.sh b/app/build-icon-font.sh new file mode 100755 index 0000000..964315f --- /dev/null +++ b/app/build-icon-font.sh @@ -0,0 +1,61 @@ +#!/usr/bin/env bash +# Rebuilds androidApp/src/main/res/font/nerd_icons.ttf. +# +# The app draws a handful of icons -- a cog, a refresh arrow, send, stop -- +# as text in a Nerd Fonts glyph rather than as vector assets or as ordinary +# Unicode. Unicode has no character for most of these, and the ones it does +# have are not reliably in an Android system font, so they land as tofu +# boxes on somebody's phone. Shipping the subset removes the hope: the +# glyph is in the APK. +# +# The whole symbols font is 3 MB for the handful below, so what is +# committed is a subset. Add a codepoint to GLYPHS below and to NerdIcons.kt +# (the two lists have to agree -- a codepoint in the Kotlin but not here is +# a glyph that silently doesn't exist), then run this and commit the result. +# +# Needs python3 and network access; fontTools is fetched into a temporary +# venv, so nothing has to be installed on the machine. +# +# Copied from dev-updater's script of the same name rather than shared +# through wg-app-link, for the reason Theme.kt gives about the palette: the +# link is the tunnel, the pinned CA and enrollment, and an icon set is a +# preference rather than part of that contract. +set -euo pipefail + +# Codepoint, then the Nerd Fonts glyph name it came from. Material Design +# Icons bar one, so they read as one family -- and the first two are +# deliberately the same two dev-updater uses, since a cog and a refresh +# arrow mean the same thing in both apps. The exception is noted on its +# own line, as dev-updater's script does with its two. +GLYPHS=( + U+F0493 # md-cog + U+F0450 # md-refresh + U+F048A # md-send + U+F04DB # md-stop + U+F004D # md-arrow_left + U+F201 # fa-line_chart -- Font Awesome's, asked for by name +) + +url=https://github.com/ryanoasis/nerd-fonts/releases/latest/download/NerdFontsSymbolsOnly.zip +out="$(cd "$(dirname "$0")" && pwd)/androidApp/src/main/res/font/nerd_icons.ttf" +work="$(mktemp -d)" +trap 'rm -rf "$work"' EXIT + +echo "Fetching $url" +curl -fsSL -o "$work/nf.zip" "$url" +python3 -c 'import sys,zipfile; zipfile.ZipFile(sys.argv[1]).extractall(sys.argv[2])' "$work/nf.zip" "$work" + +python3 -m venv "$work/venv" +"$work/venv/bin/pip" -q install fonttools + +unicodes="$(IFS=,; echo "${GLYPHS[*]}")" +mkdir -p "$(dirname "$out")" +# The proportional face rather than the Mono one: these are drawn inline +# beside text, where a fixed advance would pad each icon out to a cell. +"$work/venv/bin/pyftsubset" "$work/SymbolsNerdFont-Regular.ttf" \ + --unicodes="$unicodes" \ + --layout-features= \ + --drop-tables+=DSIG \ + --output-file="$out" + +echo "Wrote $out ($(stat -c %s "$out") bytes) with ${#GLYPHS[@]} glyphs"