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
42 changes: 29 additions & 13 deletions apps/mobile/src/features/usage/UsageLimitsPooled.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@ import { useSafeAreaInsets } from "react-native-safe-area-context";

import { SymbolView } from "../../components/AppSymbol";
import { AppText as Text } from "../../components/AppText";
import { ProviderIcon } from "../../components/ProviderIcon";
import { ProviderIcon, ProviderInstanceIcon } from "../../components/ProviderIcon";
import { SettingsScreen } from "../settings/components/SettingsScreen";
import { environmentPresentations } from "../../state/presentation";
import { ResetCredits } from "./UsageLimitsSection";
Expand All @@ -37,6 +37,28 @@ function accountName(account: LimitAccount) {
return `${local[0] ?? ""}${domain[0] ?? ""}`.toUpperCase() || "Account";
}

/**
* The account's own mark, matching the web Limits bar: the provider glyph with
* its account initials badge. Replaces the position number that was hard to map
* to an account.
*/
function AccountAvatar({ account }: { readonly account: LimitAccount }) {
return (
<ProviderInstanceIcon
provider={account.driver}
size={20}
// Initials from the full email, as on web: two addresses on one domain
// would otherwise share a badge.
displayName={account.displayName ?? account.email ?? accountName(account)}
accentColor={account.accentColor}
showBadge={Boolean(account.displayName ?? account.email)}
// The badge sits on hatched segments and list rows, with no single
// background for its ring to match.
surfaceColor="transparent"
/>
);
}

