Fix explorer back and session usage selection

This commit is contained in:
iris committed 2026-09-09 13:01:27 -04:00
1 parent 8c88a7e991
commit 14dd520719
10 files changed
+222 -57

No files matched your search

@@ -884,6 +884,8 @@ data class UsageWindow(
val kind: String,
val label: String,
val percent: Double,
/** Length of this cycle, when the provider reported it. */
val durationMinutes: Long?,
val resetsAt: String?,
val active: Boolean,
)
@@ -931,6 +933,10 @@ fun fetchUsage(settings: ServerSettings): List<UsageSnapshot> =
kind = window.optString("kind").ifEmpty { "unknown" },
label = window.getString("label"),
percent = window.getDouble("percent"),
durationMinutes =
if (window.has("durationMinutes")) {
window.getLong("durationMinutes")
} else null,
resetsAt = window.optString("resetsAt").ifEmpty { null },
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.
*
* 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
* viewer, viewer to the directory it came from, directory to the one above -- and only closes from
* where it opened.
* flowing and coming back from a file costs nothing. Back always closes the explorer and returns to
* that session; directory navigation stays inside the listing, where its `..` row is explicit.
*
* 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
@@ -72,7 +71,7 @@ private sealed class Spot(val path: String) {
@Composable
fun FilesScreen(settings: ServerSettings, target: FilesTarget, onClose: () -> Unit) {
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>>() }
var creating by remember { mutableStateOf(false) }
// 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 askUnsaved by remember { mutableStateOf(false) }
val here = stack.last()
fun go(spot: Spot) {
editing = false
dirty = false
stack = stack + spot
here = spot
}
fun back() {
when {
editing && dirty -> askUnsaved = true
editing -> editing = false
stack.size > 1 -> {
stack = stack.dropLast(1)
editing = false
dirty = false
}
else -> onClose()
}
if (editing && dirty) askUnsaved = true else onClose()
}
suspend fun load(path: String, again: Boolean) {
@@ -173,8 +161,7 @@ fun FilesScreen(settings: ServerSettings, target: FilesTarget, onClose: () -> Un
UnsavedDialog(
onDiscard = {
askUnsaved = false
editing = false
dirty = false
onClose()
},
onCancel = { askUnsaved = false },
)
@@ -1929,7 +1929,9 @@ fun SessionScreen(
// is the screen's business rather than any row's. See [SessionImageViewer].
fullImage?.let { ref -> SessionImageViewer(settings, summary.id, ref) { fullImage = null } }
if (usageOpen) {
usageFeed?.let { UsageDialog(feed = it, onDismiss = { usageOpen = false }) }
usageFeed?.let {
UsageDialog(feed = it, session = summary, onDismiss = { usageOpen = false })
}
}
if (settingsOpen) {
// 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) {
is LoadState.Loading -> SessionUsage.Waiting
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
* 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
* passes windows it does not recognise straight through.
* Taken over however many windows this session's provider returned rather than the three Claude
* 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
* 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
* 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.
SessionUsage.NotMetered,
SessionUsage.Waiting -> Unit
is SessionUsage.Unavailable -> UsageNote("5-hour usage unknown -- ${state.why}")
is SessionUsage.Unavailable -> UsageNote("Usage unknown -- ${state.why}")
is SessionUsage.Known -> {
val window = state.windows.firstOrNull { it.kind == "session" }
val window = shortestUsageWindow(state.windows)
if (window == null) {
UsageNote("5-hour usage unknown -- no five-hour window reported")
UsageNote("Usage unknown -- no window duration was reported")
} else {
LinearProgressIndicator(
progress = { (window.percent / 100.0).toFloat().coerceIn(0f, 1f) },
@@ -204,7 +204,7 @@ fun SessionUsageBar(usage: SessionUsage, modifier: Modifier = Modifier) {
modifier = Modifier.weight(1f),
)
Text(
fiveHourLabel(window, now),
usageWindowLabel(window, now),
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
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
* that is not running gets the percentage and nothing else.
*/
private fun fiveHourLabel(window: UsageWindow, now: OffsetDateTime): String {
val percent = "${window.percent.toInt()}%"
private fun usageWindowLabel(window: UsageWindow, now: OffsetDateTime): String {
val percent = "${window.label} · ${window.percent.toInt()}%"
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
// nothing: there is no window to run out. The percentage is the whole answer.
// Between blocks a window can have no reset time, and saying so is a fact about nothing:
// there is no window to run out. The percentage is the whole answer.
WindowEnd.NotRunning -> percent
WindowEnd.Unreadable -> "$percent · reset time unreadable"
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
* 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
// 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 =
snapshots.firstOrNull { it.setup == setup && it.provider == provider }
?: return SessionUsage.NotMetered
usagePoolFor(pools, model)
?: return SessionUsage.Unavailable("couldn't tell which usage pool this session uses")
if (mine.state != "ok") {
return SessionUsage.Unavailable(mine.detail ?: mine.state)
}
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".
*/
@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
// 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
@@ -45,10 +45,6 @@ fun UsageDialog(feed: UsageFeed, onDismiss: () -> Unit) {
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.fillMaxWidth(),
) {
// Deliberately not subtitled with the provider this was opened from. These
// numbers belong to an account on a particular machine -- 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(
"Usage",
style = MaterialTheme.typography.headlineSmall,
@@ -65,10 +61,24 @@ fun UsageDialog(feed: UsageFeed, onDismiss: () -> Unit) {
}
Spacer(Modifier.height(8.dp))
// 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
// out of room is silent. `fill = false` so a short read-out keeps a short dialog.
// a provider can report several billing pools, and a dialog is the one place where
// 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())) {
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)) {
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.Loaded ->
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.
Text(
"No machine here runs anything with usage limits.",
"This session's provider has no usage limits.",
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
} else {
// No card around each machine. A card is a step up the surface ladder, and
// inside a dialog -- itself a raised surface -- the step barely renders while
// costing 16dp on every side. What separates one machine from the next is the
// line naming it.
// No card around each pool. A card is a step up the surface ladder, and inside
// a dialog -- itself a raised surface -- the step barely renders while costing
// 16dp on every side. What separates one pool from the next is the line naming
// it.
current.value.forEachIndexed { index, snapshot ->
if (index > 0) {
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,
)
}