Merge remote-tracking branch 'origin/main'
This commit is contained in:
commit
bb191eec21
17 files changed
+627
-310
No files matched your search
@@ -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>(Screen.SessionList) }
|
||||
var screen by remember { mutableStateOf<Screen>(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,
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -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.
|
||||
*/
|
||||
@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<List<Setup>>>(LoadState.Loading) }
|
||||
var chosen by remember { mutableStateOf<Setup?>(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.",
|
||||
|
||||
@@ -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.
|
||||
*/
|
||||
@Composable
|
||||
fun ModelsScreen(settings: ServerSettings, onBack: () -> Unit) {
|
||||
fun ModelsScreen(settings: ServerSettings, reloadToken: Int) {
|
||||
val scope = rememberCoroutineScope()
|
||||
var state by remember { mutableStateOf<LoadState<Models>>(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,
|
||||
|
||||
@@ -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.
|
||||
|
||||
/**
|
||||
* 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<List<SessionSummary>>>(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
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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))
|
||||
|
||||
|
||||
@@ -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<UsageWindow>) : 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>(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>(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<UsageSnapshot>, setup: String): FiveHourUsage {
|
||||
val mine = snapshots.firstOrNull { it.setup == setup }
|
||||
fun usageFor(snapshots: List<UsageSnapshot>, 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)
|
||||
}
|
||||
@@ -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(
|
||||
|
||||
@@ -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<List<Setup>>>(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))
|
||||
|
||||
|
||||
@@ -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.
|
||||
*
|
||||
|
||||
+41
-27
@@ -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<List<UsageSnapshot>>>(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<List<UsageSnapshot>>) {
|
||||
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 {
|
||||
Binary file not shown.
Reference in new issue
Block a user