/** The spent share comes back at reset. SVG keeps the hatching static on both platforms. */
function AccountSegment({
remaining,
Expand Down Expand Up @@ -123,13 +145,13 @@ function PoolWindowCard({
</Text>
) : null}
<View className="flex-row gap-1">
{pool.columns.map(({ account, window }, index) => {
{pool.columns.map(({ account, window }) => {
if (!window) return <View key={account.key} className="h-7 min-w-0 flex-1" />;
return (
<Pressable
key={account.key}
accessibilityRole="button"
accessibilityLabel={`Segment ${index + 1}, ${accountName(account)}, ${remainingPercent(window)}% left`}
accessibilityLabel={`${accountName(account)}, ${remainingPercent(window)}% left`}
accessibilityHint="Show account details"
onPress={() => openAccount(account)}
className="h-7 min-w-0 flex-1 overflow-hidden rounded-md bg-subtle"
Expand All @@ -140,33 +162,27 @@ function PoolWindowCard({
pending={Boolean(window.resetsAt)}
/>
<View pointerEvents="none" className="absolute inset-0 items-center justify-center">
<Text className="text-xs font-t3-medium tabular-nums text-foreground">
{index + 1}
</Text>
<AccountAvatar account={account} />
</View>
</Pressable>
);
})}
</View>
<View>
{pool.columns.map(({ account, window }, index) => {
{pool.columns.map(({ account, window }) => {
if (!window) return null;
const credits = account.limits.resetCredits?.availableCount ?? 0;
const resetsIn = formatResetsIn(window, now);
return (
<Pressable
key={account.key}
accessibilityRole="button"
accessibilityLabel={`Segment ${index + 1}, ${accountName(account)}, ${remainingPercent(window)}% left${resetsIn ? `, ${resetsIn}` : ""}${credits ? `, ${credits} reset credits banked` : ""}`}
accessibilityLabel={`${accountName(account)}, ${remainingPercent(window)}% left${resetsIn ? `, ${resetsIn}` : ""}${credits ? `, ${credits} reset credits banked` : ""}`}
accessibilityHint="Show account details"
onPress={() => openAccount(account)}
className="min-h-[44px] flex-row items-center gap-2 active:opacity-60"
>
<View className="size-5 items-center justify-center overflow-hidden rounded-md bg-subtle-strong">
<Text className="text-xs font-t3-medium tabular-nums text-foreground">
{index + 1}
</Text>
</View>
<AccountAvatar account={account} />
<Text
numberOfLines={1}
className="min-w-0 flex-1 text-sm font-t3-medium text-foreground"
Expand Down
64 changes: 26 additions & 38 deletions apps/web/src/components/usage/UsageLimitsPooled.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -67,7 +67,8 @@ function AccountChip({ email }: { readonly email: string }) {

/**
* The same mark the model picker uses for a native instance (provider glyph,
* initials badge, accent); hub accounts have no instance, so they get the chip.
* initials badge, accent). A hub account with an email and no instance gets the
* two-letter chip; anything else still gets the glyph, so no account is blank.
*/
function AccountAvatar({
account,
Expand All @@ -76,22 +77,25 @@ function AccountAvatar({
readonly account: LimitAccount;
readonly className?: string;
}) {
if (account.redeem) {
if (!account.email || account.redeem) {
return (
<ProviderInstanceIcon
driverKind={account.driver}
displayName={
account.displayName ?? getDriverOption(account.driver)?.label ?? String(account.driver)
}
displayName={account.displayName ?? account.email ?? accountLabel(account)}
accentColor={account.accentColor}
showBadge={Boolean(account.displayName)}
showBadge={Boolean(account.displayName ?? account.email)}
Comment thread
coderabbitai[bot] marked this conversation as resolved.
indicatorBackground="var(--popover)"
className={cn("size-5", className)}
iconClassName="size-4 text-foreground/80"
/>
);
}
return account.email ? <AccountChip email={account.email} /> : null;
return <AccountChip email={account.email} />;
}

/** The account's label beside its avatar: the instance name when there is one, else the driver label. */
function accountLabel(account: LimitAccount): string {
return account.displayName ?? getDriverOption(account.driver)?.label ?? String(account.driver);
}

Comment thread
coderabbitai[bot] marked this conversation as resolved.
/**
Expand All @@ -114,11 +118,7 @@ function AccountName({
</span>
);
}
return (
<span className={className}>
{getDriverOption(account.driver)?.label ?? String(account.driver)}
</span>
);
return <span className={className}>{accountLabel(account)}</span>;
}

function Row({ label, children }: { readonly label: string; readonly children: ReactNode }) {
Expand Down Expand Up @@ -165,9 +165,7 @@ function SegmentPopover({
<div className="flex min-w-0 flex-col gap-0.5">
<span className="flex items-center gap-2 text-sm font-medium text-foreground">
<AccountAvatar account={account} />
<span className="truncate">
{account.displayName ?? getDriverOption(account.driver)?.label ?? account.driver}
</span>
<span className="truncate">{accountLabel(account)}</span>
</span>
{account.email ? (
<RedactedSensitiveText
Expand Down Expand Up @@ -236,7 +234,7 @@ function PoolSegment({
readonly reset: LimitPoolWindow["resets"][number] | undefined;
readonly color: string;
readonly now: number;
/** 1-based position in the bar, shown on the strip and its legend row to tie them together. */
/** 1-based position in the bar, placing the segment and its legend row in the same column. */
readonly index: number;
readonly showAccountName: boolean;
}) {
Expand Down Expand Up @@ -276,16 +274,16 @@ function PoolSegment({
) : null}
<span
aria-hidden
className="absolute inset-0 flex items-center justify-center text-3xs leading-none font-semibold text-foreground/80 tabular-nums @2xl/pool:hidden"
className="absolute inset-0 flex items-center justify-center @2xl/pool:hidden"
>
{index}
<AccountAvatar account={account} />
</span>
<div className="relative hidden h-full min-w-0 items-center gap-1.5 px-2 text-xs @2xl/pool:flex">
<AccountAvatar account={account} />
{showAccountName ? (
<AccountName
account={account}
className="min-w-0 truncate font-medium text-foreground"
/>
<span className="min-w-0 truncate font-medium text-foreground">
{accountLabel(account)}
</span>
) : null}
<span className="shrink-0 font-semibold text-foreground tabular-nums">{remaining}%</span>
{/* Countdown and badge get their own plate: fill and hatching run under them otherwise. */}
Expand All @@ -307,7 +305,7 @@ function PoolSegment({
</span>
</div>
</PopoverTrigger>
<LegendRow account={account} window={window} color={color} now={now} index={index} />
<LegendRow account={account} window={window} now={now} index={index} />
{account.redeem ? (
<RedeemableSegmentPopup
account={account}
Expand Down Expand Up @@ -341,13 +339,11 @@ function PoolSegment({
function LegendRow({
account,
window,
color,
now,
index,
}: {
readonly account: LimitAccount;
readonly window: LimitPoolMember["window"];
readonly color: string;
readonly now: number;
readonly index: number;
}) {
Expand All @@ -360,16 +356,8 @@ function LegendRow({
render={<Button variant="ghost" size="compact" />}
className="min-w-0 @2xl/pool:hidden"
>
<span className="relative inline-flex size-4 shrink-0 items-center justify-center rounded-sm text-3xs leading-none font-semibold text-foreground/80 tabular-nums">
<span
aria-hidden
className="absolute inset-0 rounded-sm opacity-35"
style={{ backgroundColor: color }}
/>
<span className="sr-only">Segment </span>
<span className="relative">{index}</span>
</span>
<AccountName account={account} className="min-w-0 truncate font-medium text-foreground" />
<AccountAvatar account={account} />
<span className="min-w-0 truncate font-medium text-foreground">{accountLabel(account)}</span>
<span className="shrink-0 font-semibold text-foreground tabular-nums">{remaining}%</span>
<span className="ms-auto flex shrink-0 items-center gap-1.5 text-2xs text-muted-foreground tabular-nums">
{resetsIn?.replace("resets in ", "↻ ") ?? ""}
Expand Down Expand Up @@ -445,9 +433,9 @@ function RedeemableSegmentPopup({
* the share of that account's quota still open. Equal widths are honest: every
* account contributes the same share of the pool, whatever its plan.
*
* Wide, each segment carries its own label. Narrow, the bar is a bare strip
* and a legend below lists the accounts in the same order; both open the
* same popover.
* Wide, each segment carries its own avatar and label. Narrow, the segment
* shows the avatar alone and a legend below repeats it with the label; both
* open the same popover.
*/
function PoolBar({
pool,
Expand Down
12 changes: 12 additions & 0 deletions packages/shared/src/usageLimits.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -536,6 +536,18 @@ describe("pools", () => {
expect(accounts[0]?.displayName).toBe("claude-team-seat");
});

it("normalizes a hub account whose stripped id is empty to no display name", () => {
const hub = {
...source,
accounts: [{ id: ".json", driver: claude, usageLimits: { checkedAt, windows: [window] } }],
};
const input = new Map([
[EnvironmentId.make("env-a"), { ...laptop, serverConfig: { usageLimitSources: [hub] } }],
]);
const accounts = collectLimitAccounts(input);
expect(accounts[0]?.displayName ?? null).toBeNull();
});

it("pools windows by id across accounts and orders resets by when they land", () => {
const input = new Map([
[
Expand Down
2 changes: 1 addition & 1 deletion packages/shared/src/usageLimits.ts
Original file line number Diff line number Diff line change
Expand Up @@ -261,7 +261,7 @@ export function collectLimitAccounts(presentations: LimitPresentations): readonl
{
key: `${source.id}:${account.id}`,
driver: account.driver,
displayName: account.email ? null : account.id.replace(/\.json$/i, ""),
displayName: account.email ? null : account.id.replace(/\.json$/i, "") || null,
email: account.email,
plan: account.plan,
accentColor: undefined,
Expand Down
Loading