Merge remote-tracking branch 'origin/main'
This commit is contained in:
commit
bb191eec21
17 files changed
+617
-300
No files matched your search
@@ -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
|
||||||
|
// 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.
|
// 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
|
// The name is the whole of what it holds today, which is why it is a cog and not
|
||||||
// word: there will be more, and a bar of words has nowhere to put it.
|
// a word: there will be more, and a bar of words has nowhere to put it.
|
||||||
IconButton(
|
GlyphButton(SETTINGS_GLYPH, "Session settings", onSettings)
|
||||||
onClick = onSettings,
|
|
||||||
modifier = Modifier.semantics { contentDescription = "Session settings" },
|
|
||||||
) {
|
|
||||||
Gear()
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 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,30 +138,27 @@ 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),
|
||||||
) {
|
) {
|
||||||
when (val state = usage) {
|
// Words, not a colour and not an empty bar: every one of these is a different kind of
|
||||||
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.
|
// answer from "this much is used", and only words carry a difference in kind.
|
||||||
is FiveHourUsage.Unavailable ->
|
when (val state = usage) {
|
||||||
Text(
|
SessionUsage.NotMetered -> Unit
|
||||||
"5-hour usage unknown -- ${state.why}",
|
is SessionUsage.Unavailable -> UsageNote("5-hour usage unknown -- ${state.why}")
|
||||||
style = MaterialTheme.typography.labelSmall,
|
SessionUsage.Waiting -> UsageNote("5-hour usage: checking")
|
||||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
is SessionUsage.Known -> {
|
||||||
)
|
val window = state.windows.firstOrNull { it.kind == "session" }
|
||||||
FiveHourUsage.Waiting ->
|
if (window == null) {
|
||||||
Text(
|
UsageNote("5-hour usage unknown -- no five-hour window reported")
|
||||||
"5-hour usage: checking",
|
} else {
|
||||||
style = MaterialTheme.typography.labelSmall,
|
|
||||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
|
||||||
)
|
|
||||||
is FiveHourUsage.Known -> {
|
|
||||||
LinearProgressIndicator(
|
LinearProgressIndicator(
|
||||||
progress = { (state.percent / 100.0).toFloat().coerceIn(0f, 1f) },
|
progress = { (window.percent / 100.0).toFloat().coerceIn(0f, 1f) },
|
||||||
color = usageColor,
|
// 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),
|
modifier = Modifier.weight(1f),
|
||||||
)
|
)
|
||||||
Text(
|
Text(
|
||||||
fiveHourLabel(state, now),
|
fiveHourLabel(window, now),
|
||||||
style = MaterialTheme.typography.labelSmall,
|
style = MaterialTheme.typography.labelSmall,
|
||||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
modifier = Modifier.padding(start = 8.dp),
|
modifier = Modifier.padding(start = 8.dp),
|
||||||
@@ -128,6 +166,17 @@ fun SessionUsageBar(settings: ServerSettings, setup: String, modifier: Modifier
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 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,
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -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.
|
||||||
*
|
*
|
||||||
|
|||||||
+40
-26
@@ -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.
Executable
+61
@@ -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"
|
||||||
Reference in new issue
Block a user