Merge remote-tracking branch 'origin/main'

This commit is contained in:
iris committed 2026-08-29 22:29:18 -04:00
commit bb191eec21
17 files changed
+627 -310

No files matched your search

+12 -3
View File
@@ -73,9 +73,18 @@ repo is in PLAN.md's "Backend layout" section.
`EchoDriver`, and transcripts. `EchoDriver`, and transcripts.
- `app/` — Compose Android app, single `:androidApp` module, package - `app/` — Compose Android app, single `:androidApp` module, package
`com.example.aiapp`, label "AI Sessions". `AppRoot.kt` is the navigation `com.example.aiapp`, label "AI Sessions". `AppRoot.kt` is the navigation
`when`; `Api.kt`/`EventStream.kt` the REST + SSE clients; `Events.kt` the `when`; `MainScreen.kt` the root's four tabs (sessions, import, models,
event model mirror; `ServerConfig.kt` settings + Keystore-sealed token; setups) with settings and refresh on the title row; `Api.kt`/`EventStream.kt`
screens in `SessionListScreen/SessionScreen/SpawnScreen/SettingsScreen`. 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: - `.dev-updater.ron` — what Dev Updater is asked to do with this checkout:
the server (built in `server/`, run as `service: Managed(...)`) and the the server (built in `server/`, run as `service: Managed(...)`) and the
APK (built in `app/`), built in parallel. The project it serves is the APK (built in `app/`), built in parallel. The project it serves is the
@@ -16,37 +16,27 @@ import androidx.compose.ui.unit.dp
import com.example.wgapplink.localNetworkAllowed import com.example.wgapplink.localNetworkAllowed
/** /**
* One `when` rather than a navigation library: four screens, with the list as the root and the back * One `when` rather than a navigation library: a handful of screens, with [Screen.Main] as the root
* button the only other way between them. * 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 { private sealed class Screen {
data object SessionList : Screen() data object Main : Screen()
data class Session(val summary: SessionSummary) : Screen() data class Session(val summary: SessionSummary) : Screen()
data object Spawn : 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 * What can be changed about one session. Carries the session back with it so Back returns where
* running that session and not to the app. It carries the session back with it so Back returns * it came from, and carries it *out* renamed, so the session behind it shows the new name
* where it came from -- see [Screen.Session]. * without waiting for a list refresh.
*/
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.
*/ */
data class SessionSettings(val from: SessionSummary) : Screen() data class SessionSettings(val from: SessionSummary) : Screen()
data object Models : Screen()
data object Setups : Screen()
data object Settings : Screen() data object Settings : Screen()
} }
@@ -58,7 +48,7 @@ private sealed class Screen {
fun AppRoot(settingsVersion: Int) { fun AppRoot(settingsVersion: Int) {
val context = LocalContext.current val context = LocalContext.current
var settings by remember(settingsVersion) { mutableStateOf(loadServerSettings(context)) } var settings by remember(settingsVersion) { mutableStateOf(loadServerSettings(context)) }
var screen by remember { mutableStateOf<Screen>(Screen.SessionList) } var screen by remember { mutableStateOf<Screen>(Screen.Main) }
// Bumped whenever another screen changes something the list shows, so // Bumped whenever another screen changes something the list shows, so
// returning to it refetches instead of showing a stale list. // returning to it refetches instead of showing a stale list.
var reloadToken by remember { mutableIntStateOf(0) } var reloadToken by remember { mutableIntStateOf(0) }
@@ -86,7 +76,7 @@ fun AppRoot(settingsVersion: Int) {
existing = null, existing = null,
onSaved = { saved -> onSaved = { saved ->
settings = saved settings = saved
screen = Screen.SessionList screen = Screen.Main
}, },
onBack = null, onBack = null,
) )
@@ -97,32 +87,32 @@ fun AppRoot(settingsVersion: Int) {
// reached by the system back gesture or a screen's own Back button. // 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 // Every leaf screen can have changed something the list shows, so it
// always refetches. // always refetches.
val goToList = { val goToMain = {
reloadToken++ reloadToken++
screen = Screen.SessionList screen = Screen.Main
} }
if (screen !is Screen.SessionList) { if (screen !is Screen.Main) {
BackHandler(onBack = goToList) BackHandler(onBack = goToMain)
} }
when (val here = screen) { when (val here = screen) {
is Screen.SessionList -> is Screen.Main ->
SessionListScreen( MainScreen(
settings = current, settings = current,
reloadToken = reloadToken, reloadToken = reloadToken,
onOpen = { screen = Screen.Session(it) }, onOpen = { screen = Screen.Session(it) },
onSpawn = { screen = Screen.Spawn }, onSpawn = { screen = Screen.Spawn },
onImport = { screen = Screen.Import }, onImported = { imported ->
onModels = { screen = Screen.Models }, reloadToken++
onSetups = { screen = Screen.Setups }, screen = Screen.Session(imported)
},
onSettings = { screen = Screen.Settings }, onSettings = { screen = Screen.Settings },
) )
is Screen.Session -> is Screen.Session ->
SessionScreen( SessionScreen(
settings = current, settings = current,
summary = here.summary, summary = here.summary,
onBack = goToList, onBack = goToMain,
onUsage = { screen = Screen.Usage(here.summary) },
onSettings = { screen = Screen.SessionSettings(here.summary) }, onSettings = { screen = Screen.SessionSettings(here.summary) },
) )
is Screen.Spawn -> is Screen.Spawn ->
@@ -132,23 +122,7 @@ fun AppRoot(settingsVersion: Int) {
reloadToken++ reloadToken++
screen = Screen.Session(spawned) screen = Screen.Session(spawned)
}, },
onBack = goToList, onBack = goToMain,
)
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) },
) )
is Screen.SessionSettings -> is Screen.SessionSettings ->
SessionSettingsScreen( SessionSettingsScreen(
@@ -162,16 +136,14 @@ fun AppRoot(settingsVersion: Int) {
}, },
onBack = { screen = Screen.Session(here.from) }, 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 -> is Screen.Settings ->
SettingsScreen( SettingsScreen(
existing = current, existing = current,
onSaved = { saved -> onSaved = { saved ->
settings = saved settings = saved
goToList() goToMain()
}, },
onBack = goToList, onBack = goToMain,
) )
} }
} }
@@ -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
@@ -39,11 +39,7 @@ import kotlinx.coroutines.withContext
* screen into a file reader. * screen into a file reader.
*/ */
@Composable @Composable
fun ImportScreen( fun ImportScreen(settings: ServerSettings, reloadToken: Int, onImported: (SessionSummary) -> Unit) {
settings: ServerSettings,
onImported: (SessionSummary) -> Unit,
onBack: () -> Unit,
) {
val scope = rememberCoroutineScope() val scope = rememberCoroutineScope()
var setups by remember { mutableStateOf<LoadState<List<Setup>>>(LoadState.Loading) } var setups by remember { mutableStateOf<LoadState<List<Setup>>>(LoadState.Loading) }
var chosen by remember { mutableStateOf<Setup?>(null) } var chosen by remember { mutableStateOf<Setup?>(null) }
@@ -73,7 +69,7 @@ fun ImportScreen(
} }
} }
LaunchedEffect(Unit) { LaunchedEffect(reloadToken) {
setups = setups =
try { try {
val found = withContext(Dispatchers.IO) { fetchSetups(settings) } val found = withContext(Dispatchers.IO) { fetchSetups(settings) }
@@ -88,14 +84,8 @@ fun ImportScreen(
} }
Column(Modifier.fillMaxSize().padding(16.dp)) { Column(Modifier.fillMaxSize().padding(16.dp)) {
Row(verticalAlignment = Alignment.CenterVertically, modifier = Modifier.fillMaxWidth()) { // No heading: the tab that selected this one already says "Import". The sentence below
Text( // stays, because it says what importing *does*, which the tab label cannot.
"Import a session",
style = MaterialTheme.typography.headlineSmall,
modifier = Modifier.weight(1f),
)
TextButton(onClick = onBack) { Text("Back") }
}
Text( Text(
"Sessions Claude Code already has on the machine. Importing continues one where it " + "Sessions Claude Code already has on the machine. Importing continues one where it " +
"left off; the transcript here shows its recent history.", "left off; the transcript here shows its recent history.",
@@ -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)
}
}
}
@@ -40,7 +40,7 @@ import kotlinx.coroutines.withContext
* going when this screen closes. * going when this screen closes.
*/ */
@Composable @Composable
fun ModelsScreen(settings: ServerSettings, onBack: () -> Unit) { fun ModelsScreen(settings: ServerSettings, reloadToken: Int) {
val scope = rememberCoroutineScope() val scope = rememberCoroutineScope()
var state by remember { mutableStateOf<LoadState<Models>>(LoadState.Loading) } var state by remember { mutableStateOf<LoadState<Models>>(LoadState.Loading) }
var query by remember { mutableStateOf("") } 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 // 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 // any session, so it has no event stream of its own. Slow enough not
// to matter, frequent enough that a bar moves. // 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) { while (true) {
reload() reload()
delay(1500) delay(1500)
@@ -69,16 +72,6 @@ fun ModelsScreen(settings: ServerSettings, onBack: () -> Unit) {
} }
Column(Modifier.fillMaxSize().padding(16.dp)) { 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 { actionError?.let {
Text(it, color = MaterialTheme.colorScheme.error) Text(it, color = MaterialTheme.colorScheme.error)
Spacer(Modifier.height(8.dp)) Spacer(Modifier.height(8.dp))
@@ -270,6 +263,10 @@ private fun DownloadCard(download: Download, onCancel: () -> Unit) {
if (download.total != null && download.total > 0) { if (download.total != null && download.total > 0) {
LinearProgressIndicator( LinearProgressIndicator(
progress = { download.done.toFloat() / download.total.toFloat() }, 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(), modifier = Modifier.fillMaxWidth(),
) )
Text( Text(
@@ -277,7 +274,7 @@ private fun DownloadCard(download: Download, onCancel: () -> Unit) {
style = MaterialTheme.typography.bodySmall, style = MaterialTheme.typography.bodySmall,
) )
} else { } else {
LinearProgressIndicator(Modifier.fillMaxWidth()) LinearProgressIndicator(color = progressColor, modifier = Modifier.fillMaxWidth())
Text( Text(
"${gigabytes(download.done)} so far, total size unknown", "${gigabytes(download.done)} so far, total size unknown",
style = MaterialTheme.typography.bodySmall, style = MaterialTheme.typography.bodySmall,
@@ -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
@@ -39,8 +39,11 @@ import kotlinx.coroutines.withContext
// as in dev-updater. // as in dev-updater.
/** /**
* The session list -- the app's root screen. Sessions awaiting an answer sort to the top: that's * The sessions tab: sessions awaiting an answer sort to the top, which is the "your turn" inbox.
* 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 @Composable
fun SessionListScreen( fun SessionListScreen(
@@ -48,10 +51,6 @@ fun SessionListScreen(
reloadToken: Int, reloadToken: Int,
onOpen: (SessionSummary) -> Unit, onOpen: (SessionSummary) -> Unit,
onSpawn: () -> Unit, onSpawn: () -> Unit,
onImport: () -> Unit,
onModels: () -> Unit,
onSetups: () -> Unit,
onSettings: () -> Unit,
) { ) {
val scope = rememberCoroutineScope() val scope = rememberCoroutineScope()
var listState by remember { mutableStateOf<LoadState<List<SessionSummary>>>(LoadState.Loading) } var listState by remember { mutableStateOf<LoadState<List<SessionSummary>>>(LoadState.Loading) }
@@ -92,25 +91,6 @@ fun SessionListScreen(
Box(Modifier.fillMaxSize()) { Box(Modifier.fillMaxSize()) {
Column(Modifier.fillMaxSize().padding(16.dp)) { 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) { when (val state = listState) {
is LoadState.Loading -> CircularProgressIndicator() is LoadState.Loading -> CircularProgressIndicator()
// The message as Api.kt wrote it, with nothing added: it is // The message as Api.kt wrote it, with nothing added: it is
@@ -27,8 +27,8 @@ import androidx.compose.material3.CardDefaults
import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.DropdownMenu import androidx.compose.material3.DropdownMenu
import androidx.compose.material3.DropdownMenuItem import androidx.compose.material3.DropdownMenuItem
import androidx.compose.material3.IconButton
import androidx.compose.material3.LinearProgressIndicator import androidx.compose.material3.LinearProgressIndicator
import androidx.compose.material3.LocalContentColor
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedButton import androidx.compose.material3.OutlinedButton
import androidx.compose.material3.OutlinedTextField import androidx.compose.material3.OutlinedTextField
@@ -316,7 +316,6 @@ fun SessionScreen(
settings: ServerSettings, settings: ServerSettings,
summary: SessionSummary, summary: SessionSummary,
onBack: () -> Unit, onBack: () -> Unit,
onUsage: () -> Unit,
onSettings: () -> Unit, onSettings: () -> Unit,
) { ) {
val scope = rememberCoroutineScope() 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()) { Column(Modifier.fillMaxSize()) {
Row( Row(
verticalAlignment = Alignment.CenterVertically, verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.fillMaxWidth().padding(horizontal = 8.dp, vertical = 4.dp), 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)) { Column(Modifier.weight(1f)) {
Text(title, style = MaterialTheme.typography.titleMedium) Text(title, style = MaterialTheme.typography.titleMedium)
Text( Text(
@@ -766,22 +771,29 @@ fun SessionScreen(
// the paid service's own numbers, so a session on a provider with no such service // 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 // 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. // and goes makes its absence the signal, and absence cannot say why.
TextButton(onClick = onUsage) { Text("Usage") } // Coloured by the worst window behind it, so the row says whether the limits are
// A step down from this session, so it sits at the end of the session's own row. // worth opening before anybody opens them. Blue at every ordinary level and only
// The name is the whole of what it holds today, which is why it is a gear and not a // yellow or red near a limit -- and the theme's plain control colour whenever there
// word: there will be more, and a bar of words has nowhere to put it. // is no measurement, since blue is the low end of the scale here and would read as
IconButton( // "checked, and fine" about a machine nobody could reach.
onClick = onSettings, Row(horizontalArrangement = Arrangement.spacedBy(GLYPH_BUTTON_GAP)) {
modifier = Modifier.semantics { contentDescription = "Session settings" }, GlyphButton(
) { USAGE_GLYPH,
Gear() "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 // 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 // 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. // 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 -> (streamError ?: actionError)?.let { message ->
Text( Text(
@@ -1052,21 +1064,46 @@ fun SessionScreen(
} }
if (running) { if (running) {
OutlinedButton(onClick = { act { interruptSession(settings, summary.id) } }) { 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)) Spacer(Modifier.width(8.dp))
} }
// "Queue" while a turn is in flight, because that is what // The paper plane alone when it means send. While a turn is in flight it keeps
// sending then does: the message is injected at the next // the word "Queue" beside it, because that is what sending then does -- the
// tool boundary rather than starting a turn of its own. // message is injected at the next tool boundary rather than starting a turn of
// Naming it Send there would promise something immediate // its own -- and an icon that does two things while looking identical would
// and describe something that waits. // promise something immediate and do something that waits. The word is also the
Button(onClick = { send() }) { Text(if (running) "Queue" else "Send") } // 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 * An inline transcript image, fetched (authenticated, pinned) from the session's files route. The
* bitmap is remembered per ref, so scrolling doesn't refetch. * bitmap is remembered per ref, so scrolling doesn't refetch.
@@ -7,6 +7,7 @@ import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.text.KeyboardActions import androidx.compose.foundation.text.KeyboardActions
import androidx.compose.foundation.text.KeyboardOptions import androidx.compose.foundation.text.KeyboardOptions
@@ -15,7 +16,6 @@ import androidx.compose.material3.Button
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedTextField import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
@@ -77,12 +77,13 @@ fun SessionSettingsScreen(
Column(Modifier.fillMaxSize().verticalScroll(rememberScrollState()).padding(16.dp)) { Column(Modifier.fillMaxSize().verticalScroll(rememberScrollState()).padding(16.dp)) {
Row(verticalAlignment = Alignment.CenterVertically, modifier = Modifier.fillMaxWidth()) { Row(verticalAlignment = Alignment.CenterVertically, modifier = Modifier.fillMaxWidth()) {
GlyphButton(BACK_GLYPH, "Back", onBack)
Spacer(Modifier.width(8.dp))
Text( Text(
"Session settings", "Session settings",
style = MaterialTheme.typography.headlineSmall, style = MaterialTheme.typography.headlineSmall,
modifier = Modifier.weight(1f), modifier = Modifier.weight(1f),
) )
TextButton(onClick = onBack) { Text("Back") }
} }
Spacer(Modifier.height(16.dp)) Spacer(Modifier.height(16.dp))
@@ -14,6 +14,7 @@ import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import java.time.Duration import java.time.Duration
import java.time.OffsetDateTime import java.time.OffsetDateTime
@@ -21,12 +22,13 @@ import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.delay import kotlinx.coroutines.delay
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
/** How much of the five-hour window is gone, or why that isn't known. */ /** What one machine's rate limits came back as, or why they didn't. */
sealed class FiveHourUsage { sealed class SessionUsage {
/** Nothing has come back yet. Distinct from every answer, including an empty one. */ /** 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<UsageWindow>) : SessionUsage()
/** /**
* This machine meters nothing, so there is no window to show. * 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 * 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. * 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. * 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 * 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. * 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. */ /** 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 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>(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. * 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 * 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 * 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 * 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. * out of them would be a guess wearing a measurement's clothes.
*/ */
@Composable @Composable
fun SessionUsageBar(settings: ServerSettings, setup: String, modifier: Modifier = Modifier) { fun SessionUsageBar(usage: SessionUsage, modifier: Modifier = Modifier) {
var usage by remember(setup) { mutableStateOf<FiveHourUsage>(FiveHourUsage.Waiting) } // The countdown moves even when the numbers do not, so it is driven by a clock of its own
// The countdown moves even when the numbers do not, so it is driven by a clock this loop // rather than recomputed at draw time: a percentage that comes back unchanged is an equal
// advances rather than recomputed at draw time: a percentage that comes back unchanged is an // value, Compose skips the recomposition, and a "left" that only ticked when the quota
// equal value, Compose skips the recomposition, and a "left" that only ticks when the quota // happened to move would sit at a stale figure for hours.
// happens to move would sit at a stale figure for hours.
var now by remember { mutableStateOf(OffsetDateTime.now()) } var now by remember { mutableStateOf(OffsetDateTime.now()) }
LaunchedEffect(Unit) {
LaunchedEffect(setup) {
while (true) { 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) delay(REFRESH_MS)
now = OffsetDateTime.now()
} }
} }
// Nothing at all for a machine that meters nothing: a row saying "unknown" there would // 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. // report a problem about a setup somebody chose, on every screen, forever.
if (usage is FiveHourUsage.NotMetered) { if (usage is SessionUsage.NotMetered) {
return return
} }
@@ -97,39 +138,47 @@ fun SessionUsageBar(settings: ServerSettings, setup: String, modifier: Modifier
verticalAlignment = Alignment.CenterVertically, verticalAlignment = Alignment.CenterVertically,
modifier = modifier.fillMaxWidth().padding(horizontal = 16.dp, vertical = 2.dp), 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) { when (val state = usage) {
FiveHourUsage.NotMetered -> Unit SessionUsage.NotMetered -> Unit
// Words, not a colour and not an empty bar: "couldn't check" is a different kind of is SessionUsage.Unavailable -> UsageNote("5-hour usage unknown -- ${state.why}")
// answer from "this much is used", and only words carry a difference in kind. SessionUsage.Waiting -> UsageNote("5-hour usage: checking")
is FiveHourUsage.Unavailable -> is SessionUsage.Known -> {
Text( val window = state.windows.firstOrNull { it.kind == "session" }
"5-hour usage unknown -- ${state.why}", if (window == null) {
style = MaterialTheme.typography.labelSmall, UsageNote("5-hour usage unknown -- no five-hour window reported")
color = MaterialTheme.colorScheme.onSurfaceVariant, } else {
) LinearProgressIndicator(
FiveHourUsage.Waiting -> progress = { (window.percent / 100.0).toFloat().coerceIn(0f, 1f) },
Text( // The same step at the same percentages as the dialog's bars: this is the
"5-hour usage: checking", // same measurement, and a reader who learned the colour there has to be
style = MaterialTheme.typography.labelSmall, // able to read it here without checking which screen they are on.
color = MaterialTheme.colorScheme.onSurfaceVariant, color = quotaColor(window.percent),
) modifier = Modifier.weight(1f),
is FiveHourUsage.Known -> { )
LinearProgressIndicator( Text(
progress = { (state.percent / 100.0).toFloat().coerceIn(0f, 1f) }, fiveHourLabel(window, now),
color = usageColor, style = MaterialTheme.typography.labelSmall,
modifier = Modifier.weight(1f), color = MaterialTheme.colorScheme.onSurfaceVariant,
) modifier = Modifier.padding(start = 8.dp),
Text( )
fiveHourLabel(state, 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. * "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 * 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. * nothing measured. The percentage is still known, so it is still shown.
*/ */
private fun fiveHourLabel(state: FiveHourUsage.Known, now: OffsetDateTime): String { private fun fiveHourLabel(window: UsageWindow, now: OffsetDateTime): String {
val percent = "${state.percent.toInt()}%" val percent = "${window.percent.toInt()}%"
val until = state.resetsAt?.let { remainingUntil(it, now) } val until = window.resetsAt?.let { remainingUntil(it, now) }
return when { 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 // 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. // as a measurement rather than as the window having run out.
until < Duration.ofMinutes(1) -> "$percent \u00b7 refresh soon" until < Duration.ofMinutes(1) -> "$percent · refresh soon"
else -> "$percent \u00b7 ${formatSpan(until)} left" 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 -- * Every way of having *failed* to get numbers is [SessionUsage.Unavailable] with the reason in it:
* the label is written to be read and would stop matching the day its wording changes, silently * a machine nobody logged into, one that could not be reached, a snapshot that came back empty.
* leaving the bar with nothing to show. * 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.
* 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.
*/ */
fun fiveHourFor(snapshots: List<UsageSnapshot>, setup: String): FiveHourUsage { fun usageFor(snapshots: List<UsageSnapshot>, setup: String): SessionUsage {
val mine = snapshots.firstOrNull { it.setup == setup }
// No snapshot at all means the backend never asked, which it only does for a machine with // 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. // nothing metered on it. That is a different answer from having asked and failed.
if (mine == null) { val mine = snapshots.firstOrNull { it.setup == setup } ?: return SessionUsage.NotMetered
return FiveHourUsage.NotMetered
}
if (mine.state != "ok") { if (mine.state != "ok") {
return FiveHourUsage.Unavailable(mine.detail ?: mine.state) return SessionUsage.Unavailable(mine.detail ?: mine.state)
} }
val window = return SessionUsage.Known(mine.windows)
mine.windows.firstOrNull { it.kind == "session" }
?: return FiveHourUsage.Unavailable("no five-hour window reported")
return FiveHourUsage.Known(window.percent, window.resetsAt)
} }
@@ -11,12 +11,12 @@ import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.width
import androidx.compose.material3.Button import androidx.compose.material3.Button
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedButton import androidx.compose.material3.OutlinedButton
import androidx.compose.material3.OutlinedTextField import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
@@ -79,14 +79,21 @@ fun SettingsScreen(
Column(Modifier.fillMaxSize().padding(16.dp)) { Column(Modifier.fillMaxSize().padding(16.dp)) {
Row(verticalAlignment = Alignment.CenterVertically, modifier = Modifier.fillMaxWidth()) { 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( Text(
"Server", "Server",
style = MaterialTheme.typography.headlineSmall, style = MaterialTheme.typography.headlineSmall,
modifier = Modifier.weight(1f), modifier = Modifier.weight(1f),
) )
if (onBack != null) {
TextButton(onClick = onBack) { Text("Back") }
}
} }
Spacer(Modifier.height(8.dp)) Spacer(Modifier.height(8.dp))
Text( Text(
@@ -38,7 +38,7 @@ import kotlinx.coroutines.withContext
* which is what keeps the enrolled token from being able to introduce commands. * which is what keeps the enrolled token from being able to introduce commands.
*/ */
@Composable @Composable
fun SetupsScreen(settings: ServerSettings, onBack: () -> Unit) { fun SetupsScreen(settings: ServerSettings, reloadToken: Int) {
val scope = rememberCoroutineScope() val scope = rememberCoroutineScope()
var state by remember { mutableStateOf<LoadState<List<Setup>>>(LoadState.Loading) } var state by remember { mutableStateOf<LoadState<List<Setup>>>(LoadState.Loading) }
var adding by remember { mutableStateOf(false) } 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)) { 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()) { Row(verticalAlignment = Alignment.CenterVertically, modifier = Modifier.fillMaxWidth()) {
TextButton(onClick = { adding = true }) { Text("Add machine") } TextButton(onClick = { adding = true }) { Text("Add machine") }
Spacer(Modifier.weight(1f))
TextButton(onClick = onBack) { Text("Back") }
} }
Spacer(Modifier.height(8.dp)) Spacer(Modifier.height(8.dp))
@@ -130,16 +130,43 @@ val warningColor: Color
@Composable get() = Mocha.Yellow @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 * Blue because a bar like this reports a quantity rather than a verdict, and the scheme's primary
* opened to do something else, and the scheme's primary made it the loudest thing there. The usage * made it the loudest thing on a screen the reader opened to do something else. A download, or a
* screen is where the same number turns [warningColor] and then [overLimitColor] -- somebody * compaction, has no limit to be near: it finishes. Only a bar measuring a *quota* escalates, and
* looking at that screen came to be told where the limits are. * that one is [quotaColor].
*/ */
val usageColor: Color val progressColor: Color
@Composable get() = Mocha.Blue @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. * Code: a fenced block, an inline span, a tool's input.
* *
@@ -3,12 +3,12 @@ package com.example.aiapp
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.Card import androidx.compose.material3.Card
import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.LinearProgressIndicator import androidx.compose.material3.LinearProgressIndicator
@@ -30,9 +30,18 @@ import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext 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 @Composable
fun UsageScreen(settings: ServerSettings, onBack: () -> Unit) { fun UsageDialog(settings: ServerSettings, onDismiss: () -> Unit) {
val scope = rememberCoroutineScope() val scope = rememberCoroutineScope()
var state by remember { mutableStateOf<LoadState<List<UsageSnapshot>>>(LoadState.Loading) } var state by remember { mutableStateOf<LoadState<List<UsageSnapshot>>>(LoadState.Loading) }
@@ -49,24 +58,35 @@ fun UsageScreen(settings: ServerSettings, onBack: () -> Unit) {
} }
LaunchedEffect(Unit) { refresh() } LaunchedEffect(Unit) { refresh() }
Column(Modifier.fillMaxSize().verticalScroll(rememberScrollState()).padding(16.dp)) { AlertDialog(
Row(verticalAlignment = Alignment.CenterVertically, modifier = Modifier.fillMaxWidth()) { onDismissRequest = onDismiss,
// Deliberately not subtitled with the provider this was opened from. These numbers title = {
// belong to an account on a particular machine, reported by whichever paid service Row(
// answered there -- naming the session's provider here made an echo session's verticalAlignment = Alignment.CenterVertically,
// screen read "echo" above a card reading "claude", which is a claim about echo modifier = Modifier.fillMaxWidth(),
// that nothing measured. Each card names the machine and the service it came from, ) {
// which is the true scope. // Deliberately not subtitled with the provider this was opened from. These
Text( // numbers belong to an account on a particular machine, reported by whichever
"Usage", // paid service answered there -- naming the session's provider here made an echo
style = MaterialTheme.typography.headlineSmall, // session's screen read "echo" above a card reading "claude", which is a claim
modifier = Modifier.weight(1f), // about echo that nothing measured. Each card names the machine and the service
) // it came from, which is the true scope.
TextButton(onClick = onBack) { Text("Back") } Text("Usage", modifier = Modifier.weight(1f))
TextButton(onClick = { refresh() }) { Text("Refresh") } GlyphButton(REFRESH_GLYPH, "Refresh usage", { refresh() })
} }
Spacer(Modifier.height(16.dp)) },
// 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<List<UsageSnapshot>>) {
Column {
when (val current = state) { when (val current = state) {
is LoadState.Loading -> CircularProgressIndicator() is LoadState.Loading -> CircularProgressIndicator()
is LoadState.Error -> Text(current.message, color = MaterialTheme.colorScheme.error) is LoadState.Error -> Text(current.message, color = MaterialTheme.colorScheme.error)
@@ -146,12 +166,6 @@ private fun SnapshotState(snapshot: UsageSnapshot) {
@Composable @Composable
private fun WindowBar(window: UsageWindow) { private fun WindowBar(window: UsageWindow) {
val color =
when {
window.percent >= 95 -> overLimitColor
window.percent >= 75 -> warningColor
else -> MaterialTheme.colorScheme.primary
}
Column { Column {
Row(modifier = Modifier.fillMaxWidth()) { Row(modifier = Modifier.fillMaxWidth()) {
Text( Text(
@@ -164,7 +178,7 @@ private fun WindowBar(window: UsageWindow) {
Spacer(Modifier.height(4.dp)) Spacer(Modifier.height(4.dp))
LinearProgressIndicator( LinearProgressIndicator(
progress = { (window.percent / 100.0).toFloat().coerceIn(0f, 1f) }, progress = { (window.percent / 100.0).toFloat().coerceIn(0f, 1f) },
color = color, color = quotaColor(window.percent),
modifier = Modifier.fillMaxWidth(), modifier = Modifier.fillMaxWidth(),
) )
window.resetsAt?.let { window.resetsAt?.let {
Binary file not shown.
+61
View File
@@ -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"