From 14dd520719091b486d6d800309810b2854ce2dbd Mon Sep 17 00:00:00 2001 From: iris <2+iris@noreply.localhost> Date: Wed, 9 Sep 2026 13:01:27 -0400 Subject: [PATCH] Fix explorer back and session usage selection --- EXPLORER.md | 6 +- PLAN.md | 8 ++ .../src/main/kotlin/com/example/aiapp/Api.kt | 6 ++ .../kotlin/com/example/aiapp/FilesScreen.kt | 25 ++---- .../kotlin/com/example/aiapp/SessionScreen.kt | 4 +- .../com/example/aiapp/SessionUsageBar.kt | 68 ++++++++++---- .../kotlin/com/example/aiapp/UsageDialog.kt | 38 +++++--- .../com/example/aiapp/SessionUsageTest.kt | 90 +++++++++++++++++++ server/src/resume.rs | 1 + server/src/usage.rs | 33 +++++-- 10 files changed, 222 insertions(+), 57 deletions(-) create mode 100644 app/androidApp/src/test/kotlin/com/example/aiapp/SessionUsageTest.kt diff --git a/EXPLORER.md b/EXPLORER.md index 2732388..612079c 100644 --- a/EXPLORER.md +++ b/EXPLORER.md @@ -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 scroll position and draft stay where they were, and returning from a file 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 -steps one level: editor → viewer (with the unsaved question) → listing → -parent directory, and only from the starting directory does it close. "Back +and returns to the session from anywhere in the explorer. Directory navigation +stays in the listing: `..` is an explicit row rather than a hidden second +meaning for Back. An editor with unsaved changes asks before closing. "Back returns; it does not exit." Rejected: a `Screen.Files` beside `Screen.Session`. Every route back from a diff --git a/PLAN.md b/PLAN.md index 8289166..1a76bb3 100644 --- a/PLAN.md +++ b/PLAN.md @@ -608,6 +608,14 @@ session now names its meter (`usageProvider`, from two lists cannot disagree) and `GET /usage` is matched on machine *and* provider. `None` is a session that meters nothing, and the phone draws 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 asked for one: `/usage` in an echo session sets an invented answer diff --git a/app/androidApp/src/main/kotlin/com/example/aiapp/Api.kt b/app/androidApp/src/main/kotlin/com/example/aiapp/Api.kt index e33ef65..4de1a61 100644 --- a/app/androidApp/src/main/kotlin/com/example/aiapp/Api.kt +++ b/app/androidApp/src/main/kotlin/com/example/aiapp/Api.kt @@ -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 = 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"), ) diff --git a/app/androidApp/src/main/kotlin/com/example/aiapp/FilesScreen.kt b/app/androidApp/src/main/kotlin/com/example/aiapp/FilesScreen.kt index c4bfe25..3937eb5 100644 --- a/app/androidApp/src/main/kotlin/com/example/aiapp/FilesScreen.kt +++ b/app/androidApp/src/main/kotlin/com/example/aiapp/FilesScreen.kt @@ -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.Dir(target.start))) } + var here by remember { mutableStateOf(Spot.Dir(target.start)) } val listings = remember { mutableStateMapOf>() } 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 }, ) diff --git a/app/androidApp/src/main/kotlin/com/example/aiapp/SessionScreen.kt b/app/androidApp/src/main/kotlin/com/example/aiapp/SessionScreen.kt index fbc6dea..6e3896f 100644 --- a/app/androidApp/src/main/kotlin/com/example/aiapp/SessionScreen.kt +++ b/app/androidApp/src/main/kotlin/com/example/aiapp/SessionScreen.kt @@ -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 diff --git a/app/androidApp/src/main/kotlin/com/example/aiapp/SessionUsageBar.kt b/app/androidApp/src/main/kotlin/com/example/aiapp/SessionUsageBar.kt index fcd0814..8622c42 100644 --- a/app/androidApp/src/main/kotlin/com/example/aiapp/SessionUsageBar.kt +++ b/app/androidApp/src/main/kotlin/com/example/aiapp/SessionUsageBar.kt @@ -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, setup: String, provider: String): SessionUsage { +fun usageFor( + snapshots: List, + 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, + setup: String, + provider: String?, +): List = + 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, 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? = + windows + .mapNotNull { window -> window.durationMinutes?.let { duration -> duration to window } } + .minByOrNull { it.first } + ?.second + +private fun String.normalizedPoolName(): String = lowercase().filter(Char::isLetterOrDigit) diff --git a/app/androidApp/src/main/kotlin/com/example/aiapp/UsageDialog.kt b/app/androidApp/src/main/kotlin/com/example/aiapp/UsageDialog.kt index 88fab49..dd24aa0 100644 --- a/app/androidApp/src/main/kotlin/com/example/aiapp/UsageDialog.kt +++ b/app/androidApp/src/main/kotlin/com/example/aiapp/UsageDialog.kt @@ -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>) { 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)) diff --git a/app/androidApp/src/test/kotlin/com/example/aiapp/SessionUsageTest.kt b/app/androidApp/src/test/kotlin/com/example/aiapp/SessionUsageTest.kt new file mode 100644 index 0000000..4f1c5e5 --- /dev/null +++ b/app/androidApp/src/test/kotlin/com/example/aiapp/SessionUsageTest.kt @@ -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, + ) +} diff --git a/server/src/resume.rs b/server/src/resume.rs index 42947f7..6d3bc23 100644 --- a/server/src/resume.rs +++ b/server/src/resume.rs @@ -282,6 +282,7 @@ mod tests { kind: "session".to_string(), label: "5-hour window".to_string(), percent, + duration_minutes: Some(300), resets_at: resets_at.map(str::to_string), active: true, } diff --git a/server/src/usage.rs b/server/src/usage.rs index 1f3482a..de36dec 100644 --- a/server/src/usage.rs +++ b/server/src/usage.rs @@ -60,6 +60,9 @@ pub struct UsageWindow { pub label: String, /// 0-100. pub percent: f64, + /// Length of the cycle when the provider reports one. + #[serde(skip_serializing_if = "Option::is_none")] + pub duration_minutes: Option, /// ISO-8601, as the API sends it; absent for windows that never reset. #[serde(skip_serializing_if = "Option::is_none")] pub resets_at: Option, @@ -361,11 +364,18 @@ fn parse_codex_windows(limits: &Value) -> Vec { None => "Secondary window".to_string(), }; Some(UsageWindow { - // Common semantic names: the phone's compact bar asks for - // `session`, and auto-resume treats all windows alike. - kind: if primary { "session" } else { "weekly_all" }.to_string(), + // Keep the common semantic names where the duration establishes them. `primary` + // is only the protocol's position and can itself be a weekly window. + kind: match minutes { + Some(300) => "session", + Some(10_080) => "weekly_all", + _ if primary => "primary", + _ => "secondary", + } + .to_string(), label, percent: window.get("usedPercent")?.as_f64()?, + duration_minutes: minutes, resets_at: window .get("resetsAt") .and_then(Value::as_i64) @@ -374,7 +384,8 @@ fn parse_codex_windows(limits: &Value) -> Vec { at.format(&time::format_description::well_known::Rfc3339) .ok() }), - active: primary, + // Unlike Claude's response, Codex does not say which window is binding. + active: false, }) }) .collect() @@ -528,6 +539,11 @@ fn parse_windows(body: &Value) -> Vec { kind: kind.to_string(), label, percent, + duration_minutes: match kind { + "session" => Some(300), + "weekly_all" | "weekly_scoped" => Some(10_080), + _ => None, + }, resets_at: limit .get("resets_at") .and_then(Value::as_str) @@ -662,6 +678,7 @@ fn fixture_windows(percent: f64, reset: Option<&str>) -> Vec { kind: "session".to_string(), label: "5-hour window".to_string(), percent, + duration_minutes: Some(300), resets_at: resets_at.clone(), active: true, }, @@ -669,6 +686,7 @@ fn fixture_windows(percent: f64, reset: Option<&str>) -> Vec { kind: "weekly_all".to_string(), label: "Weekly (all models)".to_string(), percent: percent / 2.0, + duration_minutes: Some(10_080), resets_at: resets_at.as_ref().map(|_| reset_in(FIXTURE_MINUTES * 40)), active: false, }, @@ -676,6 +694,7 @@ fn fixture_windows(percent: f64, reset: Option<&str>) -> Vec { kind: "weekly_scoped".to_string(), label: "Weekly (Echo)".to_string(), percent: percent / 4.0, + duration_minutes: Some(10_080), resets_at: resets_at.as_ref().map(|_| reset_in(FIXTURE_MINUTES * 40)), active: false, }, @@ -1107,9 +1126,11 @@ mod tests { assert_eq!(windows.len(), 2); assert_eq!(windows[0].label, "5-hour window"); 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].label, "Weekly"); + assert_eq!(windows[1].duration_minutes, Some(10_080)); assert!( windows[1] .resets_at @@ -1141,6 +1162,8 @@ mod tests { assert_eq!(snapshot.limit_id.as_deref(), Some("base_model_inference")); assert_eq!(snapshot.limit_name.as_deref(), Some("gpt-reserve")); 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].duration_minutes, Some(10_080)); } }