Fix explorer back and session usage selection
This commit is contained in:
1 parent
8c88a7e991
commit
14dd520719
10 files changed
+222
-57
No files matched your search
+3
-3
@@ -215,9 +215,9 @@ be lost. The explorer draws over the session, which deliberately has no
|
|||||||
the session stays composed under it: its event stream keeps flowing, its
|
the session stays composed under it: its event stream keeps flowing, its
|
||||||
scroll position and draft stay where they were, and returning from a file
|
scroll position and draft stay where they were, and returning from a file
|
||||||
costs nothing. Back — the button and the platform gesture — clears `files`
|
costs nothing. Back — the button and the platform gesture — clears `files`
|
||||||
when set and goes to the list otherwise. Inside the explorer the same back
|
and returns to the session from anywhere in the explorer. Directory navigation
|
||||||
steps one level: editor → viewer (with the unsaved question) → listing →
|
stays in the listing: `..` is an explicit row rather than a hidden second
|
||||||
parent directory, and only from the starting directory does it close. "Back
|
meaning for Back. An editor with unsaved changes asks before closing. "Back
|
||||||
returns; it does not exit."
|
returns; it does not exit."
|
||||||
|
|
||||||
Rejected: a `Screen.Files` beside `Screen.Session`. Every route back from a
|
Rejected: a `Screen.Files` beside `Screen.Session`. Every route back from a
|
||||||
|
|||||||
@@ -608,6 +608,14 @@ session now names its meter (`usageProvider`, from
|
|||||||
two lists cannot disagree) and `GET /usage` is matched on machine *and*
|
two lists cannot disagree) and `GET /usage` is matched on machine *and*
|
||||||
provider. `None` is a session that meters nothing, and the phone draws
|
provider. `None` is a session that meters nothing, and the phone draws
|
||||||
nothing at all for it — not a zero, and not "unknown".
|
nothing at all for it — not a zero, and not "unknown".
|
||||||
|
The session's usage dialog applies the same machine-and-provider match and
|
||||||
|
shows every billing pool for that provider; it does not turn opening one
|
||||||
|
session into a comparison with the other providers on that machine.
|
||||||
|
For a provider with several pools, the compact bar selects the pool named by
|
||||||
|
the session's model (including Luna's `gpt-reserve` name), falling back to the
|
||||||
|
provider's generic pool, and shows the shortest cycle that pool actually
|
||||||
|
reports. A weekly-only pool therefore gets a weekly bar; it is never relabeled
|
||||||
|
as five-hour merely because the provider called it `primary`.
|
||||||
|
|
||||||
`DriverKind::Echo` names a meter of its own that exists only when a test has
|
`DriverKind::Echo` names a meter of its own that exists only when a test has
|
||||||
asked for one: `/usage` in an echo session sets an invented answer
|
asked for one: `/usage` in an echo session sets an invented answer
|
||||||
|
|||||||
@@ -884,6 +884,8 @@ data class UsageWindow(
|
|||||||
val kind: String,
|
val kind: String,
|
||||||
val label: String,
|
val label: String,
|
||||||
val percent: Double,
|
val percent: Double,
|
||||||
|
/** Length of this cycle, when the provider reported it. */
|
||||||
|
val durationMinutes: Long?,
|
||||||
val resetsAt: String?,
|
val resetsAt: String?,
|
||||||
val active: Boolean,
|
val active: Boolean,
|
||||||
)
|
)
|
||||||
@@ -931,6 +933,10 @@ fun fetchUsage(settings: ServerSettings): List<UsageSnapshot> =
|
|||||||
kind = window.optString("kind").ifEmpty { "unknown" },
|
kind = window.optString("kind").ifEmpty { "unknown" },
|
||||||
label = window.getString("label"),
|
label = window.getString("label"),
|
||||||
percent = window.getDouble("percent"),
|
percent = window.getDouble("percent"),
|
||||||
|
durationMinutes =
|
||||||
|
if (window.has("durationMinutes")) {
|
||||||
|
window.getLong("durationMinutes")
|
||||||
|
} else null,
|
||||||
resetsAt = window.optString("resetsAt").ifEmpty { null },
|
resetsAt = window.optString("resetsAt").ifEmpty { null },
|
||||||
active = window.getBoolean("active"),
|
active = window.getBoolean("active"),
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -61,9 +61,8 @@ private sealed class Spot(val path: String) {
|
|||||||
* The files on the machine a session runs on: browse them, read one, change one.
|
* The files on the machine a session runs on: browse them, read one, change one.
|
||||||
*
|
*
|
||||||
* Drawn **over** the session rather than instead of it (see [AppRoot]), so its event stream keeps
|
* Drawn **over** the session rather than instead of it (see [AppRoot]), so its event stream keeps
|
||||||
* flowing and coming back from a file costs nothing. Back steps one level inside here -- editor to
|
* flowing and coming back from a file costs nothing. Back always closes the explorer and returns to
|
||||||
* viewer, viewer to the directory it came from, directory to the one above -- and only closes from
|
* that session; directory navigation stays inside the listing, where its `..` row is explicit.
|
||||||
* where it opened.
|
|
||||||
*
|
*
|
||||||
* Every directory that has been visited is kept for as long as this is open; the refresh glyph is
|
* Every directory that has been visited is kept for as long as this is open; the refresh glyph is
|
||||||
* how one gets asked again on purpose, and creating something refetches the directory it was
|
* how one gets asked again on purpose, and creating something refetches the directory it was
|
||||||
@@ -72,7 +71,7 @@ private sealed class Spot(val path: String) {
|
|||||||
@Composable
|
@Composable
|
||||||
fun FilesScreen(settings: ServerSettings, target: FilesTarget, onClose: () -> Unit) {
|
fun FilesScreen(settings: ServerSettings, target: FilesTarget, onClose: () -> Unit) {
|
||||||
val scope = rememberCoroutineScope()
|
val scope = rememberCoroutineScope()
|
||||||
var stack by remember { mutableStateOf(listOf<Spot>(Spot.Dir(target.start))) }
|
var here by remember { mutableStateOf<Spot>(Spot.Dir(target.start)) }
|
||||||
val listings = remember { mutableStateMapOf<String, LoadState<Listing>>() }
|
val listings = remember { mutableStateMapOf<String, LoadState<Listing>>() }
|
||||||
var creating by remember { mutableStateOf(false) }
|
var creating by remember { mutableStateOf(false) }
|
||||||
// Edit mode and whether anything has been typed live here rather than in the pane below,
|
// Edit mode and whether anything has been typed live here rather than in the pane below,
|
||||||
@@ -82,25 +81,14 @@ fun FilesScreen(settings: ServerSettings, target: FilesTarget, onClose: () -> Un
|
|||||||
var dirty by remember { mutableStateOf(false) }
|
var dirty by remember { mutableStateOf(false) }
|
||||||
var askUnsaved by remember { mutableStateOf(false) }
|
var askUnsaved by remember { mutableStateOf(false) }
|
||||||
|
|
||||||
val here = stack.last()
|
|
||||||
|
|
||||||
fun go(spot: Spot) {
|
fun go(spot: Spot) {
|
||||||
editing = false
|
editing = false
|
||||||
dirty = false
|
dirty = false
|
||||||
stack = stack + spot
|
here = spot
|
||||||
}
|
}
|
||||||
|
|
||||||
fun back() {
|
fun back() {
|
||||||
when {
|
if (editing && dirty) askUnsaved = true else onClose()
|
||||||
editing && dirty -> askUnsaved = true
|
|
||||||
editing -> editing = false
|
|
||||||
stack.size > 1 -> {
|
|
||||||
stack = stack.dropLast(1)
|
|
||||||
editing = false
|
|
||||||
dirty = false
|
|
||||||
}
|
|
||||||
else -> onClose()
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
suspend fun load(path: String, again: Boolean) {
|
suspend fun load(path: String, again: Boolean) {
|
||||||
@@ -173,8 +161,7 @@ fun FilesScreen(settings: ServerSettings, target: FilesTarget, onClose: () -> Un
|
|||||||
UnsavedDialog(
|
UnsavedDialog(
|
||||||
onDiscard = {
|
onDiscard = {
|
||||||
askUnsaved = false
|
askUnsaved = false
|
||||||
editing = false
|
onClose()
|
||||||
dirty = false
|
|
||||||
},
|
},
|
||||||
onCancel = { askUnsaved = false },
|
onCancel = { askUnsaved = false },
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -1929,7 +1929,9 @@ fun SessionScreen(
|
|||||||
// is the screen's business rather than any row's. See [SessionImageViewer].
|
// is the screen's business rather than any row's. See [SessionImageViewer].
|
||||||
fullImage?.let { ref -> SessionImageViewer(settings, summary.id, ref) { fullImage = null } }
|
fullImage?.let { ref -> SessionImageViewer(settings, summary.id, ref) { fullImage = null } }
|
||||||
if (usageOpen) {
|
if (usageOpen) {
|
||||||
usageFeed?.let { UsageDialog(feed = it, onDismiss = { usageOpen = false }) }
|
usageFeed?.let {
|
||||||
|
UsageDialog(feed = it, session = summary, onDismiss = { usageOpen = false })
|
||||||
|
}
|
||||||
}
|
}
|
||||||
if (settingsOpen) {
|
if (settingsOpen) {
|
||||||
// Measured when the dialog opens rather than kept up to date: what the reader is being told
|
// Measured when the dialog opens rather than kept up to date: what the reader is being told
|
||||||
|
|||||||
@@ -83,7 +83,7 @@ class UsageFeed(
|
|||||||
return when (val state = snapshots) {
|
return when (val state = snapshots) {
|
||||||
is LoadState.Loading -> SessionUsage.Waiting
|
is LoadState.Loading -> SessionUsage.Waiting
|
||||||
is LoadState.Error -> SessionUsage.Unavailable(state.message)
|
is LoadState.Error -> SessionUsage.Unavailable(state.message)
|
||||||
is LoadState.Loaded -> usageFor(state.value, session.setup, provider)
|
is LoadState.Loaded -> usageFor(state.value, session.setup, provider, session.model)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -125,8 +125,8 @@ fun rememberUsageFeed(settings: ServerSettings): UsageFeed {
|
|||||||
*
|
*
|
||||||
* Worst rather than the five-hour one, because the button it colours opens *all* of them, and a
|
* 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.
|
* 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
|
* Taken over however many windows this session's provider returned rather than the three Claude
|
||||||
* passes windows it does not recognise straight through.
|
* sends today -- the backend passes windows it does not recognise straight through.
|
||||||
*
|
*
|
||||||
* Every state that is not a measurement takes the ordinary control colour instead. That is the
|
* 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
|
* point where colour stops being able to help: blue is the low end of a scale here, so colouring an
|
||||||
@@ -142,7 +142,7 @@ fun usageGlyphColour(usage: SessionUsage): Color =
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The five-hour window for the machine this session runs on, under the session's own header.
|
* The shortest usage window for the pool this session uses, under the session's own header.
|
||||||
*
|
*
|
||||||
* Here rather than only in the usage dialog 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
|
||||||
@@ -189,11 +189,11 @@ fun SessionUsageBar(usage: SessionUsage, modifier: Modifier = Modifier) {
|
|||||||
// Both handled above, before the row exists at all.
|
// Both handled above, before the row exists at all.
|
||||||
SessionUsage.NotMetered,
|
SessionUsage.NotMetered,
|
||||||
SessionUsage.Waiting -> Unit
|
SessionUsage.Waiting -> Unit
|
||||||
is SessionUsage.Unavailable -> UsageNote("5-hour usage unknown -- ${state.why}")
|
is SessionUsage.Unavailable -> UsageNote("Usage unknown -- ${state.why}")
|
||||||
is SessionUsage.Known -> {
|
is SessionUsage.Known -> {
|
||||||
val window = state.windows.firstOrNull { it.kind == "session" }
|
val window = shortestUsageWindow(state.windows)
|
||||||
if (window == null) {
|
if (window == null) {
|
||||||
UsageNote("5-hour usage unknown -- no five-hour window reported")
|
UsageNote("Usage unknown -- no window duration was reported")
|
||||||
} else {
|
} else {
|
||||||
LinearProgressIndicator(
|
LinearProgressIndicator(
|
||||||
progress = { (window.percent / 100.0).toFloat().coerceIn(0f, 1f) },
|
progress = { (window.percent / 100.0).toFloat().coerceIn(0f, 1f) },
|
||||||
@@ -204,7 +204,7 @@ fun SessionUsageBar(usage: SessionUsage, modifier: Modifier = Modifier) {
|
|||||||
modifier = Modifier.weight(1f),
|
modifier = Modifier.weight(1f),
|
||||||
)
|
)
|
||||||
Text(
|
Text(
|
||||||
fiveHourLabel(window, now),
|
usageWindowLabel(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),
|
||||||
@@ -234,11 +234,11 @@ private fun UsageNote(text: String) {
|
|||||||
* The window's end has two missing cases, worded differently on purpose; see [WindowEnd]. A window
|
* The window's end has two missing cases, worded differently on purpose; see [WindowEnd]. A window
|
||||||
* that is not running gets the percentage and nothing else.
|
* that is not running gets the percentage and nothing else.
|
||||||
*/
|
*/
|
||||||
private fun fiveHourLabel(window: UsageWindow, now: OffsetDateTime): String {
|
private fun usageWindowLabel(window: UsageWindow, now: OffsetDateTime): String {
|
||||||
val percent = "${window.percent.toInt()}%"
|
val percent = "${window.label} · ${window.percent.toInt()}%"
|
||||||
return when (val end = windowEnd(window.resetsAt, now)) {
|
return when (val end = windowEnd(window.resetsAt, now)) {
|
||||||
// Between blocks the five-hour window has no reset time, and saying so is a fact about
|
// Between blocks a window can have no reset time, and saying so is a fact about nothing:
|
||||||
// nothing: there is no window to run out. The percentage is the whole answer.
|
// there is no window to run out. The percentage is the whole answer.
|
||||||
WindowEnd.NotRunning -> percent
|
WindowEnd.NotRunning -> percent
|
||||||
WindowEnd.Unreadable -> "$percent · reset time unreadable"
|
WindowEnd.Unreadable -> "$percent · reset time unreadable"
|
||||||
is WindowEnd.Ends ->
|
is WindowEnd.Ends ->
|
||||||
@@ -260,14 +260,52 @@ private fun fiveHourLabel(window: UsageWindow, now: OffsetDateTime): String {
|
|||||||
* None of them may look like zero, and none may look like [SessionUsage.NotMetered], which is the
|
* 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.
|
* machine having no quota rather than the question going unanswered.
|
||||||
*/
|
*/
|
||||||
fun usageFor(snapshots: List<UsageSnapshot>, setup: String, provider: String): SessionUsage {
|
fun usageFor(
|
||||||
|
snapshots: List<UsageSnapshot>,
|
||||||
|
setup: String,
|
||||||
|
provider: String,
|
||||||
|
model: String?,
|
||||||
|
): SessionUsage {
|
||||||
// No snapshot at all means the backend never asked, which it only does where there is nothing
|
// No snapshot at all means the backend never asked, which it only does where there is nothing
|
||||||
// to ask about. That is a different answer from having asked and failed.
|
// to ask about. That is a different answer from having asked and failed.
|
||||||
|
val pools = usageSnapshotsFor(snapshots, setup, provider)
|
||||||
|
if (pools.isEmpty()) return SessionUsage.NotMetered
|
||||||
val mine =
|
val mine =
|
||||||
snapshots.firstOrNull { it.setup == setup && it.provider == provider }
|
usagePoolFor(pools, model)
|
||||||
?: return SessionUsage.NotMetered
|
?: return SessionUsage.Unavailable("couldn't tell which usage pool this session uses")
|
||||||
if (mine.state != "ok") {
|
if (mine.state != "ok") {
|
||||||
return SessionUsage.Unavailable(mine.detail ?: mine.state)
|
return SessionUsage.Unavailable(mine.detail ?: mine.state)
|
||||||
}
|
}
|
||||||
return SessionUsage.Known(mine.windows)
|
return SessionUsage.Known(mine.windows)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Every billing pool reported for one provider on one machine. */
|
||||||
|
internal fun usageSnapshotsFor(
|
||||||
|
snapshots: List<UsageSnapshot>,
|
||||||
|
setup: String,
|
||||||
|
provider: String?,
|
||||||
|
): List<UsageSnapshot> =
|
||||||
|
if (provider == null) emptyList()
|
||||||
|
else snapshots.filter { it.setup == setup && it.provider == provider }
|
||||||
|
|
||||||
|
/** The pool an explicit model names, or the provider's generic pool for every other model. */
|
||||||
|
internal fun usagePoolFor(pools: List<UsageSnapshot>, model: String?): UsageSnapshot? {
|
||||||
|
if (pools.size == 1) return pools.first()
|
||||||
|
val normalizedModel = model?.normalizedPoolName()
|
||||||
|
val named = normalizedModel?.let { wanted ->
|
||||||
|
pools.firstOrNull { pool ->
|
||||||
|
val name = pool.limitName?.normalizedPoolName()
|
||||||
|
name == wanted || (wanted.contains("luna") && name == "gptreserve")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return named ?: pools.firstOrNull { it.limitId == "codex" }
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The shortest cycle the selected pool actually reported. */
|
||||||
|
internal fun shortestUsageWindow(windows: List<UsageWindow>): UsageWindow? =
|
||||||
|
windows
|
||||||
|
.mapNotNull { window -> window.durationMinutes?.let { duration -> duration to window } }
|
||||||
|
.minByOrNull { it.first }
|
||||||
|
?.second
|
||||||
|
|
||||||
|
private fun String.normalizedPoolName(): String = lowercase().filter(Char::isLetterOrDigit)
|
||||||
@@ -30,7 +30,7 @@ import java.time.OffsetDateTime
|
|||||||
* own, so the only thing its Back could ever have meant was "put this away".
|
* own, so the only thing its Back could ever have meant was "put this away".
|
||||||
*/
|
*/
|
||||||
@Composable
|
@Composable
|
||||||
fun UsageDialog(feed: UsageFeed, onDismiss: () -> Unit) {
|
fun UsageDialog(feed: UsageFeed, session: SessionSummary, onDismiss: () -> Unit) {
|
||||||
// A plain Dialog rather than an AlertDialog, for the spacing alone. AlertDialog fixes the gaps
|
// A plain Dialog rather than an AlertDialog, for the spacing alone. AlertDialog fixes the gaps
|
||||||
// between its title, content and buttons at sizes meant for a sentence of prose and a decision;
|
// between its title, content and buttons at sizes meant for a sentence of prose and a decision;
|
||||||
// this is a dense read-out, and those gaps left a band of empty dialog above Close that was
|
// this is a dense read-out, and those gaps left a band of empty dialog above Close that was
|
||||||
@@ -45,10 +45,6 @@ fun UsageDialog(feed: UsageFeed, onDismiss: () -> Unit) {
|
|||||||
verticalAlignment = Alignment.CenterVertically,
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
modifier = Modifier.fillMaxWidth(),
|
modifier = Modifier.fillMaxWidth(),
|
||||||
) {
|
) {
|
||||||
// Deliberately not subtitled with the provider this was opened from. These
|
|
||||||
// numbers belong to an account on a particular machine -- naming the session's
|
|
||||||
// provider here made an echo session's screen read "echo" above a line reading
|
|
||||||
// "claude". Each machine names itself and the service it came from.
|
|
||||||
Text(
|
Text(
|
||||||
"Usage",
|
"Usage",
|
||||||
style = MaterialTheme.typography.headlineSmall,
|
style = MaterialTheme.typography.headlineSmall,
|
||||||
@@ -65,10 +61,24 @@ fun UsageDialog(feed: UsageFeed, onDismiss: () -> Unit) {
|
|||||||
}
|
}
|
||||||
Spacer(Modifier.height(8.dp))
|
Spacer(Modifier.height(8.dp))
|
||||||
// Scrolls rather than being trimmed: a machine can report any number of windows and
|
// Scrolls 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
|
// a provider can report several billing pools, and a dialog is the one place where
|
||||||
// out of room is silent. `fill = false` so a short read-out keeps a short dialog.
|
// running out of room is silent. `fill = false` so a short read-out keeps a short
|
||||||
|
// dialog.
|
||||||
Column(Modifier.weight(1f, fill = false).verticalScroll(rememberScrollState())) {
|
Column(Modifier.weight(1f, fill = false).verticalScroll(rememberScrollState())) {
|
||||||
UsageBody(feed.snapshots)
|
val state =
|
||||||
|
when (val snapshots = feed.snapshots) {
|
||||||
|
is LoadState.Loading -> LoadState.Loading
|
||||||
|
is LoadState.Error -> snapshots
|
||||||
|
is LoadState.Loaded ->
|
||||||
|
LoadState.Loaded(
|
||||||
|
usageSnapshotsFor(
|
||||||
|
snapshots.value,
|
||||||
|
session.setup,
|
||||||
|
session.usageProvider,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
UsageBody(state)
|
||||||
}
|
}
|
||||||
TextButton(onClick = onDismiss, modifier = Modifier.align(Alignment.End)) {
|
TextButton(onClick = onDismiss, modifier = Modifier.align(Alignment.End)) {
|
||||||
Text("Close")
|
Text("Close")
|
||||||
@@ -87,18 +97,18 @@ private fun UsageBody(state: LoadState<List<UsageSnapshot>>) {
|
|||||||
is LoadState.Error -> Text(current.message, color = MaterialTheme.colorScheme.error)
|
is LoadState.Error -> Text(current.message, color = MaterialTheme.colorScheme.error)
|
||||||
is LoadState.Loaded ->
|
is LoadState.Loaded ->
|
||||||
if (current.value.isEmpty()) {
|
if (current.value.isEmpty()) {
|
||||||
// Not an error and not a blank screen: no machine offers a paid service, so
|
// Not an error and not a blank screen: this provider has no paid quota, so
|
||||||
// there is genuinely nothing to report and saying so is the answer.
|
// there is genuinely nothing to report and saying so is the answer.
|
||||||
Text(
|
Text(
|
||||||
"No machine here runs anything with usage limits.",
|
"This session's provider has no usage limits.",
|
||||||
style = MaterialTheme.typography.bodyMedium,
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
)
|
)
|
||||||
} else {
|
} else {
|
||||||
// No card around each machine. A card is a step up the surface ladder, and
|
// No card around each pool. A card is a step up the surface ladder, and inside
|
||||||
// inside a dialog -- itself a raised surface -- the step barely renders while
|
// a dialog -- itself a raised surface -- the step barely renders while costing
|
||||||
// costing 16dp on every side. What separates one machine from the next is the
|
// 16dp on every side. What separates one pool from the next is the line naming
|
||||||
// line naming it.
|
// it.
|
||||||
current.value.forEachIndexed { index, snapshot ->
|
current.value.forEachIndexed { index, snapshot ->
|
||||||
if (index > 0) {
|
if (index > 0) {
|
||||||
Spacer(Modifier.height(20.dp))
|
Spacer(Modifier.height(20.dp))
|
||||||
|
|||||||
@@ -0,0 +1,90 @@
|
|||||||
|
package com.example.aiapp
|
||||||
|
|
||||||
|
import kotlin.test.Test
|
||||||
|
import kotlin.test.assertEquals
|
||||||
|
|
||||||
|
class SessionUsageTest {
|
||||||
|
@Test
|
||||||
|
fun `usage snapshots stay with the session's machine and provider`() {
|
||||||
|
val claude = snapshot("machine", "claude", null)
|
||||||
|
val codex = snapshot("machine", "codex", "codex")
|
||||||
|
val reserve = snapshot("machine", "codex", "gpt-reserve")
|
||||||
|
val elsewhere = snapshot("other", "codex", "codex")
|
||||||
|
|
||||||
|
assertEquals(
|
||||||
|
listOf(codex, reserve),
|
||||||
|
usageSnapshotsFor(
|
||||||
|
listOf(claude, codex, reserve, elsewhere),
|
||||||
|
setup = "machine",
|
||||||
|
provider = "codex",
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `a session without a meter has no usage snapshots`() {
|
||||||
|
assertEquals(
|
||||||
|
emptyList(),
|
||||||
|
usageSnapshotsFor(
|
||||||
|
listOf(snapshot("machine", "claude", null)),
|
||||||
|
setup = "machine",
|
||||||
|
provider = null,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `the model selects its named pool and other models use the generic pool`() {
|
||||||
|
val generic = snapshot("machine", "codex", "codex")
|
||||||
|
val spark =
|
||||||
|
snapshot(
|
||||||
|
"machine",
|
||||||
|
"codex",
|
||||||
|
"codex_bengalfox",
|
||||||
|
limitName = "GPT-5.3-Codex-Spark",
|
||||||
|
)
|
||||||
|
val reserve =
|
||||||
|
snapshot("machine", "codex", "base_model_inference", limitName = "gpt-reserve")
|
||||||
|
val pools = listOf(generic, spark, reserve)
|
||||||
|
|
||||||
|
assertEquals(spark, usagePoolFor(pools, "gpt-5.3-codex-spark"))
|
||||||
|
assertEquals(reserve, usagePoolFor(pools, "gpt-5.6-luna"))
|
||||||
|
assertEquals(generic, usagePoolFor(pools, "gpt-6-astra"))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `the bar uses the shortest reported cycle`() {
|
||||||
|
val weekly = window("Weekly", 10_080)
|
||||||
|
val hourly = window("5-hour window", 300)
|
||||||
|
|
||||||
|
assertEquals(hourly, shortestUsageWindow(listOf(weekly, hourly)))
|
||||||
|
assertEquals(null, shortestUsageWindow(listOf(window("unknown", null))))
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun snapshot(
|
||||||
|
setup: String,
|
||||||
|
provider: String,
|
||||||
|
limitId: String?,
|
||||||
|
limitName: String? = null,
|
||||||
|
) =
|
||||||
|
UsageSnapshot(
|
||||||
|
provider = provider,
|
||||||
|
setup = setup,
|
||||||
|
setupName = setup,
|
||||||
|
limitId = limitId,
|
||||||
|
limitName = limitName,
|
||||||
|
state = "ok",
|
||||||
|
detail = null,
|
||||||
|
windows = emptyList(),
|
||||||
|
)
|
||||||
|
|
||||||
|
private fun window(label: String, durationMinutes: Long?) =
|
||||||
|
UsageWindow(
|
||||||
|
kind = "test",
|
||||||
|
label = label,
|
||||||
|
percent = 12.0,
|
||||||
|
durationMinutes = durationMinutes,
|
||||||
|
resetsAt = null,
|
||||||
|
active = false,
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -282,6 +282,7 @@ mod tests {
|
|||||||
kind: "session".to_string(),
|
kind: "session".to_string(),
|
||||||
label: "5-hour window".to_string(),
|
label: "5-hour window".to_string(),
|
||||||
percent,
|
percent,
|
||||||
|
duration_minutes: Some(300),
|
||||||
resets_at: resets_at.map(str::to_string),
|
resets_at: resets_at.map(str::to_string),
|
||||||
active: true,
|
active: true,
|
||||||
}
|
}
|
||||||
|
|||||||
+28
-5
@@ -60,6 +60,9 @@ pub struct UsageWindow {
|
|||||||
pub label: String,
|
pub label: String,
|
||||||
/// 0-100.
|
/// 0-100.
|
||||||
pub percent: f64,
|
pub percent: f64,
|
||||||
|
/// Length of the cycle when the provider reports one.
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
pub duration_minutes: Option<u64>,
|
||||||
/// ISO-8601, as the API sends it; absent for windows that never reset.
|
/// ISO-8601, as the API sends it; absent for windows that never reset.
|
||||||
#[serde(skip_serializing_if = "Option::is_none")]
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
pub resets_at: Option<String>,
|
pub resets_at: Option<String>,
|
||||||
@@ -361,11 +364,18 @@ fn parse_codex_windows(limits: &Value) -> Vec<UsageWindow> {
|
|||||||
None => "Secondary window".to_string(),
|
None => "Secondary window".to_string(),
|
||||||
};
|
};
|
||||||
Some(UsageWindow {
|
Some(UsageWindow {
|
||||||
// Common semantic names: the phone's compact bar asks for
|
// Keep the common semantic names where the duration establishes them. `primary`
|
||||||
// `session`, and auto-resume treats all windows alike.
|
// is only the protocol's position and can itself be a weekly window.
|
||||||
kind: if primary { "session" } else { "weekly_all" }.to_string(),
|
kind: match minutes {
|
||||||
|
Some(300) => "session",
|
||||||
|
Some(10_080) => "weekly_all",
|
||||||
|
_ if primary => "primary",
|
||||||
|
_ => "secondary",
|
||||||
|
}
|
||||||
|
.to_string(),
|
||||||
label,
|
label,
|
||||||
percent: window.get("usedPercent")?.as_f64()?,
|
percent: window.get("usedPercent")?.as_f64()?,
|
||||||
|
duration_minutes: minutes,
|
||||||
resets_at: window
|
resets_at: window
|
||||||
.get("resetsAt")
|
.get("resetsAt")
|
||||||
.and_then(Value::as_i64)
|
.and_then(Value::as_i64)
|
||||||
@@ -374,7 +384,8 @@ fn parse_codex_windows(limits: &Value) -> Vec<UsageWindow> {
|
|||||||
at.format(&time::format_description::well_known::Rfc3339)
|
at.format(&time::format_description::well_known::Rfc3339)
|
||||||
.ok()
|
.ok()
|
||||||
}),
|
}),
|
||||||
active: primary,
|
// Unlike Claude's response, Codex does not say which window is binding.
|
||||||
|
active: false,
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
.collect()
|
.collect()
|
||||||
@@ -528,6 +539,11 @@ fn parse_windows(body: &Value) -> Vec<UsageWindow> {
|
|||||||
kind: kind.to_string(),
|
kind: kind.to_string(),
|
||||||
label,
|
label,
|
||||||
percent,
|
percent,
|
||||||
|
duration_minutes: match kind {
|
||||||
|
"session" => Some(300),
|
||||||
|
"weekly_all" | "weekly_scoped" => Some(10_080),
|
||||||
|
_ => None,
|
||||||
|
},
|
||||||
resets_at: limit
|
resets_at: limit
|
||||||
.get("resets_at")
|
.get("resets_at")
|
||||||
.and_then(Value::as_str)
|
.and_then(Value::as_str)
|
||||||
@@ -662,6 +678,7 @@ fn fixture_windows(percent: f64, reset: Option<&str>) -> Vec<UsageWindow> {
|
|||||||
kind: "session".to_string(),
|
kind: "session".to_string(),
|
||||||
label: "5-hour window".to_string(),
|
label: "5-hour window".to_string(),
|
||||||
percent,
|
percent,
|
||||||
|
duration_minutes: Some(300),
|
||||||
resets_at: resets_at.clone(),
|
resets_at: resets_at.clone(),
|
||||||
active: true,
|
active: true,
|
||||||
},
|
},
|
||||||
@@ -669,6 +686,7 @@ fn fixture_windows(percent: f64, reset: Option<&str>) -> Vec<UsageWindow> {
|
|||||||
kind: "weekly_all".to_string(),
|
kind: "weekly_all".to_string(),
|
||||||
label: "Weekly (all models)".to_string(),
|
label: "Weekly (all models)".to_string(),
|
||||||
percent: percent / 2.0,
|
percent: percent / 2.0,
|
||||||
|
duration_minutes: Some(10_080),
|
||||||
resets_at: resets_at.as_ref().map(|_| reset_in(FIXTURE_MINUTES * 40)),
|
resets_at: resets_at.as_ref().map(|_| reset_in(FIXTURE_MINUTES * 40)),
|
||||||
active: false,
|
active: false,
|
||||||
},
|
},
|
||||||
@@ -676,6 +694,7 @@ fn fixture_windows(percent: f64, reset: Option<&str>) -> Vec<UsageWindow> {
|
|||||||
kind: "weekly_scoped".to_string(),
|
kind: "weekly_scoped".to_string(),
|
||||||
label: "Weekly (Echo)".to_string(),
|
label: "Weekly (Echo)".to_string(),
|
||||||
percent: percent / 4.0,
|
percent: percent / 4.0,
|
||||||
|
duration_minutes: Some(10_080),
|
||||||
resets_at: resets_at.as_ref().map(|_| reset_in(FIXTURE_MINUTES * 40)),
|
resets_at: resets_at.as_ref().map(|_| reset_in(FIXTURE_MINUTES * 40)),
|
||||||
active: false,
|
active: false,
|
||||||
},
|
},
|
||||||
@@ -1107,9 +1126,11 @@ mod tests {
|
|||||||
assert_eq!(windows.len(), 2);
|
assert_eq!(windows.len(), 2);
|
||||||
assert_eq!(windows[0].label, "5-hour window");
|
assert_eq!(windows[0].label, "5-hour window");
|
||||||
assert_eq!(windows[0].percent, 10.0);
|
assert_eq!(windows[0].percent, 10.0);
|
||||||
assert!(windows[0].active);
|
assert_eq!(windows[0].duration_minutes, Some(300));
|
||||||
|
assert!(!windows[0].active);
|
||||||
assert_eq!(windows[1].kind, "weekly_all");
|
assert_eq!(windows[1].kind, "weekly_all");
|
||||||
assert_eq!(windows[1].label, "Weekly");
|
assert_eq!(windows[1].label, "Weekly");
|
||||||
|
assert_eq!(windows[1].duration_minutes, Some(10_080));
|
||||||
assert!(
|
assert!(
|
||||||
windows[1]
|
windows[1]
|
||||||
.resets_at
|
.resets_at
|
||||||
@@ -1141,6 +1162,8 @@ mod tests {
|
|||||||
assert_eq!(snapshot.limit_id.as_deref(), Some("base_model_inference"));
|
assert_eq!(snapshot.limit_id.as_deref(), Some("base_model_inference"));
|
||||||
assert_eq!(snapshot.limit_name.as_deref(), Some("gpt-reserve"));
|
assert_eq!(snapshot.limit_name.as_deref(), Some("gpt-reserve"));
|
||||||
assert_eq!(snapshot.windows.len(), 1);
|
assert_eq!(snapshot.windows.len(), 1);
|
||||||
|
assert_eq!(snapshot.windows[0].kind, "weekly_all");
|
||||||
assert_eq!(snapshot.windows[0].label, "Weekly");
|
assert_eq!(snapshot.windows[0].label, "Weekly");
|
||||||
|
assert_eq!(snapshot.windows[0].duration_minutes, Some(10_080));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Reference in new issue
Block a user