Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
66 changes: 48 additions & 18 deletions apps/mobile/src/features/usage/UsageLimitsPooled.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,9 @@ import {
collectLimitPools,
cursorUsageWindowDetails,
displayLimitWindows,
formatPaceHeadroom,
formatResetsIn,
paceRemainingPercent,
remainingPercent,
type LimitAccount,
type LimitPoolWindow,
Expand All @@ -31,7 +33,6 @@ const DRIVER_LABEL: Partial<Record<string, string>> = {
claudeAgent: "Claude",
antigravity: "Antigravity",
};
const PACE_LABEL = { ahead: "Ahead of pace", on: "On pace", under: "Under pace" } as const;

function accountName(account: LimitAccount) {
if (account.displayName) return account.displayName;
Expand All @@ -40,6 +41,17 @@ function accountName(account: LimitAccount) {
return `${local[0] ?? ""}${domain[0] ?? ""}`.toUpperCase() || "Account";
}

/** Where even spending would sit: a solid tick taller than the bar, so it reads as a target rather than the fill's edge. */
function PaceMark({ left }: { readonly left: number }) {
return (
<View
pointerEvents="none"
className="absolute -top-1 -bottom-1 w-0.5 -translate-x-px rounded-full bg-foreground-muted"
style={{ left: `${left}%` }}
/>
);
}

/** The spent share comes back at reset. SVG keeps the hatching static on both platforms. */
function AccountSegment({
remaining,
Expand Down Expand Up @@ -87,6 +99,7 @@ function PoolWindowCard({
readonly description?: string;
}) {
const navigation = useNavigation();
// The soonest reset that hands anything back; an untouched account resets to no effect.
const nextRefill = pool.resets.find((reset) => reset.restoresPercent > 0);
const openAccount = (account: LimitAccount) =>
navigation.navigate("SettingsSheet", {
Expand All @@ -112,38 +125,49 @@ function PoolWindowCard({
{pool.remainingPercent}%
</Text>
<Text className="text-sm text-foreground-muted">left</Text>
{nextRefill ? (
<Text
accessibilityLabel={`${pool.columns.length > 1 ? "Pooled usage" : "Usage"} +${nextRefill.restoresPercent}% ${formatResetsIn(
nextRefill.member.window,
now,
)
?.replace("resets in ", "in ")
.replace("resets now", "now")}`}
className="text-xs tabular-nums text-foreground-muted"
>
↻ +{nextRefill.restoresPercent}%
</Text>
) : null}
</View>
</View>
{pool.pace ? (
<Text className="text-xs text-foreground-tertiary">{PACE_LABEL[pool.pace]}</Text>
{pool.paceHeadroomPercent !== null ? (
<Text className="text-xs tabular-nums text-foreground-tertiary">
{formatPaceHeadroom(pool.paceHeadroomPercent)}
</Text>
) : null}
</View>
{description ? <Text className="text-xs text-foreground-muted">{description}</Text> : null}
{nextRefill ? (
<Text className="text-xs tabular-nums text-foreground-muted">
↻ +{nextRefill.restoresPercent}%{" "}
{formatResetsIn(nextRefill.member.window, now)
?.replace("resets in ", "in ")
.replace("resets now", "now")}
</Text>
) : null}
<View className="flex-row gap-1">
{pool.columns.map(({ account, window }, index) => {
if (!window) return <View key={account.key} className="h-7 min-w-0 flex-1" />;
const timeLeft = paceRemainingPercent(window, now);
return (
<Pressable
key={account.key}
accessibilityRole="button"
accessibilityLabel={`Segment ${index + 1}, ${accountName(account)}, ${remainingPercent(window)}% left`}
accessibilityLabel={`Segment ${index + 1}, ${accountName(account)}, ${remainingPercent(window)}% left${timeLeft === null ? "" : `, ${timeLeft}% of the window left`}`}
accessibilityHint="Show account details"
onPress={() => openAccount(account)}
className="h-7 min-w-0 flex-1 overflow-hidden rounded-md bg-subtle"
className="h-7 min-w-0 flex-1 rounded-md bg-subtle"
>
<AccountSegment
remaining={remainingPercent(window)}
color={color}
pending={Boolean(window.resetsAt)}
/>
<View className="absolute inset-0 overflow-hidden rounded-md">
<AccountSegment
remaining={remainingPercent(window)}
color={color}
pending={Boolean(window.resetsAt)}
/>
</View>
{timeLeft !== null ? <PaceMark left={timeLeft} /> : null}
<View pointerEvents="none" className="absolute inset-0 items-center justify-center">
<Text className="text-xs font-t3-medium tabular-nums text-foreground">
{index + 1}
Expand Down Expand Up @@ -361,6 +385,7 @@ export function UsageLimitAccountScreen({ route }: AccountScreenProps) {
.find((candidate) => candidate.driver === account?.driver)
?.windows.find((candidate) => candidate.id === windowId && candidate.kind === windowKind);
const window = pool?.members.find((member) => member.account.key === accountKey)?.window;
const evenPace = window ? paceRemainingPercent(window, now) : null;
const reset = pool?.resets.find((candidate) => candidate.member.account.key === accountKey);
const [revealed, setRevealed] = useState(false);
return (
Expand Down Expand Up @@ -415,6 +440,11 @@ export function UsageLimitAccountScreen({ route }: AccountScreenProps) {
})}
</Text>
) : null}
{evenPace !== null ? (
<Text className="text-sm text-foreground-muted">
Even pace: {evenPace}% left by now
</Text>
) : null}
{reset && reset.restoresPercent > 0 ? (
<Text className="text-sm text-foreground-muted">
Restores {reset.restoresPercent}% of the pool
Expand Down
5 changes: 2 additions & 3 deletions apps/mobile/src/features/usage/UsageLimitsSection.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -10,11 +10,11 @@ import {
type UsageProviderKind,
} from "@t3tools/contracts";
import {
elapsedShare,
formatDuration,
formatResetsIn,
limitsNotice,
paceOf,
paceRemainingPercent,
remainingPercent,
} from "@t3tools/shared/usageLimits";
import { type ReactNode, useEffect, useEffectEvent, useRef, useState } from "react";
Expand Down Expand Up @@ -54,8 +54,7 @@ function WindowRow(props: {
}) {
const { window, now } = props;
const remaining = remainingPercent(window);
const elapsed = elapsedShare(window, now);
const timeLeft = elapsed === null ? null : Math.round((1 - elapsed) * 100);
const timeLeft = paceRemainingPercent(window, now);
const pace = paceOf(window, now);
const resetsIn = formatResetsIn(window, now);
return (
Expand Down
7 changes: 3 additions & 4 deletions apps/web/src/components/usage/UsageLimits.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -10,11 +10,11 @@ import {
} from "@t3tools/contracts";
import { useAtomValue } from "@effect/atom-react";
import {
elapsedShare,
formatDuration,
formatResetsIn,
type LimitPace,
paceOf,
paceRemainingPercent,
remainingPercent,
} from "@t3tools/shared/usageLimits";
import { GaugeIcon, TrendingDownIcon, TrendingUpIcon } from "lucide-react";
Expand Down Expand Up @@ -60,7 +60,7 @@ export function barColor(driver: ServerProvider["driver"]): string {
}

/** Pace as a glyph with the words on hover. */
export function PaceIcon({ pace }: { readonly pace: LimitPace }) {
function PaceIcon({ pace }: { readonly pace: LimitPace }) {
const Icon = PACE[pace].icon;
return (
<Tooltip>
Expand Down Expand Up @@ -97,9 +97,8 @@ function WindowBar({
}) {
const timestampFormat = usePrimarySettings((settings) => settings.timestampFormat);
const remaining = remainingPercent(window);
const elapsed = elapsedShare(window, now);
// The fill is quota left, so the even-spending mark is the time left.
const timeLeft = elapsed === null ? null : Math.round((1 - elapsed) * 100);
const timeLeft = paceRemainingPercent(window, now);
const resetsIn = formatResetsIn(window, now);
const resetsAt = window.resetsAt
? formatUpcomingTimestamp(window.resetsAt, timestampFormat, now)
Expand Down
90 changes: 59 additions & 31 deletions apps/web/src/components/usage/UsageLimitsPooled.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,11 +6,13 @@ import {
collectLimitPools,
cursorUsageWindowDetails,
displayLimitWindows,
formatPaceHeadroom,
formatResetsIn,
type LimitAccount,
type LimitPool,
type LimitPoolMember,
type LimitPoolWindow,
paceRemainingPercent,
remainingPercent,
} from "@t3tools/shared/usageLimits";
import { AlertTriangleIcon, ExternalLinkIcon, TicketIcon } from "lucide-react";
Expand All @@ -27,13 +29,8 @@ import { Button } from "../ui/button";
import { OpenAI } from "../Icons";
import { Alert, AlertTitle } from "../ui/alert";
import { Popover, PopoverPopup, PopoverTrigger } from "../ui/popover";
import {
PaceIcon,
ResetCreditDialog,
barColor,
resetCreditsSummary,
useResetCredit,
} from "./UsageLimits";
import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip";
import { ResetCreditDialog, barColor, resetCreditsSummary, useResetCredit } from "./UsageLimits";

/** `someone@example.com` → `SE`: enough to tell accounts apart, too little to identify one. */
function accountInitials(email: string): string {
Expand Down Expand Up @@ -155,6 +152,7 @@ function SegmentPopover({
}) {
const timestampFormat = usePrimarySettings((settings) => settings.timestampFormat);
const remaining = remainingPercent(window);
const timeLeft = paceRemainingPercent(window, now);
const resetsIn = formatResetsIn(window, now);
const where =
account.environments.length > 0
Expand Down Expand Up @@ -195,6 +193,7 @@ function SegmentPopover({
{resetsIn ? ` · ${resetsIn.replace("resets in ", "in ")}` : ""}
</Row>
) : null}
{timeLeft !== null ? <Row label="Even pace">{timeLeft}% left by now</Row> : null}
{reset && reset.restoresPercent > 0 ? (
<Row label="Restores">+{reset.restoresPercent}% of pool</Row>
) : null}
Expand Down Expand Up @@ -244,6 +243,7 @@ function PoolSegment({
}) {
const [open, setOpen] = useState(false);
const remaining = remainingPercent(window);
const timeLeft = paceRemainingPercent(window, now);
const resetsIn = formatResetsIn(window, now);
const credits = account.limits.resetCredits?.availableCount ?? 0;
return (
Expand All @@ -254,26 +254,36 @@ function PoolSegment({
<button
type="button"
style={{ gridColumn: index, gridRow: 1 }}
aria-label={`${account.displayName ?? (account.email ? accountInitials(account.email) : account.driver)}: ${remaining}% left${resetsIn ? `, ${resetsIn}` : ""}${credits ? `, ${credits} reset ${credits === 1 ? "credit" : "credits"} banked` : ""}`}
className="relative h-5 min-w-0 cursor-pointer overflow-hidden rounded-md bg-muted text-start outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background data-[popup-open]:ring-1 data-[popup-open]:ring-inset data-[popup-open]:ring-border @2xl/pool:h-8"
aria-label={`${account.displayName ?? (account.email ? accountInitials(account.email) : account.driver)}: ${remaining}% left${timeLeft === null ? "" : `, ${timeLeft}% of the window left`}${resetsIn ? `, ${resetsIn}` : ""}${credits ? `, ${credits} reset ${credits === 1 ? "credit" : "credits"} banked` : ""}`}
className="relative h-5 min-w-0 cursor-pointer rounded-md bg-muted text-start outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background data-[popup-open]:ring-1 data-[popup-open]:ring-inset data-[popup-open]:ring-border @2xl/pool:h-8"
/>
}
>
{/* Translucent so the label reads over the fill for any provider colour and theme. */}
<div
aria-hidden
className="absolute inset-y-0 left-0 rounded-md opacity-35"
style={{ width: `${remaining}%`, backgroundColor: color }}
/>
{/* The spent share is hatched, not blank: it is what the countdown restores. */}
{remaining < 100 && reset ? (
<div aria-hidden className="absolute inset-0 overflow-hidden rounded-md">
{/* Translucent so the label reads over the fill for any provider colour and theme. */}
<div
aria-hidden
className="absolute inset-y-0 right-0 opacity-20"
style={{
width: `${100 - remaining}%`,
backgroundImage: `repeating-linear-gradient(135deg, ${color} 0 1px, transparent 1px 5px)`,
}}
className="absolute inset-y-0 left-0 rounded-md opacity-35"
style={{ width: `${remaining}%`, backgroundColor: color }}
/>
{/* The spent share is hatched, not blank: it is what the countdown restores. */}
{remaining < 100 && reset ? (
<div
aria-hidden
className="absolute inset-y-0 right-0 opacity-20"
style={{
width: `${100 - remaining}%`,
backgroundImage: `repeating-linear-gradient(135deg, ${color} 0 1px, transparent 1px 5px)`,
}}
/>
) : null}
</div>
{/* A solid tick taller than the bar, so it reads as a target rather than the fill's edge. */}
{timeLeft !== null ? (
<span
aria-hidden
className="absolute -inset-y-1 w-0.5 -translate-x-1/2 rounded-full bg-muted-foreground"
style={{ left: `${timeLeft}%` }}
/>
) : null}
<span
Expand Down Expand Up @@ -489,7 +499,8 @@ function PoolBar({

/**
* Big pooled number and the segment bar. Accounts keep the same column across
* windows; each segment's popover shows its own reset time and share restored.
* windows; each segment's popover shows its own reset time and share restored,
* and the refill beside the number says what the next reset hands back.
*/
function PoolWindowCard({
pool,
Expand All @@ -510,18 +521,35 @@ function PoolWindowCard({
<div className="grid items-center gap-x-6 gap-y-3 rounded-lg border border-border/60 p-4 md:grid-cols-[11rem_minmax(0,1fr)]">
<div className="flex flex-col gap-1">
<span className="text-sm font-medium text-foreground">{label ?? pool.label}</span>
<span className="flex items-baseline gap-2">
<span className="flex flex-wrap items-baseline gap-x-2">
<span className="text-3xl font-semibold text-foreground tabular-nums">
{pool.remainingPercent}%
</span>
<span className="inline-flex items-center gap-2 text-sm text-muted-foreground">
<span>left</span>
{pool.pace ? <PaceIcon pace={pool.pace} /> : null}
</span>
<span className="text-sm text-muted-foreground">left</span>
{nextRefill ? (
<Tooltip>
<TooltipTrigger
render={
<span
tabIndex={0}
className="rounded-sm text-xs text-muted-foreground tabular-nums outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background"
/>
}
>
↻ +{nextRefill.restoresPercent}%
</TooltipTrigger>
<TooltipPopup side="top">
{pool.columns.length > 1 ? "Pooled usage" : "Usage"} +{nextRefill.restoresPercent}%{" "}
{formatResetsIn(nextRefill.member.window, now)
?.replace("resets in ", "in ")
.replace("resets now", "now")}
</TooltipPopup>
</Tooltip>
) : null}
</span>
{nextRefill && pool.columns.length > 1 ? (
<span className="text-xs font-medium text-foreground tabular-nums">
↻ +{nextRefill.restoresPercent}%
{pool.paceHeadroomPercent !== null ? (
<span className="text-xs text-muted-foreground tabular-nums">
{formatPaceHeadroom(pool.paceHeadroomPercent)}
</span>
) : null}
</div>
Expand Down
Loading
Loading