diff --git a/apps/mobile/src/features/usage/UsageLimitsPooled.tsx b/apps/mobile/src/features/usage/UsageLimitsPooled.tsx index 1eec514d330fc..19e7e7e03c6e3 100644 --- a/apps/mobile/src/features/usage/UsageLimitsPooled.tsx +++ b/apps/mobile/src/features/usage/UsageLimitsPooled.tsx @@ -31,6 +31,7 @@ import { usageProviderKind, useProviderColors } from "./usageProviders"; const DRIVER_LABEL: Partial> = { codex: "Codex", claudeAgent: "Claude", + grok: "Grok", antigravity: "Antigravity", }; const PACE_LABEL = { ahead: "Ahead of pace", on: "On pace", under: "Under pace" } as const; @@ -38,8 +39,35 @@ const PACE_LABEL = { ahead: "Ahead of pace", on: "On pace", under: "Under pace" function accountName(account: LimitAccount) { if (account.displayName) return account.displayName; if (!account.email) return DRIVER_LABEL[account.driver] ?? String(account.driver); - const [local = "", domain = ""] = account.email.split("@"); - return `${local[0] ?? ""}${domain[0] ?? ""}`.toUpperCase() || "Account"; + return emailInitials(account.email) || "Account"; +} + +function emailInitials(email: string) { + const [local = "", domain = ""] = email.split("@"); + return `${local[0] ?? ""}${domain[0] ?? ""}`.toUpperCase(); +} + +function accountHue(email: string) { + let hash = 0; + for (let index = 0; index < email.length; index += 1) { + hash = (hash * 31 + email.charCodeAt(index)) | 0; + } + return Math.abs(hash) % 360; +} + +// Same hashed hue as the web account chip, so an account keeps its color across clients. +function AccountChip({ email }: { readonly email: string }) { + const hue = accountHue(email); + return ( + + + {emailInitials(email)} + + + ); } // Same gap-and-tick as the Limits rows: two clipped copies of the segment leave a see-through gap. @@ -141,6 +169,7 @@ function PoolWindowCard({ environmentIds, label, description, + wide, }: { readonly pool: LimitPoolWindow; readonly color: string; @@ -148,8 +177,15 @@ function PoolWindowCard({ readonly environmentIds: readonly string[] | null; readonly label?: string; readonly description?: string; + readonly wide: boolean; }) { const navigation = useNavigation(); + const [barWidth, setBarWidth] = useState(0); + // Each segment needs room for its account, percent and reset to label itself. + const segmentWidth = barWidth / Math.max(pool.columns.length, 1); + const inlineLabels = wide && segmentWidth >= 96; + const inlineReset = wide && segmentWidth >= 150; + const inlineName = wide && segmentWidth >= 220; const nextRefill = pool.resets.find((reset) => reset.restoresPercent > 0); const openAccount = (account: LimitAccount) => navigation.navigate("SettingsUsageAccount", { @@ -159,9 +195,9 @@ function PoolWindowCard({ environmentIds, now, }); - return ( - - + const summary = ( + <> + {label ?? pool.label} @@ -184,31 +220,89 @@ function PoolWindowCard({ .replace("resets now", "now")} ) : null} - - {pool.columns.map((member, index) => { - if (!member.window) - return ; - const { account, window } = member; - const timeLeft = paceRemainingPercent(window, now); + + ); + const bar = ( + setBarWidth(event.nativeEvent.layout.width)} + > + {pool.columns.map((member, index) => { + if (!member.window) return ( - openAccount(account)} - className="h-7 min-w-0 flex-1 rounded-md" - > - - - - + + ); + const { account, window } = member; + const timeLeft = paceRemainingPercent(window, now); + const credits = account.limits.resetCredits?.availableCount ?? 0; + const resetsIn = formatResetsIn(window, now)?.replace("resets in ", ""); + return ( + openAccount(account)} + className={wide ? "h-9 min-w-0 flex-1 rounded-md" : "h-7 min-w-0 flex-1 rounded-md"} + > + + + + + + + {inlineLabels ? ( + + + {account.email ? : null} + {account.email && !(account.displayName && inlineName) ? null : ( + + {accountName(account)} + + )} + + {remainingPercent(window)}% + - + {inlineReset && (resetsIn || credits) ? ( + + {resetsIn ? ( + <> + + + {resetsIn} + + + ) : null} + {credits ? ( + <> + + + {credits} + + + ) : null} + + ) : null} + + ) : ( - - ); - })} - - - {pool.columns.map(({ account, window }, index) => { - if (!window) return null; - const credits = account.limits.resetCredits?.availableCount ?? 0; - const resetsIn = formatResetsIn(window, now); - return ( - openAccount(account)} - className="min-h-[44px] flex-row items-center gap-2 active:opacity-60" - > - - - {index + 1} - - - - {accountName(account)} - - - {remainingPercent(window)}% + )} + + ); + })} + + ); + const legend = ( + + {pool.columns.map(({ account, window }, index) => { + if (!window) return null; + const credits = account.limits.resetCredits?.availableCount ?? 0; + const resetsIn = formatResetsIn(window, now); + return ( + openAccount(account)} + className="min-h-[44px] flex-row items-center gap-2 active:opacity-60" + > + + + {index + 1} - - {resetsIn ? ( - <> - - - {resetsIn.replace("resets in ", "")} - - - ) : null} - {credits ? ( - <> - {resetsIn ? · : null} - - - {credits} - - - ) : null} - - - ); - })} + + + {accountName(account)} + + + {remainingPercent(window)}% + + + {resetsIn ? ( + <> + + + {resetsIn.replace("resets in ", "")} + + + ) : null} + {credits ? ( + <> + {resetsIn ? · : null} + + + {credits} + + + ) : null} + + + ); + })} + + ); + if (wide) { + return ( + + {summary} + + {bar} + {inlineLabels ? null : legend} + + ); + } + return ( + + {summary} + {bar} + {legend} ); } @@ -288,11 +402,13 @@ export function UsageLimitsSection({ failedLabels, selectedEnvironmentIds, cursorPrompt, + wide = false, }: { readonly now: number; readonly failedLabels: readonly string[]; readonly selectedEnvironmentIds: ReadonlySet | null; readonly cursorPrompt?: ReactNode; + readonly wide?: boolean; }) { const presentations = useAtomValue(environmentPresentations.presentationsWithProvidersAtom); const selected = @@ -347,6 +463,7 @@ export function UsageLimitsSection({ } label={details?.label} description={details?.description} + wide={wide} /> ); })} diff --git a/apps/mobile/src/features/usage/UsageRouteScreen.tsx b/apps/mobile/src/features/usage/UsageRouteScreen.tsx index 5e165cac5ff70..1cba6c00547fe 100644 --- a/apps/mobile/src/features/usage/UsageRouteScreen.tsx +++ b/apps/mobile/src/features/usage/UsageRouteScreen.tsx @@ -54,11 +54,12 @@ import { withNativeGlassHeaderItem } from "../layout/native-glass-header-items"; import type { UsageChartMetric } from "./usageChartData"; import { PROVIDER_LABEL, useProviderColors, useUsageMixColors } from "./usageProviders"; -type UsageTab = "usage" | "limits"; -const TAB_OPTIONS = [ - { value: "usage", label: "Usage" }, +type UsageView = UsageChartMetric | "limits"; +const VIEW_OPTIONS = [ + { value: "cost", label: "Cost" }, + { value: "tokens", label: "Tokens" }, { value: "limits", label: "Limits" }, -] as const satisfies readonly { value: UsageTab; label: string }[]; +] as const satisfies readonly { value: UsageView; label: string }[]; const WINDOW_OPTIONS = [ { value: 1, label: "24h", accessibilityLabel: "Past 24 hours" }, @@ -67,12 +68,11 @@ const WINDOW_OPTIONS = [ { value: 90, label: "90d", accessibilityLabel: "Past 90 days" }, ] as const; -const METRIC_OPTIONS = [ - { value: "cost", label: "Cost" }, - { value: "tokens", label: "Tokens" }, -] as const satisfies readonly { value: UsageChartMetric; label: string }[]; - const CHART_HEIGHT = 180; +const WIDE_CHART_HEIGHT = 260; +// Below this the page keeps the phone's single column. +const WIDE_WIDTH = 600; +const MAX_CONTENT_WIDTH = 1080; const providerLabel = (provider: UsageProviderKind) => PROVIDER_LABEL[provider]; const CURSOR_KEYCHAIN_COPY = "Requires access to your Cursor login in macOS Keychain."; @@ -88,21 +88,22 @@ function UsageScreen(props: { const insets = useSafeAreaInsets(); const [selection, setSelection] = useState(() => ({ params: props.tabParam, - tab: (props.tabParam === "usage" ? "usage" : "limits") as UsageTab, + view: (props.tabParam === "usage" ? "cost" : "limits") as UsageView, })); if (selection.params !== props.tabParam) { setSelection({ params: props.tabParam, - tab: props.tabParam === "usage" ? "usage" : "limits", + view: props.tabParam === "usage" ? "cost" : "limits", }); } - const { tab } = selection; - const setTab = (tab: UsageTab) => setSelection({ params: props.tabParam, tab }); + const { view } = selection; + const setView = (view: UsageView) => setSelection({ params: props.tabParam, view }); + const [width, setWidth] = useState(0); + const wide = width >= WIDE_WIDTH; const [windowSelection, setWindowSelection] = useState(() => ({ days: 30, window: makeWindow(30), })); - const [metric, setMetric] = useState("cost"); const { days: windowDays, window } = windowSelection; const isPast24Hours = windowDays === 1; const [selectedEnvironmentIds, setSelectedEnvironmentIds] = @@ -112,7 +113,7 @@ function UsageScreen(props: { selectedEnvironmentIds, ); const isFocused = useIsFocused(); - const limits = useRefreshLimits(selectedEnvironmentIds, isFocused && tab === "limits"); + const limits = useRefreshLimits(selectedEnvironmentIds, isFocused && view === "limits"); const cursorAccessEnvironments = cursorKeychainAccessEnvironments(selectedEnvironments); const refreshAfterCursorEnable = () => { void refresh(); @@ -154,7 +155,8 @@ function UsageScreen(props: { const [refreshingUsage, setRefreshingUsage] = useState(false); const refreshingRef = useRef(false); - const showingLimits = tab === "limits"; + const showingLimits = view === "limits"; + const metric: UsageChartMetric = view === "tokens" ? "tokens" : "cost"; const progress = usageProgress(selectedEnvironments, { refreshing: refreshingUsage, providerLabel, @@ -184,6 +186,27 @@ function UsageScreen(props: { }); }; + const chart = { + merged, + days: chartDays, + daily: chartTotals, + metric, + sinceDay: window.sinceDay, + untilDay: window.untilDay, + isPast24Hours, + timeZone: window.timeZone, + }; + const providers = ( + 1} + onCursorEnabled={refreshAfterCursorEnable} + compact={wide} + /> + ); + const showEnvironmentFilter = environments.length > 0 || selectedEnvironmentIds !== null; const filterIcon = selectedEnvironmentIds === null @@ -271,8 +294,9 @@ function UsageScreen(props: { contentInsetAdjustmentBehavior="automatic" showsVerticalScrollIndicator={false} className="flex-1" - contentContainerClassName="gap-6 px-5 pt-4" + contentContainerClassName="px-5 pt-4" contentContainerStyle={{ paddingBottom: Math.max(insets.bottom, 18) + 18 }} + onLayout={(event) => setWidth(event.nativeEvent.layout.width)} refreshControl={ showingLimits || canReadDiagnostics ? ( - - - {showingLimits ? ( - 0 ? ( - - ) : null - } - /> - ) : ( - <> - + + {wide ? ( + + {showingLimits ? ( + + ) : ( - - - - {merged.duplicateSources.length > 0 ? ( - - Counted once across environments sharing a transcript directory:{" "} - {merged.duplicateSources.join(", ")} - - ) : null} - {isPending ? ( - - Scanning provider transcripts… - - ) : selectedEnvironments.length === 0 ? ( - - {environments.length === 0 - ? "Connect an environment to see usage." - : "Select an environment to see usage."} - - ) : !canReadDiagnostics ? ( - - {selectedEnvironments.map((environment) => ( - - {selectedEnvironments.length > 1 ? `${environment.label}: ` : null} - {environment.error} - - ))} - - ) : ( - <> - {sourceMessages.map((message) => ( - - {message} - - ))} - - - 1} - onCursorEnabled={refreshAfterCursorEnable} - /> - - - - - )} - + + + ) : ( + )} - + + {showingLimits ? ( + 0 ? ( + + ) : null + } + /> + ) : ( + <> + {wide ? null : ( + + )} + + {merged.duplicateSources.length > 0 ? ( + + Counted once across environments sharing a transcript directory:{" "} + {merged.duplicateSources.join(", ")} + + ) : null} + {isPending ? ( + + Scanning provider transcripts… + + ) : selectedEnvironments.length === 0 ? ( + + {environments.length === 0 + ? "Connect an environment to see usage." + : "Select an environment to see usage."} + + ) : !canReadDiagnostics ? ( + + {selectedEnvironments.map((environment) => ( + + {selectedEnvironments.length > 1 ? `${environment.label}: ` : null} + {environment.error} + + ))} + + ) : ( + <> + {sourceMessages.map((message) => ( + + {message} + + ))} + + {wide ? ( + + + + + + {providers} + + + + + + ) : ( + <> + + {providers} + + )} + = 900 ? 6 : wide ? 3 : 2} + /> + + + + + )} + + )} + + ); @@ -536,38 +594,46 @@ function ChartCard(props: { readonly untilDay: string; readonly isPast24Hours: boolean; readonly timeZone: string; + readonly height: number; + /** Replaces the summary when it sits beside the chart. */ + readonly title?: string; + /** Grows the chart past `height` to match a taller neighbouring column. */ + readonly fill?: boolean; }) { const { merged, metric } = props; const colors = useProviderColors(); const hasActivity = props.daily.some((period) => period.totalTokens > 0); + const [fillHeight, setFillHeight] = useState(0); + const height = props.fill ? Math.max(fillHeight, props.height) : props.height; + const chart = hasActivity ? ( + + ) : ( + + No activity in this window. + + ); return ( - - - - {metric === "cost" ? "Raw token cost" : "Processed tokens"} - - - {metric === "cost" ? `${formatUsd(merged.costUsd)}*` : formatTokens(merged.totalTokens)} - - - {metric === "cost" - ? "* if billed at full API rate" - : `Across ${formatCount(merged.sessions)} sessions`} - - - - {hasActivity ? ( - + + {props.title ? ( + {props.title} ) : ( - - No activity in this window. + + )} + + {props.fill ? ( + // Absolute so the measured slot can shrink again when the neighbour gets shorter. + setFillHeight(event.nativeEvent.layout.height)} + > + {chart} + ) : ( + chart )} @@ -601,12 +667,33 @@ function ChartCard(props: { ); } +function UsageSummary(props: { readonly merged: MergedUsage; readonly metric: UsageChartMetric }) { + const { merged, metric } = props; + return ( + + + {metric === "cost" ? "Raw token cost" : "Processed tokens"} + + + {metric === "cost" ? `${formatUsd(merged.costUsd)}*` : formatTokens(merged.totalTokens)} + + + {metric === "cost" + ? "* if billed at full API rate" + : `Across ${formatCount(merged.sessions)} sessions`} + + + ); +} + function ProviderSection(props: { readonly merged: MergedUsage; readonly metric: UsageChartMetric; readonly cursorAccessEnvironments: readonly EnvironmentUsageStatus[]; readonly showCursorEnvironment: boolean; readonly onCursorEnabled: () => void; + /** Drops the share bar so the list fits beside the chart. */ + readonly compact: boolean; }) { const { merged, metric } = props; const colors = useProviderColors(); @@ -654,7 +741,10 @@ function ProviderSection(props: { return ( 0 && "border-t border-border-subtle", + )} > @@ -662,21 +752,35 @@ function ProviderSection(props: { className="size-2.5 rounded-full" style={{ backgroundColor: colors[provider.provider] }} /> - {PROVIDER_LABEL[provider.provider]} + + {PROVIDER_LABEL[provider.provider]} + - + {metric === "cost" ? formatUsd(provider.costUsd) : formatTokens(provider.totalTokens)} - - - - + {props.compact ? null : ( + + + + + )} {metric === "cost" ? `${formatPercent(share)} of cost · ${formatTokens(provider.totalTokens)} tokens` @@ -689,7 +793,11 @@ function ProviderSection(props: { ); } -function TotalsSection(props: { readonly merged: MergedUsage; readonly isPast24Hours: boolean }) { +function TotalsSection(props: { + readonly merged: MergedUsage; + readonly isPast24Hours: boolean; + readonly columns: number; +}) { const { merged } = props; const activePeriods = (props.isPast24Hours ? merged.hourly : merged.daily).filter( (period) => period.totalTokens > 0, @@ -702,11 +810,13 @@ function TotalsSection(props: { readonly merged: MergedUsage; readonly isPast24H - - {speedCost.fast + speedCost.ultrafast > 0 ? ( - - + + + - ) : null} + {speedCost.fast + speedCost.ultrafast > 0 ? ( + + + + ) : null} + ); } @@ -821,9 +945,10 @@ function MetricCell(props: { readonly label: string; readonly value: string; readonly detail: string; + readonly columns: number; }) { return ( - + {props.label} {props.value} {props.detail} @@ -831,7 +956,11 @@ function MetricCell(props: { ); } -function ModelsSection(props: { readonly merged: MergedUsage; readonly metric: UsageChartMetric }) { +function ModelsSection(props: { + readonly merged: MergedUsage; + readonly metric: UsageChartMetric; + readonly columns: 1 | 2; +}) { const { merged, metric } = props; const colors = useProviderColors(); if (merged.models.length === 0) return null; @@ -845,42 +974,45 @@ function ModelsSection(props: { readonly merged: MergedUsage; readonly metric: U return ( - {ordered.map((model, index) => ( - + + {ordered.map((model, index) => ( - - - {model.model} - - + key={`${model.provider}:${model.model}`} + className={cn( + "flex-row items-center gap-3 p-4", + index >= props.columns && "border-t border-border-subtle", + index % props.columns === 1 && "border-l border-border-subtle", + )} + style={{ width: `${100 / props.columns}%` }} + > + + + + {model.model} + + + {metric === "tokens" + ? `${formatPercent(model.tokenShare)} of tokens · ${ + isModelCostUnknown(model) ? "no known rates" : formatUsd(model.costUsd) + }` + : isModelCostUnknown(model) + ? `no known rates · ${formatTokens(model.totalTokens)} tokens` + : `${formatPercent(model.costShare)} of cost · ${formatTokens(model.totalTokens)} tokens`} + + + {metric === "tokens" - ? `${formatPercent(model.tokenShare)} of tokens · ${ - isModelCostUnknown(model) ? "no known rates" : formatUsd(model.costUsd) - }` + ? formatTokens(model.totalTokens) : isModelCostUnknown(model) - ? `no known rates · ${formatTokens(model.totalTokens)} tokens` - : `${formatPercent(model.costShare)} of cost · ${formatTokens(model.totalTokens)} tokens`} + ? "Unpriced" + : formatUsd(model.costUsd)} - - {metric === "tokens" - ? formatTokens(model.totalTokens) - : isModelCostUnknown(model) - ? "Unpriced" - : formatUsd(model.costUsd)} - - - ))} + ))} + ); }