package com.example.aiapp import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.verticalScroll import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Surface import androidx.compose.material3.Text import androidx.compose.material3.TextButton import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf 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 import androidx.compose.ui.window.Dialog import java.time.OffsetDateTime /** * 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, so the only thing its Back could ever have meant was "put this away". */ @Composable fun UsageDialog( settings: ServerSettings, feed: UsageFeed, session: SessionSummary, onDismiss: () -> Unit, ) { var signingIn by remember { mutableStateOf(false) } val now = rememberUsageNow() // 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 // taller than a bar. Dialog(onDismissRequest = onDismiss) { Surface( shape = MaterialTheme.shapes.extraLarge, color = MaterialTheme.colorScheme.surfaceContainerHigh, ) { Column(Modifier.padding(horizontal = 24.dp, vertical = 16.dp)) { Row( verticalAlignment = Alignment.CenterVertically, modifier = Modifier.fillMaxWidth(), ) { Text( "Usage", style = MaterialTheme.typography.headlineSmall, modifier = Modifier.weight(1f), ) // A spinner in the button's place while the answer is on its way, since the // numbers under it stay put during a refresh -- without it, pressing refresh // over an unchanged read-out looks like a button that does nothing. if (feed.refreshing) { GlyphSpinner("Refreshing usage") } else { GlyphButton(REFRESH_GLYPH, "Refresh usage", feed.refresh) } } Spacer(Modifier.height(8.dp)) // Scrolls rather than being trimmed: a machine can report any number of windows and // 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())) { 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.machine, session.usageProvider, ) ) } UsageBody(state, now, onSignIn = { signingIn = true }) } TextButton(onClick = onDismiss, modifier = Modifier.align(Alignment.End)) { Text("Close") } } } } if (signingIn) { ProviderLoginDialog( settings = settings, machineId = session.machine, machineName = session.machineName, provider = session.provider, onDismiss = { signingIn = false }, onSignedIn = { signingIn = false feed.refresh() }, ) } } /** What came back, or why nothing did. Split out so the dialog above reads as its own shape. */ @Composable private fun UsageBody( state: LoadState>, now: OffsetDateTime, onSignIn: () -> Unit, ) { Column { when (val current = state) { is LoadState.Loading -> CircularProgressIndicator() 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: this provider has no paid quota, so // there is genuinely nothing to report and saying so is the answer. Text( "This session's provider has no usage limits.", style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onSurfaceVariant, ) } else { // 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)) } // Machine and service on one line: which account these numbers belong to is // decided by both together, and stacked as a heading over a subtitle they // read as a section of their own. Small and quiet, because the numbers // below are what somebody opened this to see. Text( usageSectionTitle(snapshot), style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant, ) SnapshotState(snapshot, onSignIn) snapshot.windows.forEachIndexed { windowIndex, window -> // Between the bars, not after the last one: a trailing gap here is what // put a band of empty dialog above the Close button. if (windowIndex > 0) { Spacer(Modifier.height(12.dp)) } WindowBar(window, now) } } } } } } private fun usageSectionTitle(snapshot: UsageSnapshot): String { val machine = snapshot.machineName.ifEmpty { snapshot.machine } val provider = snapshot.provider val pool = if (provider == "codex" && snapshot.limitId != "codex") { when (snapshot.limitName) { "gpt-reserve" -> "Luna Reserve" null -> snapshot.limitId else -> snapshot.limitName } } else null return listOfNotNull(machine, provider, pool).joinToString(" ยท ") } /** * Anything other than numbers: why this machine has none. * * The distinction the old single message could not draw. A machine nobody has logged in on is * working exactly as somebody set it up, so it reads as a plain statement -- marking it would be * the interface nagging about a decision already made. It still offers the direct sign-in action; * unreachable and provider failures are the states coloured as faults. */ @Composable private fun SnapshotState(snapshot: UsageSnapshot, onSignIn: () -> Unit) { when (snapshot.state) { "ok" -> {} "notLoggedIn", "loginRequired" -> { Text( snapshot.detail ?: "No Claude account on this machine.", style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onSurfaceVariant, ) TextButton(onClick = onSignIn) { Text("Sign in") } } "authenticating" -> Text( "Claude sign-in is in progress.", style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onSurfaceVariant, ) // Reached but refused, versus never reached at all: different things to go and do, so they // say different things rather than sharing one "unavailable". "failed" -> Text( snapshot.detail ?: "Couldn't read the limits from this machine.", style = MaterialTheme.typography.bodyMedium, color = failedColor, ) else -> Text( snapshot.detail ?: "Couldn't reach this machine.", style = MaterialTheme.typography.bodyMedium, color = failedColor, ) } } @Composable private fun WindowBar(window: UsageWindow, now: OffsetDateTime) { Column { Row(modifier = Modifier.fillMaxWidth()) { Text( window.label + if (window.active) " (active)" else "", style = MaterialTheme.typography.bodyMedium, modifier = Modifier.weight(1f), ) Text("${window.percent.toInt()}%", style = MaterialTheme.typography.bodyMedium) } Spacer(Modifier.height(4.dp)) UsageProgressIndicator(window, now, Modifier.fillMaxWidth()) resetLine(window, now)?.let { Spacer(Modifier.height(2.dp)) Text( it, style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant, ) } } } /** * "resets in 3h 12m" -- close enough for deciding whether to start a big task -- or nothing. * * Null for a window that is not running: there is no end to report. What this used to get wrong is * the other missing case, a timestamp that arrived and could not be read -- printed raw, so a parse * failure appeared as an ISO string in a sentence written for a person. Both are named in * [WindowEnd], and the session bar words them the same way. */ private fun resetLine(window: UsageWindow, now: OffsetDateTime): String? = when (val end = windowEnd(window.resetsAt, now)) { WindowEnd.NotRunning -> null WindowEnd.Unreadable -> "reset time unreadable" is WindowEnd.Ends -> if (end.until.isNegative) "resets soon" else "resets in ${formatSpan(end.until)}" }