Unified · Split
| @@ -56,6 +56,7 @@ import { SettingsAuthRouteScreen } from "./features/settings/SettingsAuthRouteSc · 55 unmodified lines | ||
| 56 | 56 | import { SettingsEnvironmentsRouteScreen } from "./features/settings/SettingsEnvironmentsRouteScreen"; |
| 57 | 57 | import { SettingsLegalRouteScreen } from "./features/settings/SettingsLegalRouteScreen"; |
| 58 | 58 | import { SettingsProjectGroupingRouteScreen } from "./features/settings/SettingsProjectGroupingRouteScreen"; |
| 59 | +import { UsageLimitAccountScreen } from "./features/usage/UsageLimitsPooled"; | |
| 59 | 60 | import { UsageRouteScreen } from "./features/usage/UsageRouteScreen"; |
| 60 | 61 | import { SettingsRouteScreen } from "./features/settings/SettingsRouteScreen"; |
| 61 | 62 | import { ShowcaseCaptureCoordinator } from "./features/showcase/ShowcaseCaptureCoordinator"; |
| @@ -192,6 +193,10 @@ const SettingsContentStack = createNativeStackNavigator({ · 130 unmodified lines | ||
| 192 | 193 | title: "Client Storage", |
| 193 | 194 | }, |
| 194 | 195 | }), |
| 196 | + SettingsUsageAccount: createNativeStackScreen({ | |
| 197 | + screen: UsageLimitAccountScreen, | |
| 198 | + options: { title: "Account" }, | |
| 199 | + }), | |
| 195 | 200 | SettingsUsage: createNativeStackScreen({ |
| 196 | 201 | screen: UsageRouteScreen, |
| 197 | 202 | linking: "usage", |
| @@ -31,6 +31,7 @@ import IconChevronRight from "@tabler/icons-react-native/IconChevronRight"; · 30 unmodified lines | ||
| 31 | 31 | import IconChevronUp from "@tabler/icons-react-native/IconChevronUp"; |
| 32 | 32 | import IconCircleCheck from "@tabler/icons-react-native/IconCircleCheck"; |
| 33 | 33 | import IconCircleXFilled from "@tabler/icons-react-native/IconCircleXFilled"; |
| 34 | +import IconTicket from "@tabler/icons-react-native/IconTicket"; | |
| 34 | 35 | import IconClock from "@tabler/icons-react-native/IconClock"; |
| 35 | 36 | import IconCode from "@tabler/icons-react-native/IconCode"; |
| 36 | 37 | import IconCopy from "@tabler/icons-react-native/IconCopy"; |
| @@ -116,6 +117,7 @@ const ANDROID_ICON_BY_SF_SYMBOL: Partial<Record<SFSymbol, Icon>> = { · 79 unmodified lines | ||
| 116 | 117 | checkmark: IconCheck, |
| 117 | 118 | "checkmark.circle": IconCircleCheck, |
| 118 | 119 | clock: IconClock, |
| 120 | + ticket: IconTicket, | |
| 119 | 121 | cloud: IconCloud, |
| 120 | 122 | cube: IconBox, |
| 121 | 123 | "chevron.down": IconChevronDown, |
| @@ -138,6 +140,7 @@ const ANDROID_ICON_BY_SF_SYMBOL: Partial<Record<SFSymbol, Icon>> = { · 16 unmodified lines | ||
| 138 | 140 | "info.circle": IconInfoCircle, |
| 139 | 141 | laptopcomputer: IconDeviceLaptop, |
| 140 | 142 | link: IconLink, |
| 143 | + "line.3.horizontal.decrease": IconFilter, | |
| 141 | 144 | "line.3.horizontal.decrease.circle": IconFilter, |
| 142 | 145 | "line.3.horizontal.decrease.circle.fill": IconFilterFilled, |
| 143 | 146 | // Tabler has no Apple desktops; the closest silhouettes stand in on Android. |
| @@ -9,7 +9,14 @@ import { · 8 unmodified lines | ||
| 9 | 9 | useMemo, |
| 10 | 10 | useRef, |
| 11 | 11 | } from "react"; |
| 12 | −import { Platform, Pressable, View, type ColorValue, type PressableProps } from "react-native"; | |
| 12 | +import { | |
| 13 | + Platform, | |
| 14 | + Pressable, | |
| 15 | + View, | |
| 16 | + type ColorValue, | |
| 17 | + type PressableProps, | |
| 18 | + type AccessibilityProps, | |
| 19 | +} from "react-native"; | |
| 13 | 20 | import { withUniwind } from "uniwind"; |
| 14 | 21 | import { useAppearancePreferences } from "../features/settings/appearance/AppearancePreferencesProvider"; |
| 15 | 22 | |
| @@ -144,10 +151,11 @@ export function ControlPill(props: { · 128 unmodified lines | ||
| 144 | 151 | // AppCompat popup can't be themed past its stock animation, metrics, and |
| 145 | 152 | // submenu chrome. |
| 146 | 153 | export function ControlPillMenu( |
| 147 | − props: Omit<ComponentProps<typeof MenuView>, "children" | "themeVariant"> & { | |
| 148 | − readonly children: ReactNode; | |
| 149 | − readonly className?: string; | |
| 150 | − }, | |
| 154 | + props: Omit<ComponentProps<typeof MenuView>, "children" | "themeVariant"> & | |
| 155 | + Pick<AccessibilityProps, "accessible" | "accessibilityLabel" | "accessibilityRole"> & { | |
| 156 | + readonly children: ReactNode; | |
| 157 | + readonly className?: string; | |
| 158 | + }, | |
| 151 | 159 | ) { |
| 152 | 160 | const { themeAppearance } = useAppearancePreferences(); |
| 153 | 161 | const isDarkMode = themeAppearance === "dark"; |
| @@ -0,0 +1,374 @@ | ||
| 1 | +import { useAtomValue } from "@effect/atom-react"; | |
| 2 | +import { useNavigation, type StaticScreenProps } from "@react-navigation/native"; | |
| 3 | +import { EnvironmentId } from "@t3tools/contracts"; | |
| 4 | +import { | |
| 5 | + collectLimitAccounts, | |
| 6 | + collectLimitNotices, | |
| 7 | + collectLimitPools, | |
| 8 | + formatDuration, | |
| 9 | + formatResetsIn, | |
| 10 | + remainingPercent, | |
| 11 | + type LimitAccount, | |
| 12 | + type LimitPoolWindow, | |
| 13 | +} from "@t3tools/shared/usageLimits"; | |
| 14 | +import { useId, useState } from "react"; | |
| 15 | +import { Platform, Pressable, ScrollView, View } from "react-native"; | |
| 16 | +import { Defs, Path, Pattern, Rect, Svg } from "react-native-svg"; | |
| 17 | +import { useSafeAreaInsets } from "react-native-safe-area-context"; | |
| 18 | + | |
| 19 | +import { AndroidScreenHeader } from "../../components/AndroidScreenHeader"; | |
| 20 | +import { SymbolView } from "../../components/AppSymbol"; | |
| 21 | +import { AppText as Text } from "../../components/AppText"; | |
| 22 | +import { ProviderIcon } from "../../components/ProviderIcon"; | |
| 23 | +import { NativeStackScreenOptions } from "../../native/StackHeader"; | |
| 24 | +import { environmentPresentations } from "../../state/presentation"; | |
| 25 | +import { ResetCredits } from "./UsageLimitsSection"; | |
| 26 | +import { useProviderColors } from "./usageProviders"; | |
| 27 | + | |
| 28 | +const DRIVER_LABEL: Partial<Record<string, string>> = { codex: "Codex", claudeAgent: "Claude" }; | |
| 29 | +const PACE_LABEL = { ahead: "Ahead of pace", on: "On pace", under: "Under pace" } as const; | |
| 30 | + | |
| 31 | +function accountName(account: LimitAccount) { | |
| 32 | + if (account.displayName) return account.displayName; | |
| 33 | + if (!account.email) return DRIVER_LABEL[account.driver] ?? String(account.driver); | |
| 34 | + const [local = "", domain = ""] = account.email.split("@"); | |
| 35 | + return `${local[0] ?? ""}${domain[0] ?? ""}`.toUpperCase() || "Account"; | |
| 36 | +} | |
| 37 | + | |
| 38 | +/** The spent share comes back at reset. SVG keeps the hatching static on both platforms. */ | |
| 39 | +function AccountSegment({ | |
| 40 | + remaining, | |
| 41 | + color, | |
| 42 | + pending, | |
| 43 | +}: { | |
| 44 | + readonly remaining: number; | |
| 45 | + readonly color: string; | |
| 46 | + readonly pending: boolean; | |
| 47 | +}) { | |
| 48 | + const patternId = useId().replace(/:/g, ""); | |
| 49 | + return ( | |
| 50 | + <Svg width="100%" height="100%" accessible={false}> | |
| 51 | + <Defs> | |
| 52 | + <Pattern id={patternId} width={6} height={6} patternUnits="userSpaceOnUse"> | |
| 53 | + <Path d="M-1 1L1 -1M0 6L6 0M5 7L7 5" stroke={color} strokeWidth={1} opacity={0.22} /> | |
| 54 | + </Pattern> | |
| 55 | + </Defs> | |
| 56 | + {pending ? ( | |
| 57 | + <Rect | |
| 58 | + x={`${remaining}%`} | |
| 59 | + width={`${100 - remaining}%`} | |
| 60 | + height="100%" | |
| 61 | + fill={`url(#${patternId})`} | |
| 62 | + /> | |
| 63 | + ) : null} | |
| 64 | + <Rect width={`${remaining}%`} height="100%" fill={color} opacity={0.35} /> | |
| 65 | + </Svg> | |
| 66 | + ); | |
| 67 | +} | |
| 68 | + | |
| 69 | +function PoolWindowCard({ | |
| 70 | + pool, | |
| 71 | + color, | |
| 72 | + now, | |
| 73 | + environmentIds, | |
| 74 | +}: { | |
| 75 | + readonly pool: LimitPoolWindow; | |
| 76 | + readonly color: string; | |
| 77 | + readonly now: number; | |
| 78 | + readonly environmentIds: readonly string[] | null; | |
| 79 | +}) { | |
| 80 | + const navigation = useNavigation(); | |
| 81 | + const nextRefill = pool.resets.find((reset) => reset.restoresPercent > 0); | |
| 82 | + const openAccount = (account: LimitAccount) => | |
| 83 | + navigation.navigate("SettingsSheet", { | |
| 84 | + screen: "SettingsContent", | |
| 85 | + params: { | |
| 86 | + screen: "SettingsUsageAccount", | |
| 87 | + params: { | |
| 88 | + accountKey: account.key, | |
| 89 | + windowId: pool.id, | |
| 90 | + windowKind: pool.kind, | |
| 91 | + environmentIds, | |
| 92 | + now, | |
| 93 | + }, | |
| 94 | + }, | |
| 95 | + }); | |
| 96 | + return ( | |
| 97 | + <View className="gap-3 rounded-[24px] border-continuous bg-card p-4"> | |
| 98 | + <View className="flex-row items-start justify-between gap-3"> | |
| 99 | + <View className="gap-1"> | |
| 100 | + <Text className="text-sm font-t3-medium text-foreground">{pool.label}</Text> | |
| 101 | + <View className="flex-row items-baseline gap-1.5"> | |
| 102 | + <Text className="text-3xl font-t3-bold tabular-nums text-foreground"> | |
| 103 | + {pool.remainingPercent}% | |
| 104 | + </Text> | |
| 105 | + <Text className="text-sm text-foreground-muted">left</Text> | |
| 106 | + </View> | |
| 107 | + </View> | |
| 108 | + {pool.pace ? ( | |
| 109 | + <Text className="text-xs text-foreground-tertiary">{PACE_LABEL[pool.pace]}</Text> | |
| 110 | + ) : null} | |
| 111 | + </View> | |
| 112 | + {nextRefill ? ( | |
| 113 | + <Text className="text-xs tabular-nums text-foreground-muted"> | |
| 114 | + ↻ +{nextRefill.restoresPercent}%{" "} | |
| 115 | + {nextRefill.at <= now ? "now" : `in ${formatDuration(nextRefill.at - now)}`} | |
| 116 | + </Text> | |
| 117 | + ) : null} | |
| 118 | + <View className="flex-row gap-1"> | |
| 119 | + {pool.members.map(({ account, window }, index) => ( | |
| 120 | + <Pressable | |
| 121 | + key={account.key} | |
| 122 | + accessibilityRole="button" | |
| 123 | + accessibilityLabel={`Segment ${index + 1}, ${accountName(account)}, ${remainingPercent(window)}% left`} | |
| 124 | + accessibilityHint="Show account details" | |
| 125 | + onPress={() => openAccount(account)} | |
| 126 | + className="h-7 min-w-0 flex-1 overflow-hidden rounded-md bg-subtle" | |
| 127 | + > | |
| 128 | + <AccountSegment | |
| 129 | + remaining={remainingPercent(window)} | |
| 130 | + color={color} | |
| 131 | + pending={Boolean(window.resetsAt)} | |
| 132 | + /> | |
| 133 | + <View pointerEvents="none" className="absolute inset-0 items-center justify-center"> | |
| 134 | + <Text className="text-xs font-t3-medium tabular-nums text-foreground"> | |
| 135 | + {index + 1} | |
| 136 | + </Text> | |
| 137 | + </View> | |
| 138 | + </Pressable> | |
| 139 | + ))} | |
| 140 | + </View> | |
| 141 | + <View> | |
| 142 | + {pool.members.map(({ account, window }, index) => { | |
| 143 | + const credits = account.redeem ? (account.limits.resetCredits?.availableCount ?? 0) : 0; | |
| 144 | + const resetsIn = formatResetsIn(window, now); | |
| 145 | + return ( | |
| 146 | + <Pressable | |
| 147 | + key={account.key} | |
| 148 | + accessibilityRole="button" | |
| 149 | + accessibilityLabel={`Segment ${index + 1}, ${accountName(account)}, ${remainingPercent(window)}% left${resetsIn ? `, ${resetsIn}` : ""}${credits ? `, ${credits} reset credits banked` : ""}`} | |
| 150 | + accessibilityHint="Show account details" | |
| 151 | + onPress={() => openAccount(account)} | |
| 152 | + className="min-h-[44px] flex-row items-center gap-2 active:opacity-60" | |
| 153 | + > | |
| 154 | + <View className="size-5 items-center justify-center overflow-hidden rounded-md bg-subtle-strong"> | |
| 155 | + <Text className="text-xs font-t3-medium tabular-nums text-foreground"> | |
| 156 | + {index + 1} | |
| 157 | + </Text> | |
| 158 | + </View> | |
| 159 | + <Text | |
| 160 | + numberOfLines={1} | |
| 161 | + className="min-w-0 flex-1 text-sm font-t3-medium text-foreground" | |
| 162 | + > | |
| 163 | + {accountName(account)} | |
| 164 | + </Text> | |
| 165 | + <Text className="text-sm font-t3-medium tabular-nums text-foreground"> | |
| 166 | + {remainingPercent(window)}% | |
| 167 | + </Text> | |
| 168 | + <View className="flex-row items-center gap-1"> | |
| 169 | + {resetsIn ? ( | |
| 170 | + <Text className="text-xs tabular-nums text-foreground-muted"> | |
| 171 | + {resetsIn.replace("resets in ", "↻ ")} | |
| 172 | + </Text> | |
| 173 | + ) : null} | |
| 174 | + {credits ? ( | |
| 175 | + <> | |
| 176 | + {resetsIn ? <Text className="text-xs text-foreground-tertiary">·</Text> : null} | |
| 177 | + <SymbolView name="ticket" size={13} tintColorClassName="accent-icon" /> | |
| 178 | + <Text className="text-xs font-t3-medium tabular-nums text-foreground"> | |
| 179 | + {credits} | |
| 180 | + </Text> | |
| 181 | + </> | |
| 182 | + ) : null} | |
| 183 | + </View> | |
| 184 | + </Pressable> | |
| 185 | + ); | |
| 186 | + })} | |
| 187 | + </View> | |
| 188 | + </View> | |
| 189 | + ); | |
| 190 | +} | |
| 191 | + | |
| 192 | +export function UsageLimitsSection({ | |
| 193 | + now, | |
| 194 | + failedLabels, | |
| 195 | + selectedEnvironmentIds, | |
| 196 | +}: { | |
| 197 | + readonly now: number; | |
| 198 | + readonly failedLabels: readonly string[]; | |
| 199 | + readonly selectedEnvironmentIds: ReadonlySet<EnvironmentId> | null; | |
| 200 | +}) { | |
| 201 | + const presentations = useAtomValue(environmentPresentations.presentationsAtom); | |
| 202 | + const selected = | |
| 203 | + selectedEnvironmentIds === null | |
| 204 | + ? presentations | |
| 205 | + : new Map([...presentations].filter(([id]) => selectedEnvironmentIds.has(id))); | |
| 206 | + const pools = collectLimitPools(collectLimitAccounts(selected), now); | |
| 207 | + const notices = collectLimitNotices(selected); | |
| 208 | + const colors = useProviderColors(); | |
| 209 | + return ( | |
| 210 | + <View className="gap-6"> | |
| 211 | + {failedLabels.length ? ( | |
| 212 | + <Text className="text-sm text-foreground-muted"> | |
| 213 | + {failedLabels.join(", ")} could not refresh limits. Showing the last known values. | |
| 214 | + </Text> | |
| 215 | + ) : null} | |
| 216 | + {pools.length === 0 ? ( | |
| 217 | + <Text className="py-12 text-center text-base text-foreground-muted"> | |
| 218 | + {selected.size === 0 | |
| 219 | + ? "Select an environment to see limits." | |
| 220 | + : "No provider on the selected environments reports subscription limits."} | |
| 221 | + </Text> | |
| 222 | + ) : null} | |
| 223 | + {pools.map((pool) => ( | |
| 224 | + <View key={pool.driver} className="gap-3"> | |
| 225 | + <View className="flex-row items-center gap-2 px-1"> | |
| 226 | + <ProviderIcon provider={pool.driver} size={18} /> | |
| 227 | + <Text className="text-base font-t3-medium text-foreground"> | |
| 228 | + {DRIVER_LABEL[pool.driver] ?? pool.driver} | |
| 229 | + </Text> | |
| 230 | + </View> | |
| 231 | + {pool.windows.map((window) => ( | |
| 232 | + <PoolWindowCard | |
| 233 | + key={`${window.kind}:${window.id}`} | |
| 234 | + pool={window} | |
| 235 | + color={pool.driver === "claudeAgent" ? colors.claude : colors.codex} | |
| 236 | + now={now} | |
| 237 | + environmentIds={selectedEnvironmentIds === null ? null : [...selectedEnvironmentIds]} | |
| 238 | + /> | |
| 239 | + ))} | |
| 240 | + </View> | |
| 241 | + ))} | |
| 242 | + {notices.map((notice) => ( | |
| 243 | + <Text key={notice} className="text-sm text-foreground-muted"> | |
| 244 | + {notice} | |
| 245 | + </Text> | |
| 246 | + ))} | |
| 247 | + </View> | |
| 248 | + ); | |
| 249 | +} | |
| 250 | + | |
| 251 | +type AccountScreenProps = StaticScreenProps<{ | |
| 252 | + accountKey: string; | |
| 253 | + windowId: string; | |
| 254 | + windowKind: LimitPoolWindow["kind"]; | |
| 255 | + environmentIds: readonly string[] | null; | |
| 256 | + now: number; | |
| 257 | +}>; | |
| 258 | + | |
| 259 | +/** Resolve the account again so live quota and credit updates reach the open detail screen. */ | |
| 260 | +export function UsageLimitAccountScreen({ route }: AccountScreenProps) { | |
| 261 | + const navigation = useNavigation(); | |
| 262 | + const insets = useSafeAreaInsets(); | |
| 263 | + const presentations = useAtomValue(environmentPresentations.presentationsAtom); | |
| 264 | + const { accountKey, windowId, windowKind, environmentIds, now } = route.params; | |
| 265 | + const selectedIds = | |
| 266 | + environmentIds === null ? null : new Set(environmentIds.map((id) => EnvironmentId.make(id))); | |
| 267 | + const selected = | |
| 268 | + selectedIds === null | |
| 269 | + ? presentations | |
| 270 | + : new Map([...presentations].filter(([id]) => selectedIds.has(id))); | |
| 271 | + const accounts = collectLimitAccounts(selected); | |
| 272 | + const account = accounts.find((candidate) => candidate.key === accountKey); | |
| 273 | + const pool = collectLimitPools(accounts, now) | |
| 274 | + .find((candidate) => candidate.driver === account?.driver) | |
| 275 | + ?.windows.find((candidate) => candidate.id === windowId && candidate.kind === windowKind); | |
| 276 | + const window = pool?.members.find((member) => member.account.key === accountKey)?.window; | |
| 277 | + const reset = pool?.resets.find((candidate) => candidate.member.account.key === accountKey); | |
| 278 | + const [revealed, setRevealed] = useState(false); | |
| 279 | + return ( | |
| 280 | + <View collapsable={false} className="flex-1 bg-sheet"> | |
| 281 | + {Platform.OS === "android" ? ( | |
| 282 | + <> | |
| 283 | + <NativeStackScreenOptions options={{ headerShown: false }} /> | |
| 284 | + <AndroidScreenHeader title="Account" onBack={() => navigation.goBack()} /> | |
| 285 | + </> | |
| 286 | + ) : null} | |
| 287 | + <ScrollView | |
| 288 | + contentInsetAdjustmentBehavior="automatic" | |
| 289 | + contentContainerClassName="gap-5 p-5" | |
| 290 | + contentContainerStyle={{ paddingBottom: insets.bottom + 24 }} | |
| 291 | + > | |
| 292 | + {!account || !window ? ( | |
| 293 | + <Text className="text-base text-foreground-muted"> | |
| 294 | + This account is no longer reporting limits on the selected environments. | |
| 295 | + </Text> | |
| 296 | + ) : ( | |
| 297 | + <> | |
| 298 | + <View className="gap-2"> | |
| 299 | + <View className="flex-row items-center gap-2"> | |
| 300 | + <ProviderIcon provider={account.driver} size={24} /> | |
| 301 | + <Text className="flex-1 text-xl font-t3-bold text-foreground"> | |
| 302 | + {account.displayName ?? DRIVER_LABEL[account.driver] ?? account.driver} | |
| 303 | + </Text> | |
| 304 | + </View> | |
| 305 | + {account.email ? ( | |
| 306 | + <Pressable | |
| 307 | + accessibilityRole="button" | |
| 308 | + accessibilityLabel={revealed ? "Hide account email" : "Reveal account email"} | |
| 309 | + onPress={() => setRevealed((value) => !value)} | |
| 310 | + className="min-h-[44px] justify-center" | |
| 311 | + > | |
| 312 | + <Text className="text-sm text-foreground-muted"> | |
| 313 | + {revealed ? account.email : "••••••@••••••"} | |
| 314 | + </Text> | |
| 315 | + </Pressable> | |
| 316 | + ) : null} | |
| 317 | + {account.plan ? ( | |
| 318 | + <Text selectable className="text-sm text-foreground-muted"> | |
| 319 | + {account.plan} | |
| 320 | + </Text> | |
| 321 | + ) : null} | |
| 322 | + </View> | |
| 323 | + <View className="gap-3 rounded-[24px] border-continuous bg-card p-4"> | |
| 324 | + <Text className="text-sm font-t3-medium text-foreground">{window.label}</Text> | |
| 325 | + <Text className="text-3xl font-t3-bold tabular-nums text-foreground"> | |
| 326 | + {remainingPercent(window)}% left | |
| 327 | + </Text> | |
| 328 | + {window.resetsAt ? ( | |
| 329 | + <Text selectable className="text-sm text-foreground-muted"> | |
| 330 | + Resets{" "} | |
| 331 | + {new Date(window.resetsAt).toLocaleString(undefined, { | |
| 332 | + dateStyle: "medium", | |
| 333 | + timeStyle: "short", | |
| 334 | + })} | |
| 335 | + </Text> | |
| 336 | + ) : null} | |
| 337 | + {reset && reset.restoresPercent > 0 ? ( | |
| 338 | + <Text className="text-sm text-foreground-muted"> | |
| 339 | + Restores {reset.restoresPercent}% of the pool | |
| 340 | + </Text> | |
| 341 | + ) : null} | |
| 342 | + </View> | |
| 343 | + <View className="gap-2 rounded-[24px] border-continuous bg-card p-4"> | |
| 344 | + <Text className="text-sm font-t3-medium text-foreground"> | |
| 345 | + {account.environments.length ? "Signed in" : "Source"} | |
| 346 | + </Text> | |
| 347 | + {account.environments.length ? ( | |
| 348 | + account.environments.map((environment) => ( | |
| 349 | + <Text key={environment.environmentId} className="text-sm text-foreground-muted"> | |
| 350 | + {environment.label} | |
| 351 | + </Text> | |
| 352 | + )) | |
| 353 | + ) : ( | |
| 354 | + <Text className="text-sm text-foreground-muted">{account.sourceLabel}</Text> | |
| 355 | + )} | |
| 356 | + </View> | |
| 357 | + {account.redeem && account.limits.resetCredits ? ( | |
| 358 | + <View className="gap-3 rounded-[24px] border-continuous bg-card p-4"> | |
| 359 | + <Text className="text-sm font-t3-medium text-foreground">Reset credits</Text> | |
| 360 | + <ResetCredits | |
| 361 | + key={`${account.redeem.environmentId}:${account.redeem.instanceId}`} | |
| 362 | + environmentId={account.redeem.environmentId} | |
| 363 | + instanceId={account.redeem.instanceId} | |
| 364 | + credits={account.limits.resetCredits} | |
| 365 | + now={now} | |
| 366 | + /> | |
| 367 | + </View> | |
| 368 | + ) : null} | |
| 369 | + </> | |
| 370 | + )} | |
| 371 | + </ScrollView> | |
| 372 | + </View> | |
| 373 | + ); | |
| 374 | +} | |
| @@ -6,18 +6,14 @@ import type { · 5 unmodified lines | ||
| 6 | 6 | ServerProvider, |
| 7 | 7 | ServerProviderResetCredits, |
| 8 | 8 | ServerProviderUsageWindow, |
| 9 | − UsageLimitSourceAccount, | |
| 10 | 9 | UsageProviderKind, |
| 11 | 10 | } from "@t3tools/contracts"; |
| 12 | 11 | import { |
| 13 | − collectLimitSources, | |
| 14 | − collectLimitsGroups, | |
| 15 | 12 | elapsedShare, |
| 16 | 13 | formatDuration, |
| 17 | 14 | formatResetsIn, |
| 18 | 15 | limitsNotice, |
| 19 | 16 | paceOf, |
| 20 | − providerLimitsLabel, | |
| 21 | 17 | remainingPercent, |
| 22 | 18 | } from "@t3tools/shared/usageLimits"; |
| 23 | 19 | import { type ReactNode, useState } from "react"; |
| @@ -28,11 +24,9 @@ import { ProviderIcon } from "../../components/ProviderIcon"; · 4 unmodified lines | ||
| 28 | 24 | import { environmentPresentations } from "../../state/presentation"; |
| 29 | 25 | import { serverEnvironment } from "../../state/server"; |
| 30 | 26 | import { useAtomCommand } from "../../state/use-atom-command"; |
| 31 | −import { SettingsSection } from "../settings/components/SettingsSection"; | |
| 32 | 27 | import { useProviderColors } from "./usageProviders"; |
| 33 | 28 | |
| 34 | 29 | const PACE_LABEL = { ahead: "ahead of pace", on: "on pace", under: "under pace" } as const; |
| 35 | −const DRIVER_LABEL: Partial<Record<string, string>> = { codex: "Codex", claudeAgent: "Claude" }; | |
| 36 | 30 | |
| 37 | 31 | type Driver = ServerProvider["driver"]; |
| 38 | 32 | |
| @@ -186,7 +180,7 @@ export function ResetCredits(props: { · 147 unmodified lines | ||
| 186 | 180 | }); |
| 187 | 181 | const [busy, setBusy] = useState(false); |
| 188 | 182 | const [status, setStatus] = useState<string | null>(null); |
| 189 | − if (credits.availableCount === 0 && status === null) return null; | |
| 183 | + if (dense && credits.availableCount === 0 && status === null) return null; | |
| 190 | 184 | |
| 191 | 185 | const expiresIn = credits.nextExpiresAt |
| 192 | 186 | ? formatDuration(Date.parse(credits.nextExpiresAt) - now) |
| @@ -237,7 +231,7 @@ export function ResetCredits(props: { · 44 unmodified lines | ||
| 237 | 231 | className={ |
| 238 | 232 | dense |
| 239 | 233 | ? "rounded-full bg-subtle-strong px-2.5 py-1" |
| 240 | − : "rounded-full bg-subtle-strong px-3 py-1.5" | |
| 234 | + : "min-h-[44px] justify-center rounded-full bg-subtle-strong px-3 py-1.5" | |
| 241 | 235 | } |
| 242 | 236 | > |
| 243 | 237 | <Text |
| @@ -256,57 +250,6 @@ export function ResetCredits(props: { · 12 unmodified lines | ||
| 256 | 250 | ); |
| 257 | 251 | } |
| 258 | 252 | |
| 259 | −function ProviderLimits(props: { | |
| 260 | − readonly provider: ServerProvider; | |
| 261 | − readonly environmentId: EnvironmentId; | |
| 262 | − readonly now: number; | |
| 263 | − readonly first: boolean; | |
| 264 | −}) { | |
| 265 | − const { provider, environmentId, now } = props; | |
| 266 | − const credits = provider.usageLimits?.resetCredits; | |
| 267 | − return ( | |
| 268 | − <AccountLimits | |
| 269 | − driver={provider.driver} | |
| 270 | − label={DRIVER_LABEL[provider.driver] ?? String(provider.driver)} | |
| 271 | − instanceLabel={providerLimitsLabel(provider, (driver) => DRIVER_LABEL[driver])} | |
| 272 | − detail={provider.auth.label} | |
| 273 | − limits={provider.usageLimits} | |
| 274 | − now={now} | |
| 275 | − first={props.first} | |
| 276 | − footer={ | |
| 277 | − credits ? ( | |
| 278 | − <ResetCredits | |
| 279 | − environmentId={environmentId} | |
| 280 | − instanceId={provider.instanceId} | |
| 281 | − credits={credits} | |
| 282 | − now={now} | |
| 283 | − /> | |
| 284 | − ) : undefined | |
| 285 | − } | |
| 286 | − /> | |
| 287 | − ); | |
| 288 | −} | |
| 289 | − | |
| 290 | −/** Emails stay off the phone screen; the plan and driver identify the row. */ | |
| 291 | −function SourceAccountLimits(props: { | |
| 292 | − readonly account: UsageLimitSourceAccount; | |
| 293 | − readonly now: number; | |
| 294 | − readonly first: boolean; | |
| 295 | −}) { | |
| 296 | − const { account } = props; | |
| 297 | − return ( | |
| 298 | − <AccountLimits | |
| 299 | − driver={account.driver} | |
| 300 | − label={DRIVER_LABEL[account.driver] ?? String(account.driver)} | |
| 301 | − instanceLabel="CLI Proxy" | |
| 302 | − detail={account.plan} | |
| 303 | − limits={account.usageLimits} | |
| 304 | − now={props.now} | |
| 305 | − first={props.first} | |
| 306 | − /> | |
| 307 | − ); | |
| 308 | −} | |
| 309 | − | |
| 310 | 253 | /** |
| 311 | 254 | * Re-probes every provider (and usage-limit source) on each connected |
| 312 | 255 | * environment; the fresh snapshots then arrive over the config stream. |
| @@ -315,107 +258,47 @@ function SourceAccountLimits(props: { · 2 unmodified lines | ||
| 315 | 258 | * Environments whose probe failed are named, since their rows keep showing |
| 316 | 259 | * the previous quota with nothing else to say so. |
| 317 | 260 | */ |
| 318 | −export function useRefreshLimits() { | |
| 261 | +export function useRefreshLimits(selectedEnvironmentIds: ReadonlySet<EnvironmentId> | null = null) { | |
| 319 | 262 | const presentations = useAtomValue(environmentPresentations.presentationsAtom); |
| 320 | 263 | const refreshProviders = useAtomCommand(serverEnvironment.refreshProviders, { |
| 321 | 264 | reportFailure: false, |
| 322 | 265 | }); |
| 323 | 266 | const [now, setNow] = useState(() => Date.now()); |
| 324 | 267 | const [refreshing, setRefreshing] = useState(false); |
| 325 | − const [failedLabels, setFailedLabels] = useState<readonly string[]>([]); | |
| 268 | + const [failedEnvironments, setFailedEnvironments] = useState< | |
| 269 | + readonly { environmentId: EnvironmentId; label: string }[] | |
| 270 | + >([]); | |
| 326 | 271 | // Always toggles `refreshing`, even with nothing to probe: Android's |
| 327 | 272 | // RefreshControl keeps its spinner up until it sees true then false. |
| 328 | 273 | const refresh = async () => { |
| 329 | 274 | const connected = [...presentations].filter( |
| 330 | − ([, presentation]) => presentation.connection.phase === "connected", | |
| 275 | + ([environmentId, presentation]) => | |
| 276 | + presentation.connection.phase === "connected" && | |
| 277 | + (selectedEnvironmentIds === null || selectedEnvironmentIds.has(environmentId)), | |
| 331 | 278 | ); |
| 332 | 279 | setRefreshing(true); |
| 333 | 280 | try { |
| 334 | 281 | const results = await Promise.all( |
| 335 | 282 | connected.map(([environmentId]) => refreshProviders({ environmentId, input: {} })), |
| 336 | 283 | ); |
| 337 | − setFailedLabels( | |
| 284 | + setFailedEnvironments( | |
| 338 | 285 | connected |
| 339 | 286 | .filter((_, index) => results[index]?._tag === "Failure") |
| 340 | − .map(([, presentation]) => presentation.entry.target.label), | |
| 287 | + .map(([environmentId, presentation]) => ({ | |
| 288 | + environmentId, | |
| 289 | + label: presentation.entry.target.label, | |
| 290 | + })), | |
| 341 | 291 | ); |
| 342 | 292 | } finally { |
| 343 | 293 | setNow(Date.now()); |
| 344 | 294 | setRefreshing(false); |
| 345 | 295 | } |
| 346 | 296 | }; |
| 297 | + const failedLabels = failedEnvironments | |
| 298 | + .filter( | |
| 299 | + ({ environmentId }) => | |
| 300 | + selectedEnvironmentIds === null || selectedEnvironmentIds.has(environmentId), | |
| 301 | + ) | |
| 302 | + .map(({ label }) => label); | |
| 347 | 303 | return { now, refreshing, failedLabels, refresh }; |
| 348 | 304 | } |
| 349 | − | |
| 350 | −/** | |
| 351 | − * Subscription quota windows from every connected environment's providers, | |
| 352 | − * read from the config each environment already streams. | |
| 353 | − */ | |
| 354 | −export function UsageLimitsSection(props: { | |
| 355 | − readonly now: number; | |
| 356 | − readonly failedLabels: readonly string[]; | |
| 357 | −}) { | |
| 358 | − const { now } = props; | |
| 359 | − const presentations = useAtomValue(environmentPresentations.presentationsAtom); | |
| 360 | − const groups = collectLimitsGroups(presentations); | |
| 361 | − const sources = collectLimitSources(presentations); | |
| 362 | − | |
| 363 | − if (groups.length === 0 && sources.length === 0) { | |
| 364 | − return ( | |
| 365 | − <Text className="py-16 text-center text-base text-foreground-muted"> | |
| 366 | − No provider on a connected environment reports subscription limits. | |
| 367 | − </Text> | |
| 368 | − ); | |
| 369 | − } | |
| 370 | − | |
| 371 | − return ( | |
| 372 | − <> | |
| 373 | − {props.failedLabels.length > 0 ? ( | |
| 374 | − <View className="rounded-[16px] border-continuous bg-card px-4 py-3"> | |
| 375 | − <Text className="text-sm text-foreground-muted"> | |
| 376 | − {props.failedLabels.join(", ")} could not refresh limits. Showing the last known values. | |
| 377 | − </Text> | |
| 378 | − </View> | |
| 379 | − ) : null} | |
| 380 | − {groups.map((group) => ( | |
| 381 | − <SettingsSection | |
| 382 | − key={group.environmentId} | |
| 383 | − title={group.environmentLabel ?? "Providers"} | |
| 384 | − card | |
| 385 | − > | |
| 386 | − {group.providers.map((provider, index) => ( | |
| 387 | − <ProviderLimits | |
| 388 | − key={provider.instanceId} | |
| 389 | − provider={provider} | |
| 390 | − environmentId={group.environmentId} | |
| 391 | − now={now} | |
| 392 | − first={index === 0} | |
| 393 | − /> | |
| 394 | − ))} | |
| 395 | − </SettingsSection> | |
| 396 | − ))} | |
| 397 | − {sources.map((source) => ( | |
| 398 | − <SettingsSection key={source.key} card> | |
| 399 | − {source.error ? ( | |
| 400 | − <Text className="p-4 text-sm text-foreground-muted">{source.error}</Text> | |
| 401 | − ) : source.accounts.length === 0 ? ( | |
| 402 | − <Text className="p-4 text-sm text-foreground-muted"> | |
| 403 | − {source.hiddenAccountCount > 0 | |
| 404 | − ? "All accounts are shown by connected providers." | |
| 405 | − : "No accounts reported."} | |
| 406 | − </Text> | |
| 407 | − ) : ( | |
| 408 | − source.accounts.map((account, index) => ( | |
| 409 | − <SourceAccountLimits | |
| 410 | − key={account.id} | |
| 411 | − account={account} | |
| 412 | − now={now} | |
| 413 | − first={index === 0} | |
| 414 | − /> | |
| 415 | − )) | |
| 416 | − )} | |
| 417 | − </SettingsSection> | |
| 418 | − ))} | |
| 419 | − </> | |
| 420 | − ); | |
| 421 | −} | |
| @@ -1,5 +1,10 @@ | ||
| 1 | +import { EnvironmentId, USAGE_CONTRACT_VERSION } from "@t3tools/contracts"; | |
| 1 | 2 | import { useNavigation } from "@react-navigation/native"; |
| 2 | −import type { DailyTotals, MergedUsage } from "@t3tools/shared/usageMerge"; | |
| 3 | +import { | |
| 4 | + isCompatibleUsageContractVersion, | |
| 5 | + type DailyTotals, | |
| 6 | + type MergedUsage, | |
| 7 | +} from "@t3tools/shared/usageMerge"; | |
| 3 | 8 | import { |
| 4 | 9 | enumerateDays, |
| 5 | 10 | enumerateHourStarts, |
| @@ -11,8 +16,9 @@ import { · 5 unmodified lines | ||
| 11 | 16 | formatUsd, |
| 12 | 17 | makeWindow, |
| 13 | 18 | } from "@t3tools/shared/usageFormat"; |
| 14 | −import { useMemo, useRef, useState } from "react"; | |
| 19 | +import { useCallback, useLayoutEffect, useMemo, useRef, useState } from "react"; | |
| 15 | 20 | import { Platform, Pressable, RefreshControl, ScrollView, View } from "react-native"; |
| 21 | +import Animated, { Easing, FadeIn, LinearTransition, ReduceMotion } from "react-native-reanimated"; | |
| 16 | 22 | import { useSafeAreaInsets } from "react-native-safe-area-context"; |
| 17 | 23 | |
| 18 | 24 | import { AndroidScreenHeader } from "../../components/AndroidScreenHeader"; |
| @@ -22,7 +28,11 @@ import { NativeStackScreenOptions } from "../../native/StackHeader"; · 3 unmodified lines | ||
| 22 | 28 | import { useUsage, type EnvironmentUsageStatus } from "../../state/usage"; |
| 23 | 29 | import { SettingsSection } from "../settings/components/SettingsSection"; |
| 24 | 30 | import { UsageDailyChart } from "./UsageDailyChart"; |
| 25 | −import { UsageLimitsSection, useRefreshLimits } from "./UsageLimitsSection"; | |
| 31 | +import { toggleUsageEnvironment } from "./usageEnvironmentSelection"; | |
| 32 | +import { useRefreshLimits } from "./UsageLimitsSection"; | |
| 33 | +import { UsageLimitsSection } from "./UsageLimitsPooled"; | |
| 34 | +import { ControlPillMenu } from "../../components/ControlPill"; | |
| 35 | +import { SymbolView } from "../../components/AppSymbol"; | |
| 26 | 36 | import type { UsageChartMetric } from "./usageChartData"; |
| 27 | 37 | import { PROVIDER_LABEL, useProviderColors } from "./usageProviders"; |
| 28 | 38 | |
| @@ -64,8 +74,13 @@ export function UsageRouteScreen() { · 35 unmodified lines | ||
| 64 | 74 | const [metric, setMetric] = useState<UsageChartMetric>("cost"); |
| 65 | 75 | const { days: windowDays, window } = windowSelection; |
| 66 | 76 | const isPast24Hours = windowDays === 1; |
| 67 | − const { merged, environments, isPending, isPartial, refresh } = useUsage(window); | |
| 68 | − const limits = useRefreshLimits(); | |
| 77 | + const [selectedEnvironmentIds, setSelectedEnvironmentIds] = | |
| 78 | + useState<ReadonlySet<EnvironmentId> | null>(null); | |
| 79 | + const { merged, environments, selectedEnvironments, isPending, refresh } = useUsage( | |
| 80 | + window, | |
| 81 | + selectedEnvironmentIds, | |
| 82 | + ); | |
| 83 | + const limits = useRefreshLimits(selectedEnvironmentIds); | |
| 69 | 84 | |
| 70 | 85 | const days = useMemo( |
| 71 | 86 | () => enumerateDays(window.sinceDay, window.untilDay), |
| @@ -119,18 +134,104 @@ export function UsageRouteScreen() { · 47 unmodified lines | ||
| 119 | 134 | }); |
| 120 | 135 | }; |
| 121 | 136 | |
| 137 | + const showEnvironmentFilter = environments.length > 0 || selectedEnvironmentIds !== null; | |
| 138 | + const hasLoadingEnvironments = selectedEnvironments.some(isUsageLoading); | |
| 139 | + const filterAccessibilityLabel = hasLoadingEnvironments | |
| 140 | + ? "Filter usage environments, some environments are loading" | |
| 141 | + : "Filter usage environments"; | |
| 142 | + const filterIcon = | |
| 143 | + selectedEnvironmentIds === null | |
| 144 | + ? "line.3.horizontal.decrease" | |
| 145 | + : "line.3.horizontal.decrease.circle.fill"; | |
| 146 | + const environmentActions = useMemo( | |
| 147 | + () => [ | |
| 148 | + { | |
| 149 | + id: "all", | |
| 150 | + title: "All environments", | |
| 151 | + subtitle: undefined, | |
| 152 | + state: selectedEnvironmentIds === null ? ("on" as const) : ("off" as const), | |
| 153 | + }, | |
| 154 | + ...environments.map((environment) => ({ | |
| 155 | + id: environment.environmentId, | |
| 156 | + title: environment.label, | |
| 157 | + subtitle: usageEnvironmentStatus(environment), | |
| 158 | + state: | |
| 159 | + selectedEnvironmentIds === null || selectedEnvironmentIds.has(environment.environmentId) | |
| 160 | + ? ("on" as const) | |
| 161 | + : ("off" as const), | |
| 162 | + })), | |
| 163 | + ], | |
| 164 | + [environments, selectedEnvironmentIds], | |
| 165 | + ); | |
| 166 | + const selectEnvironment = useCallback( | |
| 167 | + (value: string) => { | |
| 168 | + if (value === "all") { | |
| 169 | + setSelectedEnvironmentIds(null); | |
| 170 | + return; | |
| 171 | + } | |
| 172 | + const id = EnvironmentId.make(value); | |
| 173 | + setSelectedEnvironmentIds((selected) => toggleUsageEnvironment(selected, environments, id)); | |
| 174 | + }, | |
| 175 | + [environments], | |
| 176 | + ); | |
| 177 | + const environmentFilter = useMemo( | |
| 178 | + () => | |
| 179 | + showEnvironmentFilter ? ( | |
| 180 | + <ControlPillMenu | |
| 181 | + accessible | |
| 182 | + accessibilityRole="button" | |
| 183 | + accessibilityLabel={filterAccessibilityLabel} | |
| 184 | + title="Environments" | |
| 185 | + actions={environmentActions} | |
| 186 | + onPressAction={({ nativeEvent }) => selectEnvironment(nativeEvent.event)} | |
| 187 | + > | |
| 188 | + <Pressable | |
| 189 | + accessibilityRole="button" | |
| 190 | + accessibilityLabel={filterAccessibilityLabel} | |
| 191 | + className={cn( | |
| 192 | + "items-center justify-center rounded-full", | |
| 193 | + Platform.OS === "ios" ? "size-[28px]" : "size-[44px]", | |
| 194 | + )} | |
| 195 | + > | |
| 196 | + <SymbolView name={filterIcon} size={22} tintColorClassName="accent-icon" /> | |
| 197 | + {hasLoadingEnvironments ? ( | |
| 198 | + <View | |
| 199 | + pointerEvents="none" | |
| 200 | + className="absolute -right-[2px] -top-[2px] size-[9px] rounded-full bg-amber-500" | |
| 201 | + /> | |
| 202 | + ) : null} | |
| 203 | + </Pressable> | |
| 204 | + </ControlPillMenu> | |
| 205 | + ) : null, | |
| 206 | + [ | |
| 207 | + showEnvironmentFilter, | |
| 208 | + environmentActions, | |
| 209 | + selectEnvironment, | |
| 210 | + filterAccessibilityLabel, | |
| 211 | + filterIcon, | |
| 212 | + hasLoadingEnvironments, | |
| 213 | + ], | |
| 214 | + ); | |
| 215 | + | |
| 216 | + useLayoutEffect(() => { | |
| 217 | + if (Platform.OS === "ios") { | |
| 218 | + navigation.setOptions({ headerRight: () => environmentFilter }); | |
| 219 | + } | |
| 220 | + }, [navigation, environmentFilter]); | |
| 221 | + | |
| 122 | 222 | return ( |
| 123 | 223 | <View collapsable={false} className="flex-1 bg-sheet"> |
| 124 | 224 | {Platform.OS === "android" ? ( |
| 125 | 225 | <> |
| 126 | 226 | <NativeStackScreenOptions options={{ headerShown: false }} /> |
| 127 | − <AndroidScreenHeader title="Usage" onBack={() => navigation.goBack()} /> | |
| 227 | + <AndroidScreenHeader | |
| 228 | + title="Usage" | |
| 229 | + onBack={() => navigation.goBack()} | |
| 230 | + trailing={environmentFilter} | |
| 231 | + /> | |
| 128 | 232 | </> |
| 129 | 233 | ) : null} |
| 130 | 234 | <ScrollView |
| 131 | − // Remount at each tab's native top. Scrolling to y: 0 ignores iOS's | |
| 132 | − // automatic header inset and hides the tab bar under the header. | |
| 133 | − key={tab} | |
| 134 | 235 | contentInsetAdjustmentBehavior="automatic" |
| 135 | 236 | showsVerticalScrollIndicator={false} |
| 136 | 237 | className="flex-1" |
| @@ -145,60 +246,73 @@ export function UsageRouteScreen() { · 8 unmodified lines | ||
| 145 | 246 | > |
| 146 | 247 | <SegmentedControl options={TAB_OPTIONS} selected={tab} onSelect={setTab} role="tab" /> |
| 147 | 248 | |
| 148 | − {showingLimits ? ( | |
| 149 | − <UsageLimitsSection now={limits.now} failedLabels={limits.failedLabels} /> | |
| 150 | − ) : ( | |
| 151 | − <> | |
| 152 | − {/* Period and metric together: neither applies to Limits, and | |
| 153 | − both change every number below, so they share one bar. */} | |
| 154 | − <View className="flex-row items-center gap-3"> | |
| 155 | − <SegmentedControl | |
| 156 | − options={WINDOW_OPTIONS} | |
| 157 | − selected={windowDays} | |
| 158 | − onSelect={selectWindow} | |
| 159 | − size="compact" | |
| 160 | − className="flex-1" | |
| 161 | − /> | |
| 162 | − <SegmentedControl | |
| 163 | − options={METRIC_OPTIONS} | |
| 164 | − selected={metric} | |
| 165 | − onSelect={setMetric} | |
| 166 | − size="compact" | |
| 167 | − className="w-36" | |
| 168 | − /> | |
| 169 | − </View> | |
| 170 | − <UsageCoverageNotice | |
| 171 | − environments={environments} | |
| 172 | − merged={merged} | |
| 173 | − isPartial={isPartial} | |
| 249 | + <Animated.View | |
| 250 | + key={tab} | |
| 251 | + entering={FadeIn.duration(160).reduceMotion(ReduceMotion.System)} | |
| 252 | + className="gap-6" | |
| 253 | + > | |
| 254 | + {showingLimits ? ( | |
| 255 | + <UsageLimitsSection | |
| 256 | + now={limits.now} | |
| 257 | + failedLabels={limits.failedLabels} | |
| 258 | + selectedEnvironmentIds={selectedEnvironmentIds} | |
| 174 | 259 | /> |
| 175 | − {isPending ? ( | |
| 176 | − <Text className="py-16 text-center text-base text-foreground-muted"> | |
| 177 | − Scanning provider transcripts… | |
| 178 | − </Text> | |
| 179 | − ) : environments.length === 0 ? ( | |
| 180 | − <Text className="py-16 text-center text-base text-foreground-muted"> | |
| 181 | − Connect an environment to see usage. | |
| 182 | − </Text> | |
| 183 | − ) : ( | |
| 184 | − <> | |
| 185 | − <ChartCard | |
| 186 | − merged={merged} | |
| 187 | − days={chartDays} | |
| 188 | − daily={chartTotals} | |
| 189 | − metric={metric} | |
| 190 | − sinceDay={window.sinceDay} | |
| 191 | − untilDay={window.untilDay} | |
| 192 | − isPast24Hours={isPast24Hours} | |
| 193 | − timeZone={window.timeZone} | |
| 260 | + ) : ( | |
| 261 | + <> | |
| 262 | + {/* Period and metric together: neither applies to Limits, and | |
| 263 | + both change every number below, so they share one bar. */} | |
| 264 | + <View className="flex-row items-center gap-3"> | |
| 265 | + <SegmentedControl | |
| 266 | + options={WINDOW_OPTIONS} | |
| 267 | + selected={windowDays} | |
| 268 | + onSelect={selectWindow} | |
| 269 | + size="compact" | |
| 270 | + className="flex-1" | |
| 194 | 271 | /> |
| 195 | − <ProviderSection merged={merged} metric={metric} /> | |
| 196 | − <TotalsSection merged={merged} isPast24Hours={isPast24Hours} /> | |
| 197 | − <ModelsSection merged={merged} /> | |
| 198 | − </> | |
| 199 | − )} | |
| 200 | − </> | |
| 201 | − )} | |
| 272 | + <SegmentedControl | |
| 273 | + options={METRIC_OPTIONS} | |
| 274 | + selected={metric} | |
| 275 | + onSelect={setMetric} | |
| 276 | + size="compact" | |
| 277 | + className="w-36" | |
| 278 | + /> | |
| 279 | + </View> | |
| 280 | + {merged.duplicateSources.length > 0 ? ( | |
| 281 | + <Text className="text-sm text-foreground-muted"> | |
| 282 | + Counted once across environments sharing a transcript directory:{" "} | |
| 283 | + {merged.duplicateSources.join(", ")} | |
| 284 | + </Text> | |
| 285 | + ) : null} | |
| 286 | + {isPending ? ( | |
| 287 | + <Text className="py-16 text-center text-base text-foreground-muted"> | |
| 288 | + Scanning provider transcripts… | |
| 289 | + </Text> | |
| 290 | + ) : selectedEnvironments.length === 0 ? ( | |
| 291 | + <Text className="py-16 text-center text-base text-foreground-muted"> | |
| 292 | + {environments.length === 0 | |
| 293 | + ? "Connect an environment to see usage." | |
| 294 | + : "Select an environment to see usage."} | |
| 295 | + </Text> | |
| 296 | + ) : ( | |
| 297 | + <> | |
| 298 | + <ChartCard | |
| 299 | + merged={merged} | |
| 300 | + days={chartDays} | |
| 301 | + daily={chartTotals} | |
| 302 | + metric={metric} | |
| 303 | + sinceDay={window.sinceDay} | |
| 304 | + untilDay={window.untilDay} | |
| 305 | + isPast24Hours={isPast24Hours} | |
| 306 | + timeZone={window.timeZone} | |
| 307 | + /> | |
| 308 | + <ProviderSection merged={merged} metric={metric} /> | |
| 309 | + <TotalsSection merged={merged} isPast24Hours={isPast24Hours} /> | |
| 310 | + <ModelsSection merged={merged} /> | |
| 311 | + </> | |
| 312 | + )} | |
| 313 | + </> | |
| 314 | + )} | |
| 315 | + </Animated.View> | |
| 202 | 316 | </ScrollView> |
| 203 | 317 | </View> |
| 204 | 318 | ); |
| @@ -221,25 +335,42 @@ function SegmentedControl<Value extends number | string>(props: { · 16 unmodified lines | ||
| 221 | 335 | const compact = props.size === "compact"; |
| 222 | 336 | return ( |
| 223 | 337 | <View |
| 224 | − accessibilityRole={props.role === "tab" ? "tablist" : undefined} | |
| 338 | + accessible={false} | |
| 225 | 339 | className={cn( |
| 226 | 340 | "flex-row overflow-hidden rounded-full border-continuous bg-card", |
| 227 | 341 | props.className, |
| 228 | 342 | )} |
| 229 | 343 | > |
| 344 | + <Animated.View | |
| 345 | + pointerEvents="none" | |
| 346 | + layout={LinearTransition.duration(200) | |
| 347 | + .easing(Easing.out(Easing.cubic)) | |
| 348 | + .reduceMotion(ReduceMotion.System)} | |
| 349 | + className="absolute bottom-0 top-0 rounded-full bg-subtle-strong" | |
| 350 | + style={{ | |
| 351 | + width: `${100 / props.options.length}%`, | |
| 352 | + start: `${ | |
| 353 | + (Math.max( | |
| 354 | + 0, | |
| 355 | + props.options.findIndex((option) => option.value === props.selected), | |
| 356 | + ) * | |
| 357 | + 100) / | |
| 358 | + props.options.length | |
| 359 | + }%`, | |
| 360 | + }} | |
| 361 | + /> | |
| 230 | 362 | {props.options.map((option) => { |
| 231 | 363 | const active = option.value === props.selected; |
| 232 | 364 | return ( |
| 233 | 365 | <Pressable |
| 234 | 366 | key={String(option.value)} |
| 235 | − accessibilityRole={props.role ?? "button"} | |
| 236 | − accessibilityLabel={option.accessibilityLabel} | |
| 367 | + accessibilityRole={Platform.OS === "ios" ? "button" : (props.role ?? "button")} | |
| 368 | + accessibilityLabel={option.accessibilityLabel ?? option.label} | |
| 237 | 369 | accessibilityState={{ selected: active }} |
| 238 | 370 | onPress={() => props.onSelect(option.value)} |
| 239 | 371 | className={cn( |
| 240 | 372 | "flex-1 items-center justify-center rounded-full", |
| 241 | 373 | compact ? "h-9" : "h-11", |
| 242 | − active && "bg-subtle-strong", | |
| 243 | 374 | )} |
| 244 | 375 | > |
| 245 | 376 | <Text |
| @@ -491,48 +622,22 @@ function ModelsSection(props: { readonly merged: MergedUsage }) { · 245 unmodified lines | ||
| 491 | 622 | * one that failed, or one whose transcripts another environment already |
| 492 | 623 | * reported. |
| 493 | 624 | */ |
| 494 | −function UsageCoverageNotice(props: { | |
| 495 | − readonly environments: readonly EnvironmentUsageStatus[]; | |
| 496 | − readonly merged: MergedUsage; | |
| 497 | − readonly isPartial: boolean; | |
| 498 | −}) { | |
| 499 | − const failed = props.environments.filter((environment) => environment.error !== null); | |
| 500 | − const stale = props.environments.filter((environment) => | |
| 501 | − props.merged.staleEnvironments.includes(environment.environmentId), | |
| 502 | − ); | |
| 503 | − const duplicateSources = props.merged.duplicateSources; | |
| 625 | +function isUsageLoading(environment: EnvironmentUsageStatus) { | |
| 626 | + return environment.isPending || (environment.summary === null && environment.error === null); | |
| 627 | +} | |
| 628 | + | |
| 629 | +function usageEnvironmentStatus(environment: EnvironmentUsageStatus): string { | |
| 504 | 630 | if ( |
| 505 | − failed.length === 0 && | |
| 506 | − stale.length === 0 && | |
| 507 | − duplicateSources.length === 0 && | |
| 508 | − !props.isPartial | |
| 631 | + environment.summary && | |
| 632 | + !isCompatibleUsageContractVersion(environment.summary.contractVersion, USAGE_CONTRACT_VERSION) | |
| 509 | 633 | ) { |
| 510 | − return null; | |
| 634 | + return "Older server · excluded from usage totals"; | |
| 511 | 635 | } |
| 512 | − | |
| 513 | − return ( | |
| 514 | − <View className="gap-1 rounded-[16px] border-continuous bg-card px-4 py-3"> | |
| 515 | − {props.isPartial ? ( | |
| 516 | − <Text className="text-sm text-foreground-muted"> | |
| 517 | − Some environments are still reporting. Totals are partial. | |
| 518 | − </Text> | |
| 519 | − ) : null} | |
| 520 | − {failed.map((environment) => ( | |
| 521 | − <Text key={environment.environmentId} className="text-sm text-foreground-muted"> | |
| 522 | − {environment.label} could not report usage. | |
| 523 | − </Text> | |
| 524 | − ))} | |
| 525 | − {stale.map((environment) => ( | |
| 526 | − <Text key={environment.environmentId} className="text-sm text-foreground-muted"> | |
| 527 | − {environment.label} runs an older server version and is excluded from totals. | |
| 528 | − </Text> | |
| 529 | − ))} | |
| 530 | − {duplicateSources.length > 0 ? ( | |
| 531 | − <Text className="text-sm text-foreground-muted"> | |
| 532 | − Counted once across environments sharing a transcript directory:{" "} | |
| 533 | − {duplicateSources.join(", ")} | |
| 534 | − </Text> | |
| 535 | − ) : null} | |
| 536 | − </View> | |
| 537 | − ); | |
| 636 | + if (!environment.isConnected) | |
| 637 | + return environment.summary ? "Disconnected · showing saved usage" : "Waiting for connection…"; | |
| 638 | + if (environment.error) | |
| 639 | + return environment.summary ? "Usage unavailable · showing saved totals" : "Usage unavailable"; | |
| 640 | + if (isUsageLoading(environment)) | |
| 641 | + return environment.summary ? "Updating usage…" : "Loading usage…"; | |
| 642 | + return "Usage up to date"; | |
| 538 | 643 | } |
| @@ -0,0 +1,40 @@ | ||
| 1 | +import { EnvironmentId } from "@t3tools/contracts"; | |
| 2 | +import { describe, expect, it } from "vite-plus/test"; | |
| 3 | + | |
| 4 | +import { toggleUsageEnvironment } from "./usageEnvironmentSelection"; | |
| 5 | + | |
| 6 | +const a = EnvironmentId.make("a"); | |
| 7 | +const b = EnvironmentId.make("b"); | |
| 8 | +const c = EnvironmentId.make("c"); | |
| 9 | +const removed = EnvironmentId.make("removed"); | |
| 10 | +const environments = [a, b, c].map((environmentId) => ({ environmentId })); | |
| 11 | + | |
| 12 | +describe("usage environment selection", () => { | |
| 13 | + it("can exclude an environment from all, then select all again", () => { | |
| 14 | + const selected = toggleUsageEnvironment(null, environments, b); | |
| 15 | + expect(selected).toEqual(new Set([a, c])); | |
| 16 | + expect(toggleUsageEnvironment(selected, environments, b)).toBeNull(); | |
| 17 | + }); | |
| 18 | + | |
| 19 | + it("can deselect the last environment", () => { | |
| 20 | + expect(toggleUsageEnvironment(new Set([a]), environments, a)).toEqual(new Set()); | |
| 21 | + }); | |
| 22 | + | |
| 23 | + it("does not count removed IDs toward selecting all current environments", () => { | |
| 24 | + expect(toggleUsageEnvironment(new Set([a, removed]), environments, b)).toEqual(new Set([a, b])); | |
| 25 | + }); | |
| 26 | + | |
| 27 | + it("returns to all mode despite stale IDs when every current environment is selected", () => { | |
| 28 | + expect(toggleUsageEnvironment(new Set([a, c, removed]), environments, b)).toBeNull(); | |
| 29 | + }); | |
| 30 | + | |
| 31 | + it("ignores a menu action for an environment that was removed", () => { | |
| 32 | + expect(toggleUsageEnvironment(new Set([a]), environments, removed)).toEqual(new Set([a])); | |
| 33 | + }); | |
| 34 | + | |
| 35 | + it("includes newly connected environments only in all mode", () => { | |
| 36 | + const expanded = [...environments, { environmentId: removed }]; | |
| 37 | + expect(toggleUsageEnvironment(null, expanded, a)).toEqual(new Set([b, c, removed])); | |
| 38 | + expect(toggleUsageEnvironment(new Set([a, b, c]), expanded, a)).toEqual(new Set([b, c])); | |
| 39 | + }); | |
| 40 | +}); | |
| @@ -0,0 +1,16 @@ | ||
| 1 | +import type { EnvironmentId } from "@t3tools/contracts"; | |
| 2 | + | |
| 3 | +/** Null follows all environments, including ones connected after the menu opened. */ | |
| 4 | +export function toggleUsageEnvironment( | |
| 5 | + selected: ReadonlySet<EnvironmentId> | null, | |
| 6 | + environments: readonly { readonly environmentId: EnvironmentId }[], | |
| 7 | + toggledId: EnvironmentId, | |
| 8 | +): ReadonlySet<EnvironmentId> | null { | |
| 9 | + const ids = environments.map(({ environmentId }) => environmentId); | |
| 10 | + const next = new Set(ids.filter((id) => selected === null || selected.has(id))); | |
| 11 | + if (ids.includes(toggledId)) { | |
| 12 | + if (next.has(toggledId)) next.delete(toggledId); | |
| 13 | + else next.add(toggledId); | |
| 14 | + } | |
| 15 | + return ids.every((id) => next.has(id)) ? null : next; | |
| 16 | +} | |
| @@ -30,6 +30,7 @@ export interface EnvironmentUsageStatus { · 29 unmodified lines | ||
| 30 | 30 | readonly environmentId: EnvironmentId; |
| 31 | 31 | readonly label: string; |
| 32 | 32 | readonly isPending: boolean; |
| 33 | + readonly isConnected: boolean; | |
| 33 | 34 | readonly error: string | null; |
| 34 | 35 | readonly summary: UsageSummary | null; |
| 35 | 36 | } |
| @@ -53,6 +54,7 @@ const usageByWindowAtom = Atom.family((windowKey: string) => · 17 unmodified lines | ||
| 53 | 54 | environmentId, |
| 54 | 55 | label: presentation.entry.target.label, |
| 55 | 56 | isPending: result.waiting, |
| 57 | + isConnected: presentation.connection.phase === "connected", | |
| 56 | 58 | error: result._tag === "Failure" ? "This environment could not report usage." : null, |
| 57 | 59 | summary: Option.getOrNull(AsyncResult.value(result)), |
| 58 | 60 | }); |
| @@ -64,18 +66,22 @@ const usageByWindowAtom = Atom.family((windowKey: string) => · 5 unmodified lines | ||
| 64 | 66 | export interface UsageView { |
| 65 | 67 | readonly merged: MergedUsage; |
| 66 | 68 | readonly environments: readonly EnvironmentUsageStatus[]; |
| 69 | + readonly selectedEnvironments: readonly EnvironmentUsageStatus[]; | |
| 67 | 70 | /** True until at least one environment has answered. */ |
| 68 | 71 | readonly isPending: boolean; |
| 69 | 72 | /** |
| 70 | 73 | * True while environments that have not failed are still answering. Failed |
| 71 | − * environments are reported through their own error rows: totals will not | |
| 74 | + * environments are reported in the environment menu: totals will not | |
| 72 | 75 | * improve by waiting on them, so they must not read as "still reporting". |
| 73 | 76 | */ |
| 74 | 77 | readonly isPartial: boolean; |
| 75 | 78 | readonly refresh: (input?: UsageSummaryInput) => Promise<void>; |
| 76 | 79 | } |
| 77 | 80 | |
| 78 | −export function useUsage(input: UsageSummaryInput): UsageView { | |
| 81 | +export function useUsage( | |
| 82 | + input: UsageSummaryInput, | |
| 83 | + selectedEnvironmentIds: ReadonlySet<EnvironmentId> | null = null, | |
| 84 | +): UsageView { | |
| 79 | 85 | const windowKey = useMemo( |
| 80 | 86 | () => |
| 81 | 87 | JSON.stringify({ |
| @@ -97,6 +103,13 @@ export function useUsage(input: UsageSummaryInput): UsageView { · 15 unmodified lines | ||
| 97 | 103 | ); |
| 98 | 104 | const atom = usageByWindowAtom(windowKey); |
| 99 | 105 | const environments = useAtomValue(atom); |
| 106 | + const selectedEnvironments = useMemo( | |
| 107 | + () => | |
| 108 | + selectedEnvironmentIds === null | |
| 109 | + ? environments | |
| 110 | + : environments.filter(({ environmentId }) => selectedEnvironmentIds.has(environmentId)), | |
| 111 | + [environments, selectedEnvironmentIds], | |
| 112 | + ); | |
| 100 | 113 | |
| 101 | 114 | const refresh = useCallback( |
| 102 | 115 | (nextInput?: UsageSummaryInput) => |
| @@ -104,14 +117,14 @@ export function useUsage(input: UsageSummaryInput): UsageView { · 1 unmodified line | ||
| 104 | 117 | registry: appAtomRegistry, |
| 105 | 118 | server: serverEnvironment, |
| 106 | 119 | presentations: environmentPresentations, |
| 107 | − environmentIds: environments.map(({ environmentId }) => environmentId), | |
| 120 | + environmentIds: selectedEnvironments.map(({ environmentId }) => environmentId), | |
| 108 | 121 | input: nextInput ?? (JSON.parse(windowKey) as UsageSummaryInput), |
| 109 | 122 | }), |
| 110 | − [environments, windowKey], | |
| 123 | + [selectedEnvironments, windowKey], | |
| 111 | 124 | ); |
| 112 | 125 | |
| 113 | 126 | const merged = useMemo(() => { |
| 114 | − const answered: EnvironmentUsage[] = environments.flatMap((environment) => | |
| 127 | + const answered: EnvironmentUsage[] = selectedEnvironments.flatMap((environment) => | |
| 115 | 128 | environment.summary === null |
| 116 | 129 | ? [] |
| 117 | 130 | : [ |
| @@ -123,16 +136,19 @@ export function useUsage(input: UsageSummaryInput): UsageView { · 5 unmodified lines | ||
| 123 | 136 | ], |
| 124 | 137 | ); |
| 125 | 138 | return mergeUsage(answered, USAGE_CONTRACT_VERSION); |
| 126 | − }, [environments]); | |
| 139 | + }, [selectedEnvironments]); | |
| 127 | 140 | |
| 128 | − const answeredCount = environments.filter((environment) => environment.summary !== null).length; | |
| 129 | − const stillReporting = environments.filter( | |
| 141 | + const answeredCount = selectedEnvironments.filter( | |
| 142 | + (environment) => environment.summary !== null, | |
| 143 | + ).length; | |
| 144 | + const stillReporting = selectedEnvironments.filter( | |
| 130 | 145 | (environment) => environment.summary === null && environment.error === null, |
| 131 | 146 | ).length; |
| 132 | 147 | |
| 133 | 148 | return { |
| 134 | 149 | merged, |
| 135 | 150 | environments, |
| 151 | + selectedEnvironments, | |
| 136 | 152 | isPending: answeredCount === 0 && stillReporting > 0, |
| 137 | 153 | isPartial: answeredCount > 0 && stillReporting > 0, |
| 138 | 154 | refresh, |
| @@ -39,14 +39,14 @@ the dialog. · 38 unmodified lines | ||
| 39 | 39 | |
| 40 | 40 | ## Track subscription limits |
| 41 | 41 | |
| 42 | −On web and desktop, **Usage → Limits** pools every subscription account it can see per provider, so with several Codex | |
| 42 | +**Usage → Limits** pools every subscription account it can see per provider, so with several Codex | |
| 43 | 43 | or Claude accounts across your environments and hubs you read one number per window rather than a |
| 44 | 44 | list. Each window card shows how much of the pool is left and a bar with one segment per account, |
| 45 | 45 | ordered by which resets soonest; when the provider reports reset times, the card also says when |
| 46 | 46 | the next reset lands and how much it hands back. The hatched |
| 47 | −part of a segment is what that reset restores. Tap or hover a segment for the account's plan, where it is | |
| 48 | −signed in, and its reset time; Codex accounts with banked reset credits show a ticket count on the | |
| 49 | −segment and the **Use reset** action in that popover. On narrow screens, numbered rows below | |
| 47 | +part of a segment is what that reset restores. Tap a segment or account row for the account's plan, | |
| 48 | +where it is signed in, and its reset time. On web, you can hover too. Codex accounts with banked | |
| 49 | +reset credits show a ticket count and the **Use reset** action in the account details. On narrow screens, numbered rows below | |
| 50 | 50 | the bar show each account's quota, countdown, and credits. Tap a row to open its details. |
| 51 | 51 | |
| 52 | 52 | The same account signed in on more than one environment, or reported by a hub as well, counts once. |
| @@ -209,7 +209,7 @@ export function collectLimitAccounts( · 208 unmodified lines | ||
| 209 | 209 | // the freshest native snapshot supplies both, or neither. |
| 210 | 210 | const native = [previous, next] |
| 211 | 211 | .filter((candidate) => candidate.redeem !== null) |
| 212 | − .toSorted((a, b) => Date.parse(b.limits.checkedAt) - Date.parse(a.limits.checkedAt))[0]; | |
| 212 | + .sort((a, b) => Date.parse(b.limits.checkedAt) - Date.parse(a.limits.checkedAt))[0]; | |
| 213 | 213 | accounts.set(key, { |
| 214 | 214 | ...previous, |
| 215 | 215 | displayName: previous.displayName ?? next.displayName, |
| @@ -374,7 +374,7 @@ export function collectLimitPools( · 158 unmodified lines | ||
| 374 | 374 | else byDriver.set(account.driver, [account]); |
| 375 | 375 | } |
| 376 | 376 | return [...byDriver].map(([driver, members]) => { |
| 377 | − const sorted = members.toSorted( | |
| 377 | + const sorted = [...members].sort( | |
| 378 | 378 | (left, right) => |
| 379 | 379 | Number(left.redeem === null) - Number(right.redeem === null) || |
| 380 | 380 | accountSortName(left).localeCompare(accountSortName(right)), |
| @@ -398,7 +398,7 @@ function poolWindows(accounts: readonly LimitAccount[], now: number): readonly L · 17 unmodified lines | ||
| 398 | 398 | } |
| 399 | 399 | } |
| 400 | 400 | const pools = [...byKey.values()].map((unordered): LimitPoolWindow => { |
| 401 | − const members = unordered.toSorted( | |
| 401 | + const members = [...unordered].sort( | |
| 402 | 402 | (left, right) => |
| 403 | 403 | (resetMillis(left.window) ?? Number.POSITIVE_INFINITY) - |
| 404 | 404 | (resetMillis(right.window) ?? Number.POSITIVE_INFINITY), |
| @@ -428,7 +428,7 @@ function poolWindows(accounts: readonly LimitAccount[], now: number): readonly L · 23 unmodified lines | ||
| 428 | 428 | }, |
| 429 | 429 | ]; |
| 430 | 430 | }) |
| 431 | − .toSorted((left, right) => left.at - right.at); | |
| 431 | + .sort((left, right) => left.at - right.at); | |
| 432 | 432 | return { |
| 433 | 433 | id: first.id, |
| 434 | 434 | kind: first.kind, |
| @@ -440,17 +440,7 @@ function poolWindows(accounts: readonly LimitAccount[], now: number): readonly L · 5 unmodified lines | ||
| 440 | 440 | resets, |
| 441 | 441 | }; |
| 442 | 442 | }); |
| 443 | − return pools.toSorted( | |
| 444 | − (left, right) => WINDOW_KIND_ORDER[left.kind] - WINDOW_KIND_ORDER[right.kind], | |
| 445 | − ); | |
| 446 | −} | |
| 447 | − | |
| 448 | −/** The instance's configured name, else the driver's, else its raw kind. */ | |
| 449 | −export function providerLimitsLabel( | |
| 450 | − provider: Pick<ServerProvider, "driver" | "displayName">, | |
| 451 | − driverLabel: (driver: ServerProvider["driver"]) => string | undefined, | |
| 452 | −): string { | |
| 453 | − return provider.displayName?.trim() || driverLabel(provider.driver) || String(provider.driver); | |
| 443 | + return pools.sort((left, right) => WINDOW_KIND_ORDER[left.kind] - WINDOW_KIND_ORDER[right.kind]); | |
| 454 | 444 | } |
| 455 | 445 | |
| 456 | 446 | /** The one-line status under a provider heading when there are no bars to draw. */ |
| @@ -627,7 +617,7 @@ export function collectProviderUsageLimits( · 170 unmodified lines | ||
| 627 | 617 | accounts.push({ |
| 628 | 618 | id: provider.instanceId, |
| 629 | 619 | driver: provider.driver, |
| 630 | − label: `${providerLimitsLabel(provider, () => undefined)} [${provider.instanceId}]`, | |
| 620 | + label: `${provider.displayName?.trim() || String(provider.driver)} [${provider.instanceId}]`, | |
| 631 | 621 | ...(provider.auth.label ? { plan: provider.auth.label } : {}), |
| 632 | 622 | instanceId: provider.instanceId, |
| 633 | 623 | ...(provider.displayName ? { displayName: provider.displayName } : {}), |