325 lines · 11.4 KB · TypeScript
RawDownload
import { useAtomValue } from "@effect/atom-react";import type {  EnvironmentId,  ProviderConsumeResetCreditOutcome,  ProviderConsumeResetCreditInput,  ServerProvider,  ServerProviderResetCredits,  ServerProviderUsageWindow,  UsageProviderKind,} from "@t3tools/contracts";import {  elapsedShare,  formatDuration,  formatResetsIn,  limitsNotice,  paceOf,  remainingPercent,} from "@t3tools/shared/usageLimits";import { type ReactNode, useState } from "react";import { Alert, Pressable, View } from "react-native"; import { AppText as Text } from "../../components/AppText";import { ProviderIcon } from "../../components/ProviderIcon";import { environmentPresentations } from "../../state/presentation";import { serverEnvironment } from "../../state/server";import { useAtomCommand } from "../../state/use-atom-command";import { useProviderColors } from "./usageProviders"; const PACE_LABEL = { ahead: "ahead of pace", on: "on pace", under: "under pace" } as const; type Driver = ServerProvider["driver"]; /** The series colour the usage chart uses for this driver, so the two views read as one. */function useBarColor(driver: Driver): string | null {  const colors = useProviderColors();  const kind: UsageProviderKind | null =    driver === "codex" ? "codex" : driver === "claudeAgent" ? "claude" : null;  return kind ? colors[kind] : null;} /** * One window as a bar spanning its whole duration: the fill is quota left, * the hairline is how much of the window is left, so even spending keeps the * fill on the line. Pace sits under the left edge, the countdown under the * right, so a row reads in one glance. */function WindowRow(props: {  readonly window: ServerProviderUsageWindow;  readonly color: string | null;  readonly now: number;}) {  const { window, now } = props;  const remaining = remainingPercent(window);  const elapsed = elapsedShare(window, now);  const timeLeft = elapsed === null ? null : Math.round((1 - elapsed) * 100);  const pace = paceOf(window, now);  const resetsIn = formatResetsIn(window, now);  return (    <View className="gap-1">      <View className="flex-row items-baseline justify-between gap-3">        <Text className="text-sm text-foreground">{window.label}</Text>        <Text className="text-sm font-t3-medium tabular-nums text-foreground">          {remaining}% left        </Text>      </View>      <View className="h-3 justify-center">        <View className="h-1.5 flex-row overflow-hidden rounded-full bg-subtle">          <View            className={              remaining <= 10                ? "h-full rounded-full bg-red-500"                : remaining <= 30                  ? "h-full rounded-full bg-amber-500"                  : "h-full rounded-full bg-foreground"            }            style={[              { flex: remaining },              remaining > 30 && props.color ? { backgroundColor: props.color } : null,            ]}          />          <View style={{ flex: 100 - remaining }} />        </View>        {timeLeft !== null ? (          <View            className="absolute top-0 bottom-0 w-px bg-foreground"            style={{ left: `${timeLeft}%`, opacity: 0.6 }}          />        ) : null}      </View>      {pace || resetsIn ? (        <View className="flex-row justify-between gap-3">          <Text className="text-xs text-foreground-tertiary">{pace ? PACE_LABEL[pace] : ""}</Text>          <Text className="text-xs tabular-nums text-foreground-tertiary">{resetsIn ?? ""}</Text>        </View>      ) : null}    </View>  );} function AccountInstanceLabel({ value }: { readonly value: string }) {  const [revealed, setRevealed] = useState(false);  if (!value.includes("@")) {    return (      <Text className="shrink text-xs text-foreground-tertiary" numberOfLines={1}>        · {value}      </Text>    );  }  return (    <Pressable      className="shrink active:opacity-60"      accessibilityRole="button"      accessibilityLabel={revealed ? "Hide account label" : "Reveal account label"}      onPress={() => setRevealed((current) => !current)}    >      <Text className="text-xs text-foreground-tertiary" numberOfLines={1}>        · {revealed ? value : "••••••@••••••"}      </Text>    </Pressable>  );} /** One account: icon, name and plan on a single line, then its windows. */export function AccountLimits(props: {  readonly driver: Driver;  readonly label: string;  readonly instanceLabel: string;  readonly detail: string | undefined;  readonly limits: ServerProvider["usageLimits"];  readonly now: number;  readonly first: boolean;  /** Tighter padding for the composer card. */  readonly dense?: boolean;  /** Sits at the end of the heading row, such as a close control. */  readonly trailing?: ReactNode;  readonly footer?: ReactNode;}) {  const { limits, now, dense = false } = props;  const color = useBarColor(props.driver);  if (!limits) return null;  const notice = limitsNotice(limits);  const padding = dense ? "px-4 py-3" : "p-4";  return (    <View      className={        props.first ? `gap-3 ${padding}` : `gap-3 border-t border-border-subtle ${padding}`      }    >      <View className="flex-row items-center gap-2">        <ProviderIcon provider={props.driver} size={16} />        <View className="min-w-0 flex-1 flex-row items-baseline gap-2">          <Text className="text-base font-t3-medium text-foreground">{props.label}</Text>          {props.instanceLabel !== props.label ? (            <AccountInstanceLabel key={props.instanceLabel} value={props.instanceLabel} />          ) : null}          {props.detail ? (            <Text className="shrink text-sm text-foreground-muted" numberOfLines={1}>              · {props.detail}            </Text>          ) : null}        </View>        {props.trailing}      </View>      {notice ? (        <Text className="text-sm text-foreground-muted">{notice}</Text>      ) : (        <View className="gap-3">          {limits.windows.map((window) => (            <WindowRow key={window.id} window={window} color={color} now={now} />          ))}        </View>      )}      {props.footer}    </View>  );} const OUTCOME_TEXT: Record<ProviderConsumeResetCreditOutcome, string> = {  reset: "Reset applied. Your windows have cleared.",  nothingToReset: "Nothing to reset right now.",  noCredit: "No reset credit left.",  alreadyRedeemed: "That credit was already redeemed.",}; /** * Banked reset credits with a confirmed redeem action. Redeeming spends a * credit the provider granted the user, so it goes through the native * confirm alert rather than firing on a bare tap. */export function ResetCredits(props: {  readonly environmentId: EnvironmentId;  readonly input: ProviderConsumeResetCreditInput;  readonly credits: ServerProviderResetCredits;  readonly now: number;  /** A smaller pill for the composer card. */  readonly dense?: boolean;}) {  const { environmentId, input, credits, now, dense = false } = props;  const consume = useAtomCommand(serverEnvironment.consumeResetCredit, {    reportFailure: false,  });  const [busy, setBusy] = useState(false);  const [status, setStatus] = useState<string | null>(null);  if (dense && credits.availableCount === 0 && status === null) return null;   const expiresIn = credits.nextExpiresAt    ? formatDuration(Date.parse(credits.nextExpiresAt) - now)    : null;  const summary =    credits.availableCount === 0      ? "No reset credits banked"      : `${credits.availableCount} ${credits.availableCount === 1 ? "reset credit" : "reset credits"} banked${          expiresIn ? ` · next expires in ${expiresIn}` : ""        }`;   const redeem = async () => {    setBusy(true);    setStatus(null);    const result = await consume({ environmentId, input });    setBusy(false);    if (result._tag === "Success") {      setStatus(result.value.warning ?? OUTCOME_TEXT[result.value.outcome]);      return;    }    setStatus(      "error" in result.cause && result.cause.error instanceof Error        ? result.cause.error.message        : "Could not use the reset credit.",    );  };   const confirm = () => {    Alert.alert(      "Use a reset credit?",      "This redeems one credit on your account and clears the current rate-limit windows. It cannot be undone.",      [        { text: "Cancel", style: "cancel" },        { text: "Use credit", onPress: () => void redeem() },      ],    );  };   return (    <View className="flex-row flex-wrap items-center gap-x-3 gap-y-1">      <Text className="text-xs tabular-nums text-foreground-tertiary">{summary}</Text>      {credits.availableCount > 0 ? (        <Pressable          accessibilityRole="button"          accessibilityState={{ disabled: busy }}          disabled={busy}          onPress={confirm}          className={            dense              ? "rounded-full bg-subtle-strong px-2.5 py-1"              : "min-h-[44px] justify-center rounded-full bg-subtle-strong px-3 py-1.5"          }        >          <Text            className={              dense                ? "text-xs font-t3-medium text-foreground"                : "text-sm font-t3-medium text-foreground"            }          >            {busy ? "Using…" : "Use reset"}          </Text>        </Pressable>      ) : null}      {status ? <Text className="text-sm text-foreground">{status}</Text> : null}    </View>  );} /** * Re-probes every provider (and usage-limit source) on each connected * environment; the fresh snapshots then arrive over the config stream. * Countdowns and pace anchor to `now` rather than ticking, so a refresh also * re-anchors the clock: quota and elapsed time move together, or not at all. * Environments whose probe failed are named, since their rows keep showing * the previous quota with nothing else to say so. */export function useRefreshLimits(selectedEnvironmentIds: ReadonlySet<EnvironmentId> | null = null) {  const presentations = useAtomValue(environmentPresentations.presentationsAtom);  const refreshProviders = useAtomCommand(serverEnvironment.refreshProviders, {    reportFailure: false,  });  const [now, setNow] = useState(() => Date.now());  const [refreshing, setRefreshing] = useState(false);  const [failedEnvironments, setFailedEnvironments] = useState<    readonly { environmentId: EnvironmentId; label: string }[]  >([]);  // Always toggles `refreshing`, even with nothing to probe: Android's  // RefreshControl keeps its spinner up until it sees true then false.  const refresh = async () => {    const connected = [...presentations].filter(      ([environmentId, presentation]) =>        presentation.connection.phase === "connected" &&        (selectedEnvironmentIds === null || selectedEnvironmentIds.has(environmentId)),    );    setRefreshing(true);    try {      const results = await Promise.all(        connected.map(([environmentId]) => refreshProviders({ environmentId, input: {} })),      );      setFailedEnvironments(        connected          .filter((_, index) => results[index]?._tag === "Failure")          .map(([environmentId, presentation]) => ({            environmentId,            label: presentation.entry.target.label,          })),      );    } finally {      setNow(Date.now());      setRefreshing(false);    }  };  const failedLabels = failedEnvironments    .filter(      ({ environmentId }) =>        selectedEnvironmentIds === null || selectedEnvironmentIds.has(environmentId),    )    .map(({ label }) => label);  return { now, refreshing, failedLabels, refresh };}