authorgravatar for 136640686+MatthewFeroz@users.noreply.github.comMatthew Feroz <136640686+MatthewFeroz@users.noreply.github.com> 2026-09-13 03:07:49-04:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-09-13 00:07:49-07:00
log17f8e2a8acba76d5516e0a52c85bf802b0e17892
treef2601a53c83c92a727dba483c87e1199388688fc
parent0a91b9a116117dd5df619fb7c1248cba9cf6f43c
signature Signed by PGP key B5690EEEBB952194

feat(mobile): add pooled subscription usage widgets (#11506)


26 files changed, 1085 insertions(+), 300 deletions(-)

apps/mobile/app.config.ts+39
...@@ -130,6 +130,45 @@ const widgetsPlugin: NonNullable<ExpoConfig["plugins"]>[number] = [...@@ -130,6 +130,45 @@ const widgetsPlugin: NonNullable<ExpoConfig["plugins"]>[number] = [
130 // frequent-updates entitlement iOS throttles the update budget sooner.130 // frequent-updates entitlement iOS throttles the update budget sooner.
131 frequentUpdates: true,131 frequentUpdates: true,
132 widgets: [132 widgets: [
133 {
134 name: "SubscriptionUsage",
135 displayName: "Subscription usage",
136 description: "Subscription quotas from your connected T3 Code environments.",
137 configuration: {
138 title: "Subscription usage",
139 description:
140 "Both shows Session and Weekly when available. The Lock Screen shows the tightest selected limit.",
141 parameters: {
142 codexPeriod: {
143 title: "Codex limits",
144 type: "enum",
145 default: "auto",
146 values: [
147 { name: "Both", value: "auto" },
148 { name: "Session", value: "session" },
149 { name: "Weekly", value: "weekly" },
150 ],
151 },
152 claudePeriod: {
153 title: "Claude limits",
154 type: "enum",
155 default: "auto",
156 values: [
157 { name: "Both", value: "auto" },
158 { name: "Session", value: "session" },
159 { name: "Weekly", value: "weekly" },
160 ],
161 },
162 },
163 },
164 supportedFamilies: [
165 "systemSmall",
166 "systemMedium",
167 "systemLarge",
168 "systemExtraLarge",
169 "accessoryRectangular",
170 ],
171 },
133 {172 {
134 name: "AgentActivity",173 name: "AgentActivity",
135 displayName: "Agent Activity",174 displayName: "Agent Activity",
apps/mobile/modules/t3-subscription-widget/android/build.gradle created+18
...@@ -0,0 +1,18 @@
1apply plugin: 'com.android.library'
2apply plugin: 'org.jetbrains.kotlin.android'
3
4group = 'com.t3tools.subscriptionwidget'
5version = '0.0.0'
6
7android {
8 namespace 'expo.modules.t3subscriptionwidget'
9 compileSdk rootProject.ext.compileSdkVersion
10 defaultConfig {
11 minSdkVersion rootProject.ext.minSdkVersion
12 targetSdkVersion rootProject.ext.targetSdkVersion
13 }
14}
15
16dependencies {
17 implementation project(':expo-modules-core')
18}
apps/mobile/modules/t3-subscription-widget/android/src/main/AndroidManifest.xml created+8
...@@ -0,0 +1,8 @@
1<manifest xmlns:android="http://schemas.android.com/apk/res/android">
2 <application>
3 <receiver android:name="expo.modules.t3subscriptionwidget.SubscriptionUsageWidget" android:exported="false" android:label="@string/t3_subscription_widget_name">
4 <intent-filter><action android:name="android.appwidget.action.APPWIDGET_UPDATE" /></intent-filter>
5 <meta-data android:name="android.appwidget.provider" android:resource="@xml/t3_subscription_widget_info" />
6 </receiver>
7 </application>
8</manifest>
apps/mobile/modules/t3-subscription-widget/android/src/main/java/expo/modules/t3subscriptionwidget/SubscriptionUsageWidget.kt created+163
...@@ -0,0 +1,163 @@
1package expo.modules.t3subscriptionwidget
2
3import android.app.AlarmManager
4import android.app.PendingIntent
5import android.appwidget.AppWidgetManager
6import android.appwidget.AppWidgetProvider
7import android.content.ComponentName
8import android.content.Context
9import android.content.Intent
10import android.net.Uri
11import android.os.Bundle
12import android.view.View
13import android.widget.RemoteViews
14import org.json.JSONObject
15import java.text.DateFormat
16import java.util.Date
17
18class SubscriptionUsageWidget : AppWidgetProvider() {
19 override fun onUpdate(context: Context, manager: AppWidgetManager, ids: IntArray) {
20 ids.forEach { update(context, manager, it) }
21 }
22
23 override fun onReceive(context: Context, intent: Intent) {
24 super.onReceive(context, intent)
25 if (intent.action == EXPIRE) updateAll(context)
26 }
27
28 override fun onDisabled(context: Context) {
29 context.getSystemService(AlarmManager::class.java).cancel(expiryIntent(context))
30 }
31
32 override fun onAppWidgetOptionsChanged(
33 context: Context,
34 manager: AppWidgetManager,
35 id: Int,
36 options: Bundle
37 ) {
38 update(context, manager, id)
39 }
40
41 companion object {
42 const val PREFERENCES = "t3_subscription_widget"
43 private const val EXPIRE = "expo.modules.t3subscriptionwidget.EXPIRE"
44
45 private fun expiryIntent(context: Context): PendingIntent = PendingIntent.getBroadcast(
46 context,
47 0,
48 Intent(context, SubscriptionUsageWidget::class.java).setAction(EXPIRE),
49 PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE
50 )
51
52 fun updateAll(context: Context) {
53 val manager = AppWidgetManager.getInstance(context)
54 manager.getAppWidgetIds(ComponentName(context, SubscriptionUsageWidget::class.java))
55 .forEach { update(context, manager, it) }
56 }
57
58 private fun update(context: Context, manager: AppWidgetManager, id: Int) {
59 val saved = context.getSharedPreferences(PREFERENCES, 0).getString("snapshot", null)
60 val snapshot = runCatching { JSONObject(saved.orEmpty()) }.getOrNull()
61 val views = RemoteViews(context.packageName, R.layout.t3_subscription_widget)
62 openAppIntent(context, id, snapshot)?.let {
63 views.setOnClickPendingIntent(R.id.t3_widget_root, it)
64 }
65 val providers = snapshot?.optJSONArray("providers")
66 val now = System.currentTimeMillis()
67 var nextExpiry = Long.MAX_VALUE
68 var totalRows = 0
69 val groups = (0 until (providers?.length() ?: 0)).mapNotNull { index ->
70 val provider = providers?.optJSONObject(index) ?: return@mapNotNull null
71 val windows = provider.optJSONArray("windows")
72 val expiresAt = provider.optLong("expiresAt")
73 if (expiresAt > now && windows != null && windows.length() > 0) {
74 nextExpiry = minOf(nextExpiry, expiresAt)
75 totalRows += provider.optInt("totalWindows", windows.length())
76 (0 until windows.length()).map { provider to windows.optJSONObject(it) }
77 } else {
78 totalRows++
79 listOf(provider to null)
80 }
81 }
82 // Show each provider before filling spare space with its other windows.
83 val rows = (0 until (groups.maxOfOrNull { it.size } ?: 0)).flatMap { index ->
84 groups.mapNotNull { it.getOrNull(index) }
85 }
86 if (rows.isNotEmpty()) {
87 views.removeAllViews(R.id.t3_widget_rows)
88 val options = manager.getAppWidgetOptions(id)
89 val height = options.getInt(AppWidgetManager.OPTION_APPWIDGET_MIN_HEIGHT, 180)
90 val count = ((height - 64) / 66).coerceIn(1, 12).coerceAtMost(rows.size)
91 for ((provider, window) in rows.take(count)) {
92 views.addView(R.id.t3_widget_rows, rowView(context, provider, window))
93 }
94 val remaining = totalRows - count
95 val checkedAt = snapshot?.optLong("checkedAt") ?: 0
96 val formatted = DateFormat.getDateTimeInstance(
97 DateFormat.SHORT,
98 DateFormat.SHORT
99 ).format(Date(checkedAt))
100 val more = if (remaining > 0) {
101 context.getString(R.string.t3_subscription_widget_more, remaining)
102 } else {
103 ""
104 }
105 val checked = if (checkedAt > 0) {
106 context.getString(R.string.t3_subscription_widget_as_of, formatted)
107 } else {
108 context.getString(R.string.t3_subscription_widget_unknown_check)
109 }
110 views.setTextViewText(R.id.t3_widget_footer, checked + more)
111 }
112 val alarms = context.getSystemService(AlarmManager::class.java)
113 alarms.cancel(expiryIntent(context))
114 // Inexact and non-wakeup: the timestamp remains visible if Android delays expiry.
115 if (nextExpiry != Long.MAX_VALUE) {
116 alarms.set(AlarmManager.RTC, nextExpiry, expiryIntent(context))
117 }
118 manager.updateAppWidget(id, views)
119 }
120
121 private fun openAppIntent(context: Context, id: Int, snapshot: JSONObject?): PendingIntent? {
122 // Target this variant's launcher so co-installed builds cannot steal the tap.
123 val intent =
124 context.packageManager.getLaunchIntentForPackage(context.packageName) ?: return null
125 intent.action = Intent.ACTION_VIEW
126 val deepLink = snapshot?.optString("url")?.takeIf { it.isNotBlank() }
127 ?: "t3code://settings/usage?tab=limits"
128 intent.data = Uri.parse(deepLink)
129 intent.flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TOP
130 return PendingIntent.getActivity(
131 context,
132 id,
133 intent,
134 PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE
135 )
136 }
137
138 private fun rowView(context: Context, provider: JSONObject, window: JSONObject?): RemoteViews {
139 val child = RemoteViews(context.packageName, R.layout.t3_subscription_widget_row)
140 val remaining = window?.optInt("remaining")?.coerceIn(0, 100)
141 val detail = provider.optString("detail")
142 val label = provider.optString("name")
143 val windowLabel = window?.optString("label") ?: detail
144 child.setTextViewText(R.id.t3_widget_label, label)
145 child.setTextViewText(R.id.t3_widget_window, windowLabel)
146 val percent = remaining?.let {
147 context.getString(R.string.t3_subscription_widget_remaining, it)
148 } ?: "—"
149 child.setTextViewText(R.id.t3_widget_percent, percent)
150 val visibility = if (remaining == null) View.GONE else View.VISIBLE
151 child.setViewVisibility(R.id.t3_widget_progress, visibility)
152 if (remaining != null) child.setProgressBar(R.id.t3_widget_progress, 100, remaining, false)
153 val reset = window?.optString("reset")
154 ?: context.getString(R.string.t3_subscription_widget_refresh)
155 child.setTextViewText(R.id.t3_widget_reset, reset)
156 child.setContentDescription(
157 R.id.t3_widget_row,
158 "$label. $windowLabel. $percent. $reset. $detail"
159 )
160 return child
161 }
162 }
163}
apps/mobile/modules/t3-subscription-widget/android/src/main/java/expo/modules/t3subscriptionwidget/T3SubscriptionWidgetModule.kt created+18
...@@ -0,0 +1,18 @@
1package expo.modules.t3subscriptionwidget
2
3import expo.modules.kotlin.modules.Module
4import expo.modules.kotlin.modules.ModuleDefinition
5import org.json.JSONObject
6
7class T3SubscriptionWidgetModule : Module() {
8 override fun definition() = ModuleDefinition {
9 Name("T3SubscriptionWidget")
10 Function("updateSnapshot") { snapshot: String ->
11 val context = appContext.reactContext ?: return@Function
12 JSONObject(snapshot) // Reject malformed writes before replacing the saved snapshot.
13 context.getSharedPreferences(SubscriptionUsageWidget.PREFERENCES, 0)
14 .edit().putString("snapshot", snapshot).apply()
15 SubscriptionUsageWidget.updateAll(context)
16 }
17 }
18}
apps/mobile/modules/t3-subscription-widget/android/src/main/res/drawable/t3_subscription_widget_background.xml created+4
...@@ -0,0 +1,4 @@
1<shape xmlns:android="http://schemas.android.com/apk/res/android">
2 <solid android:color="@color/t3_widget_background" />
3 <corners android:radius="20dp" />
4</shape>
apps/mobile/modules/t3-subscription-widget/android/src/main/res/layout/t3_subscription_widget.xml created+9
...@@ -0,0 +1,9 @@
1<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
2 android:id="@+id/t3_widget_root" android:layout_width="match_parent" android:layout_height="match_parent"
3 android:orientation="vertical" android:padding="16dp" android:background="@drawable/t3_subscription_widget_background">
4 <TextView android:layout_width="match_parent" android:layout_height="wrap_content" android:text="@string/t3_subscription_widget_name" android:textStyle="bold" android:textSize="16sp" android:textColor="@color/t3_widget_foreground" android:maxLines="1" android:ellipsize="end" />
5 <LinearLayout android:id="@+id/t3_widget_rows" android:layout_width="match_parent" android:layout_height="0dp" android:layout_weight="1" android:orientation="vertical">
6 <TextView android:layout_width="match_parent" android:layout_height="wrap_content" android:layout_marginTop="8dp" android:text="@string/t3_subscription_widget_empty" android:textSize="13sp" android:textColor="@color/t3_widget_secondary" />
7 </LinearLayout>
8 <TextView android:id="@+id/t3_widget_footer" android:layout_width="match_parent" android:layout_height="wrap_content" android:text="@string/t3_subscription_widget_open" android:textSize="10sp" android:textColor="@color/t3_widget_secondary" android:maxLines="1" android:ellipsize="end" />
9</LinearLayout>
apps/mobile/modules/t3-subscription-widget/android/src/main/res/layout/t3_subscription_widget_row.xml created+10
...@@ -0,0 +1,10 @@
1<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
2 android:id="@+id/t3_widget_row" android:layout_width="match_parent" android:layout_height="wrap_content" android:orientation="vertical" android:paddingTop="8dp">
3 <LinearLayout android:layout_width="match_parent" android:layout_height="wrap_content" android:orientation="horizontal">
4 <TextView android:id="@+id/t3_widget_label" android:layout_width="0dp" android:layout_weight="1" android:layout_height="wrap_content" android:textSize="12sp" android:textStyle="bold" android:textColor="@color/t3_widget_foreground" android:maxLines="1" android:ellipsize="end" />
5 <TextView android:id="@+id/t3_widget_percent" android:layout_width="wrap_content" android:layout_height="wrap_content" android:paddingStart="8dp" android:textSize="12sp" android:textColor="@color/t3_widget_foreground" />
6 </LinearLayout>
7 <TextView android:id="@+id/t3_widget_window" android:layout_width="match_parent" android:layout_height="wrap_content" android:textSize="10sp" android:textColor="@color/t3_widget_secondary" android:maxLines="1" android:ellipsize="end" />
8 <ProgressBar android:id="@+id/t3_widget_progress" style="?android:attr/progressBarStyleHorizontal" android:layout_width="match_parent" android:layout_height="6dp" android:max="100" android:progressTint="#0284c7" />
9 <TextView android:id="@+id/t3_widget_reset" android:layout_width="match_parent" android:layout_height="wrap_content" android:textSize="10sp" android:textColor="@color/t3_widget_secondary" android:maxLines="1" android:ellipsize="end" />
10</LinearLayout>
apps/mobile/modules/t3-subscription-widget/android/src/main/res/values-night/colors.xml created+5
...@@ -0,0 +1,5 @@
1<resources>
2 <color name="t3_widget_background">#18181B</color>
3 <color name="t3_widget_foreground">#FAFAFA</color>
4 <color name="t3_widget_secondary">#A1A1AA</color>
5</resources>
apps/mobile/modules/t3-subscription-widget/android/src/main/res/values/colors.xml created+5
...@@ -0,0 +1,5 @@
1<resources>
2 <color name="t3_widget_background">#FAFAFA</color>
3 <color name="t3_widget_foreground">#18181B</color>
4 <color name="t3_widget_secondary">#52525B</color>
5</resources>
apps/mobile/modules/t3-subscription-widget/android/src/main/res/values/strings.xml created+11
...@@ -0,0 +1,11 @@
1<resources>
2 <string name="t3_subscription_widget_unknown_check">Last checked unavailable</string>
3 <string name="t3_subscription_widget_name">Subscription usage</string>
4 <string name="t3_subscription_widget_description">Saved subscription quotas from your T3 Code environments. Tap to refresh in the app.</string>
5 <string name="t3_subscription_widget_empty">Open T3 Code and connect an environment to see limits.</string>
6 <string name="t3_subscription_widget_open">Tap to open Usage</string>
7 <string name="t3_subscription_widget_refresh">Open app to refresh</string>
8 <string name="t3_subscription_widget_remaining">%1$d%% remaining</string>
9 <string name="t3_subscription_widget_as_of">As of %1$s</string>
10 <string name="t3_subscription_widget_more"> · +%1$d more</string>
11</resources>
apps/mobile/modules/t3-subscription-widget/android/src/main/res/xml/t3_subscription_widget_info.xml created+9
...@@ -0,0 +1,9 @@
1<appwidget-provider xmlns:android="http://schemas.android.com/apk/res/android"
2 android:minWidth="250dp" android:minHeight="180dp"
3 android:minResizeWidth="180dp" android:minResizeHeight="130dp"
4 android:targetCellWidth="4" android:targetCellHeight="3"
5 android:resizeMode="horizontal|vertical" android:widgetCategory="home_screen"
6 android:updatePeriodMillis="1800000"
7 android:description="@string/t3_subscription_widget_description"
8 android:initialLayout="@layout/t3_subscription_widget"
9 android:previewLayout="@layout/t3_subscription_widget" />
apps/mobile/modules/t3-subscription-widget/expo-module.config.json created+4
...@@ -0,0 +1,4 @@
1{
2 "platforms": ["android"],
3 "android": { "modules": ["expo.modules.t3subscriptionwidget.T3SubscriptionWidgetModule"] }
4}
apps/mobile/src/App.tsx+3
...@@ -24,6 +24,8 @@ import { OverlayPortalHost } from "./components/OverlayPortal";...@@ -24,6 +24,8 @@ import { OverlayPortalHost } from "./components/OverlayPortal";
24import { appBlurTargetRef } from "./lib/appBlurTarget";24import { appBlurTargetRef } from "./lib/appBlurTarget";
25import { useMobileNavigationTheme } from "./lib/useMobileNavigationTheme";25import { useMobileNavigationTheme } from "./lib/useMobileNavigationTheme";
2626
27import { SubscriptionUsageCoordinator } from "./widgets/SubscriptionUsageCoordinator";
28
27import "../global.css";29import "../global.css";
2830
29if (process.env.EXPO_PUBLIC_SHOWCASE === "1") {31if (process.env.EXPO_PUBLIC_SHOWCASE === "1") {
...@@ -77,6 +79,7 @@ function AppContent() {...@@ -77,6 +79,7 @@ function AppContent() {
77 return (79 return (
78 <>80 <>
79 <SplashScreenCoordinator />81 <SplashScreenCoordinator />
82 <SubscriptionUsageCoordinator />
80 <GestureHandlerRootView className="flex-1">83 <GestureHandlerRootView className="flex-1">
81 <KeyboardProvider statusBarTranslucent>84 <KeyboardProvider statusBarTranslucent>
82 <SafeAreaProvider>85 <SafeAreaProvider>
apps/mobile/src/features/usage/UsageRouteScreen.tsx+15-4
...@@ -1,5 +1,5 @@...@@ -1,5 +1,5 @@
1import { EnvironmentId, USAGE_CONTRACT_VERSION } from "@t3tools/contracts";1import { EnvironmentId, USAGE_CONTRACT_VERSION } from "@t3tools/contracts";
2import { useNavigation } from "@react-navigation/native";2import { type RouteProp, useNavigation, useRoute } from "@react-navigation/native";
3import {3import {
4 isCompatibleUsageContractVersion,4 isCompatibleUsageContractVersion,
5 isModelCostUnknown,5 isModelCostUnknown,
...@@ -65,11 +65,22 @@ const CHART_HEIGHT = 180;...@@ -65,11 +65,22 @@ const CHART_HEIGHT = 180;
65 * pull to refresh, each refreshing its own data.65 * pull to refresh, each refreshing its own data.
66 */66 */
67export function UsageRouteScreen() {67export function UsageRouteScreen() {
68 const route = useRoute<RouteProp<{ Usage: { tab?: string } | undefined }, "Usage">>();
68 const navigation = useNavigation();69 const navigation = useNavigation();
69 const insets = useSafeAreaInsets();70 const insets = useSafeAreaInsets();
70 // Limits first: remaining quota and reset time are what most people open71 // Preserve the Limits default while honoring explicit widget/navigation links.
71 // the screen for.72 const [selection, setSelection] = useState(() => ({
72 const [tab, setTab] = useState<UsageTab>("limits");73 params: route.params,
74 tab: (route.params?.tab === "usage" ? "usage" : "limits") as UsageTab,
75 }));
76 if (selection.params !== route.params) {
77 setSelection({
78 params: route.params,
79 tab: route.params?.tab === "usage" ? "usage" : "limits",
80 });
81 }
82 const { tab } = selection;
83 const setTab = (tab: UsageTab) => setSelection({ params: route.params, tab });
73 const [windowSelection, setWindowSelection] = useState(() => ({84 const [windowSelection, setWindowSelection] = useState(() => ({
74 days: 30,85 days: 30,
75 window: makeWindow(30),86 window: makeWindow(30),
apps/mobile/src/widgets/SubscriptionUsage.tsx created+262
...@@ -0,0 +1,262 @@
1import { HStack, ProgressView, Spacer, Text, VStack } from "@expo/ui/swift-ui";
2import {
3 accessibilityElement,
4 accessibilityLabel,
5 font,
6 foregroundStyle,
7 frame,
8 layoutPriority,
9 lineLimit,
10 minimumScaleFactor,
11 progressViewStyle,
12 tint,
13 widgetURL,
14} from "@expo/ui/swift-ui/modifiers";
15import { createWidget, type WidgetEnvironment } from "expo-widgets";
16
17import type { SubscriptionUsageSnapshot as SubscriptionUsageProps } from "./subscriptionUsageSnapshot";
18
19type UsageConfiguration = {
20 codexPeriod?: "auto" | "session" | "weekly";
21 claudePeriod?: "auto" | "session" | "weekly";
22};
23
24function SubscriptionUsage(
25 props: SubscriptionUsageProps,
26 environment: WidgetEnvironment<UsageConfiguration>,
27) {
28 "widget";
29 // The extension evaluates this function without the app's module scope.
30 const family = environment.widgetFamily;
31 // Gallery snapshots can render an old timeline entry after it has expired.
32 const now = Math.max(environment.date.getTime(), Date.now());
33 const accessory = family === "accessoryRectangular";
34 const compact =
35 family === "systemSmall" || accessory || environment.levelOfDetail === "simplified";
36 const limit = family === "systemExtraLarge" ? 6 : family === "systemLarge" ? 4 : 2;
37 const monochrome =
38 environment.widgetRenderingMode !== "fullColor" || environment.isLuminanceReduced;
39 const providers = props.providers ?? [
40 { name: "Codex", detail: "Open T3 to connect", windows: [], expiresAt: 0 },
41 { name: "Claude", detail: "Open T3 to connect", windows: [], expiresAt: 0 },
42 ];
43 const columns = providers.map((provider) => {
44 const stale = provider.windows.length > 0 && now >= provider.expiresAt;
45 const period =
46 environment.configuration?.[provider.name === "Claude" ? "claudePeriod" : "codexPeriod"] ??
47 "auto";
48 const windows = stale
49 ? []
50 : provider.windows.filter((window) => period === "auto" || window.kind === period);
51 // Lock Screen widgets surface the tightest selected limit.
52 const tightest = windows.reduce<(typeof windows)[number] | undefined>(
53 (result, window) => (!result || window.remaining < result.remaining ? window : result),
54 undefined,
55 );
56 const compactWindows = [
57 windows.find((window) => window.kind === "session"),
58 windows.find((window) => window.kind === "weekly"),
59 ].filter((window) => window !== undefined);
60 const shown =
61 accessory || environment.levelOfDetail === "simplified"
62 ? tightest
63 ? [tightest]
64 : []
65 : family === "systemSmall" && compactWindows.length > 0
66 ? compactWindows
67 : period === "auto" && compactWindows.length > 0
68 ? [
69 ...compactWindows,
70 ...windows.filter((window) => !compactWindows.includes(window)),
71 ].slice(0, limit)
72 : windows.slice(0, limit);
73 const detail = stale
74 ? "Open T3 to refresh"
75 : period !== "auto" && windows.length === 0 && provider.windows.length > 0
76 ? `No ${period} limit reported`
77 : provider.detail;
78 const barModifiers = [
79 progressViewStyle("linear"),
80 ...(monochrome ? [] : [tint(provider.name === "Claude" ? "#d97757" : "#8e8e93")]),
81 ];
82 if (accessory) {
83 return (
84 <VStack
85 key={provider.name}
86 alignment="leading"
87 spacing={2}
88 modifiers={[
89 accessibilityElement("ignore"),
90 accessibilityLabel(
91 tightest
92 ? `${provider.name}, ${tightest.label}, ${tightest.remaining} percent remaining. ${tightest.reset}. ${provider.detail}.`
93 : `${provider.name}. ${detail}.`,
94 ),
95 ]}
96 >
97 <HStack spacing={4}>
98 <Text
99 modifiers={[
100 font({ textStyle: "caption", weight: "semibold" }),
101 lineLimit(1),
102 minimumScaleFactor(0.75),
103 foregroundStyle("primary"),
104 ]}
105 >
106 {provider.name}
107 {tightest ? ` · ${tightest.label}` : ""}
108 </Text>
109 <Spacer />
110 <Text
111 modifiers={[
112 font({ textStyle: "caption", weight: "semibold" }),
113 lineLimit(1),
114 layoutPriority(1),
115 foregroundStyle("primary"),
116 ]}
117 >
118 {tightest
119 ? `${tightest.remaining}% left`
120 : period !== "auto" && !stale && provider.windows.length > 0
121 ? "N/A"
122 : "Open T3"}
123 </Text>
124 </HStack>
125 {tightest ? (
126 <ProgressView value={tightest.remaining / 100} modifiers={barModifiers} />
127 ) : null}
128 </VStack>
129 );
130 }
131 return (
132 <VStack
133 key={provider.name}
134 alignment="leading"
135 spacing={compact ? 2 : 4}
136 modifiers={[frame({ maxWidth: Infinity, alignment: "leading" })]}
137 >
138 <Text
139 modifiers={[
140 font({ textStyle: compact ? "caption" : "headline", weight: "bold" }),
141 lineLimit(1),
142 minimumScaleFactor(0.75),
143 foregroundStyle("primary"),
144 ]}
145 >
146 {provider.name}
147 </Text>
148 {(!compact || shown.length === 0) && detail !== "Subscription remaining" ? (
149 <Text
150 modifiers={[
151 font({ textStyle: "caption2" }),
152 foregroundStyle("secondary"),
153 lineLimit(compact ? 1 : 2),
154 ]}
155 >
156 {detail}
157 </Text>
158 ) : null}
159 {shown.map((window) => (
160 <VStack
161 key={window.label}
162 alignment="leading"
163 spacing={2}
164 modifiers={[
165 accessibilityElement("ignore"),
166 accessibilityLabel(
167 `${provider.name}, ${window.label}, ${window.remaining} percent remaining. ${window.reset}. ${provider.detail}.`,
168 ),
169 ]}
170 >
171 <HStack spacing={4}>
172 <Text
173 modifiers={[
174 font({ textStyle: compact ? "caption2" : "caption" }),
175 foregroundStyle("secondary"),
176 lineLimit(1),
177 minimumScaleFactor(0.75),
178 ]}
179 >
180 {window.label}
181 </Text>
182 <Spacer />
183 <Text
184 modifiers={[
185 font({ textStyle: compact ? "caption2" : "caption", weight: "semibold" }),
186 lineLimit(1),
187 minimumScaleFactor(0.75),
188 layoutPriority(1),
189 foregroundStyle(
190 window.remaining <= 10 && !monochrome
191 ? environment.colorScheme === "light"
192 ? "#dc2626"
193 : "#fca5a5"
194 : "primary",
195 ),
196 ]}
197 >
198 {window.remaining}% left
199 </Text>
200 </HStack>
201 <ProgressView value={window.remaining / 100} modifiers={barModifiers} />
202 {!compact ? (
203 <Text
204 modifiers={[
205 font({ textStyle: "caption2" }),
206 foregroundStyle("secondary"),
207 lineLimit(1),
208 minimumScaleFactor(0.75),
209 ]}
210 >
211 {window.reset}
212 </Text>
213 ) : null}
214 </VStack>
215 ))}
216 {!compact &&
217 !stale &&
218 (period === "auto" ? (provider.totalWindows ?? windows.length) : windows.length) > limit ? (
219 <Text modifiers={[font({ textStyle: "caption2" }), foregroundStyle("secondary")]}>
220 {(period === "auto" ? (provider.totalWindows ?? windows.length) : windows.length) -
221 limit}{" "}
222 more in T3
223 </Text>
224 ) : null}
225 </VStack>
226 );
227 });
228 return (
229 <VStack
230 alignment="leading"
231 spacing={accessory ? 2 : 6}
232 modifiers={props.url ? [widgetURL(props.url)] : []}
233 >
234 {compact ? (
235 <VStack alignment="leading" spacing={accessory ? 4 : 8}>
236 {columns}
237 </VStack>
238 ) : (
239 <HStack alignment="top" spacing={16}>
240 {columns}
241 </HStack>
242 )}
243 {!accessory ? <Spacer /> : null}
244 {!accessory ? (
245 <Text
246 modifiers={[
247 font({ textStyle: "caption2" }),
248 foregroundStyle("secondary"),
249 lineLimit(1),
250 minimumScaleFactor(0.75),
251 ]}
252 >
253 {props.checkedAt
254 ? `As of ${new Date(props.checkedAt).toLocaleString(undefined, { hour: "numeric", minute: "2-digit", month: "short", day: "numeric" })}`
255 : "Tap to connect in T3"}
256 </Text>
257 ) : null}
258 </VStack>
259 );
260}
261
262export default createWidget("SubscriptionUsage", SubscriptionUsage);
apps/mobile/src/widgets/SubscriptionUsageCoordinator.tsx created+32
...@@ -0,0 +1,32 @@
1import { useAtomValue } from "@effect/atom-react";
2import { Atom } from "effect/unstable/reactivity";
3import * as Linking from "expo-linking";
4import { useEffect } from "react";
5import { environmentCatalog } from "../connection/catalog";
6import { environmentPresentations } from "../state/presentation";
7import { publishSubscriptionUsage } from "./publishSubscriptionUsage";
8import { useSubscriptionUsage } from "./useSubscriptionUsage";
9import { buildSubscriptionUsageSnapshot } from "./subscriptionUsageSnapshot";
10
11// Isolate quota changes from the much busier thread/config presentation stream.
12const snapshotAtom = Atom.make((get) =>
13 buildSubscriptionUsageSnapshot(
14 get(environmentPresentations.presentationsAtom),
15 Linking.createURL("settings/usage", { queryParams: { tab: "limits" } }),
16 ),
17).pipe(Atom.withEquality((a, b) => JSON.stringify(a) === JSON.stringify(b)));
18
19export function SubscriptionUsageCoordinator() {
20 const catalog = useAtomValue(environmentCatalog.catalogValueAtom);
21 const snapshot = useAtomValue(snapshotAtom);
22 useSubscriptionUsage(catalog.isReady);
23 useEffect(() => {
24 if (!catalog.isReady) return;
25 void Promise.resolve()
26 .then(() => publishSubscriptionUsage(snapshot))
27 .catch((error: unknown) => {
28 console.warn("Could not update subscription usage widget", error);
29 });
30 }, [catalog.isReady, snapshot]);
31 return null;
32}
apps/mobile/src/widgets/publishSubscriptionUsage.android.ts created+8
...@@ -0,0 +1,8 @@
1import { requireOptionalNativeModule } from "expo";
2import type { SubscriptionUsageSnapshot } from "./subscriptionUsageSnapshot";
3
4export function publishSubscriptionUsage(snapshot: SubscriptionUsageSnapshot) {
5 requireOptionalNativeModule<{ updateSnapshot: (snapshot: string) => void }>(
6 "T3SubscriptionWidget",
7 )?.updateSnapshot(JSON.stringify(snapshot));
8}
apps/mobile/src/widgets/publishSubscriptionUsage.ios.ts created+11
...@@ -0,0 +1,11 @@
1import { requireOptionalNativeModule } from "expo";
2import {
3 subscriptionUsageTimeline,
4 type SubscriptionUsageSnapshot,
5} from "./subscriptionUsageSnapshot";
6
7export async function publishSubscriptionUsage(snapshot: SubscriptionUsageSnapshot) {
8 if (!requireOptionalNativeModule("ExpoWidgets")) return;
9 const { default: widget } = await import("./SubscriptionUsage");
10 widget.updateTimeline(subscriptionUsageTimeline(snapshot, Date.now()));
11}
apps/mobile/src/widgets/publishSubscriptionUsage.ts created+3
...@@ -0,0 +1,3 @@
1import type { SubscriptionUsageSnapshot } from "./subscriptionUsageSnapshot";
2
3export function publishSubscriptionUsage(_snapshot: SubscriptionUsageSnapshot) {}
apps/mobile/src/widgets/subscriptionUsageSnapshot.test.ts created+257
...@@ -0,0 +1,257 @@
1import {
2 EnvironmentId,
3 ProviderDriverKind,
4 ProviderInstanceId,
5 UsageLimitSourceId,
6 type ServerProvider,
7} from "@t3tools/contracts";
8import { describe, expect, it, vi } from "vite-plus/test";
9import {
10 buildSubscriptionUsageSnapshot,
11 createWidgetRefresher,
12 WIDGET_REFRESH_INTERVAL,
13 subscriptionUsageTimeline,
14} from "./subscriptionUsageSnapshot";
15
16const checkedAt = "2026-09-05T12:00:00.000Z";
17const now = Date.parse(checkedAt);
18const window = {
19 id: "session",
20 kind: "session",
21 label: "5 hours",
22 usedPercent: 40,
23 resetsAt: "2026-09-05T12:10:00.000Z",
24} as const;
25const limits = { checkedAt, windows: [window] };
26const deepLink = "t3code-dev://settings/usage?tab=limits";
27function provider(overrides: Partial<ServerProvider> = {}): ServerProvider {
28 return {
29 instanceId: ProviderInstanceId.make("codex"),
30 driver: ProviderDriverKind.make("codex"),
31 enabled: true,
32 installed: true,
33 version: null,
34 status: "ready",
35 auth: { status: "authenticated", email: "private@example.com" },
36 checkedAt,
37 models: [],
38 slashCommands: [],
39 skills: [],
40 usageLimits: limits,
41 ...overrides,
42 };
43}
44function presentations(providers: readonly ServerProvider[] = [provider()]) {
45 return new Map([
46 [
47 EnvironmentId.make("env"),
48 { entry: { target: { label: "Remote" } }, serverConfig: { providers } },
49 ],
50 ]);
51}
52
53describe("subscription widget snapshots", () => {
54 it("uses provider data and its observation time without exposing account emails", () => {
55 const snapshot = buildSubscriptionUsageSnapshot(
56 presentations([provider({ displayName: "private@example.com" })]),
57 deepLink,
58 );
59 expect(snapshot.checkedAt).toBe(now);
60 expect(snapshot.providers[0]).toMatchObject({
61 name: "Codex",
62 windows: [{ remaining: 60 }],
63 expiresAt: now + 10 * 60_000,
64 });
65 expect(snapshot.url).toBe(deepLink);
66 expect(JSON.stringify(snapshot)).not.toContain("private@example.com");
67 });
68 it("clears data after removing environments and hides disabled providers", () => {
69 expect(
70 buildSubscriptionUsageSnapshot(new Map(), deepLink).providers.every(
71 (p) => p.windows.length === 0,
72 ),
73 ).toBe(true);
74 expect(
75 buildSubscriptionUsageSnapshot(
76 presentations([provider({ enabled: false })]),
77 deepLink,
78 ).providers.every((p) => p.windows.length === 0),
79 ).toBe(true);
80 });
81 it("uses upstream deduplication for a native account also present in a proxy hub", () => {
82 const input = new Map([
83 [
84 EnvironmentId.make("env"),
85 {
86 entry: { target: { label: "Remote" } },
87 serverConfig: {
88 providers: [provider()],
89 usageLimitSources: [
90 {
91 id: UsageLimitSourceId.make("hub"),
92 kind: "cliproxy" as const,
93 label: "Hub",
94 checkedAt,
95 accounts: [
96 {
97 id: "account",
98 driver: ProviderDriverKind.make("codex"),
99 email: " PRIVATE@example.com ",
100 usageLimits: limits,
101 },
102 ],
103 },
104 ],
105 },
106 },
107 ],
108 ]);
109 expect(buildSubscriptionUsageSnapshot(input, deepLink).providers[0]?.detail).toBe(
110 "Subscription remaining",
111 );
112 input.get(EnvironmentId.make("env"))!.serverConfig.providers = [];
113 const snapshot = buildSubscriptionUsageSnapshot(input, deepLink);
114 expect(snapshot.providers[0]?.name).toBe("Codex");
115 expect(JSON.stringify(snapshot)).not.toContain("example.com");
116 });
117 it("keeps unavailable quotas distinct from zero usage and omits provider error messages", () => {
118 const snapshot = buildSubscriptionUsageSnapshot(
119 presentations([
120 provider({
121 usageLimits: {
122 ...limits,
123 unavailable: { reason: "probeFailed", message: "token secret" },
124 },
125 }),
126 ]),
127 deepLink,
128 );
129 expect(snapshot.providers[0]?.windows).toEqual([]);
130 expect(JSON.stringify(snapshot)).not.toContain("token secret");
131 expect(
132 buildSubscriptionUsageSnapshot(
133 presentations([
134 provider({ usageLimits: { checkedAt, windows: [{ ...window, usedPercent: 0 }] } }),
135 ]),
136 deepLink,
137 ).providers[0]?.windows[0]?.remaining,
138 ).toBe(100);
139 });
140 it("bounds OS storage and puts the most constrained windows first", () => {
141 const windows = Array.from({ length: 20 }, (_, index) => ({
142 ...window,
143 id: `${index}`,
144 usedPercent: index * 5,
145 }));
146 const snapshot = buildSubscriptionUsageSnapshot(
147 presentations([provider({ usageLimits: { checkedAt, windows } })]),
148 deepLink,
149 );
150 expect(snapshot.providers[0]?.windows).toHaveLength(6);
151 expect(snapshot.providers[0]?.totalWindows).toBe(20);
152 expect(snapshot.providers[0]?.windows[0]?.remaining).toBe(5);
153 });
154 it("marks unknown or distant reset times stale after fifteen minutes", () => {
155 const snapshot = buildSubscriptionUsageSnapshot(
156 presentations([
157 provider({ usageLimits: { checkedAt, windows: [{ ...window, resetsAt: undefined }] } }),
158 ]),
159 deepLink,
160 );
161 expect(snapshot.providers[0]?.expiresAt).toBe(now + 15 * 60_000);
162 expect(snapshot.providers[0]?.windows[0]?.reset).toBe("Reset time unavailable");
163 });
164 it("schedules a reset boundary without inventing a zero quota", () => {
165 const snapshot = buildSubscriptionUsageSnapshot(presentations(), deepLink);
166 const timeline = subscriptionUsageTimeline(snapshot, now);
167 expect(timeline.map((entry) => entry.date.getTime())).toEqual([now, now + 10 * 60_000]);
168 expect(timeline[1]?.props.providers[0]?.windows).toEqual([]);
169 expect(subscriptionUsageTimeline(snapshot, now + 60 * 60_000)).toHaveLength(1);
170 });
171 it("expires providers independently without inventing a refill", () => {
172 const snapshot = buildSubscriptionUsageSnapshot(
173 presentations([
174 provider(),
175 provider({
176 instanceId: ProviderInstanceId.make("claude"),
177 driver: ProviderDriverKind.make("claudeAgent"),
178 usageLimits: { checkedAt, windows: [{ ...window, resetsAt: undefined }] },
179 }),
180 ]),
181 deepLink,
182 );
183 const timeline = subscriptionUsageTimeline(snapshot, now);
184 expect(timeline.map((entry) => entry.date.getTime())).toEqual([
185 now,
186 now + 10 * 60_000,
187 now + 15 * 60_000,
188 ]);
189 expect(timeline[1]?.props.providers[0]?.windows).toEqual([]);
190 expect(timeline[1]?.props.providers[1]?.windows[0]?.remaining).toBe(60);
191 expect(timeline[2]?.props.providers.every((provider) => provider.windows.length === 0)).toBe(
192 true,
193 );
194 });
195 it("marks a malformed check time immediately stale without storing null", () => {
196 const snapshot = buildSubscriptionUsageSnapshot(
197 presentations([provider({ usageLimits: { ...limits, checkedAt: "invalid" } })]),
198 deepLink,
199 );
200 expect(snapshot.checkedAt).toBe(0);
201 expect(snapshot.providers[0]).toMatchObject({ expiresAt: 0, windows: [] });
202 expect(subscriptionUsageTimeline(snapshot, now)).toHaveLength(1);
203 expect(JSON.stringify(snapshot)).not.toContain("null");
204 });
205 it("uses the freshest copy of an account across environments before pooling", () => {
206 const input = presentations();
207 input.set(EnvironmentId.make("other"), {
208 entry: { target: { label: "Other" } },
209 serverConfig: {
210 providers: [
211 provider({
212 usageLimits: {
213 checkedAt: new Date(now + 60_000).toISOString(),
214 windows: [{ ...window, usedPercent: 80 }],
215 },
216 }),
217 ],
218 },
219 });
220 const snapshot = buildSubscriptionUsageSnapshot(input, deepLink);
221 expect(snapshot.providers[0]?.detail).toBe("Subscription remaining");
222 expect(snapshot.providers[0]?.windows[0]?.remaining).toBe(20);
223 });
224});
225
226describe("widget refresh probes", () => {
227 it("throttles each connected environment independently and retries failures", async () => {
228 const probe = vi
229 .fn<(id: string) => Promise<void>>()
230 .mockRejectedValueOnce(new Error("offline"))
231 .mockResolvedValue(undefined);
232 const refresh = createWidgetRefresher(probe);
233 await refresh([], now);
234 expect(probe).not.toHaveBeenCalled();
235 await refresh(["first"], now);
236 await refresh(["first", "second"], now + 1);
237 expect(probe.mock.calls).toEqual([["first"], ["second"]]);
238 await refresh(["first"], now + WIDGET_REFRESH_INTERVAL);
239 expect(probe.mock.calls).toEqual([["first"], ["second"], ["first"]]);
240 });
241
242 it("does not overlap a slow probe even after the refresh interval", async () => {
243 let finish!: () => void;
244 const probe = vi.fn(
245 () =>
246 new Promise<void>((resolve) => {
247 finish = resolve;
248 }),
249 );
250 const refresh = createWidgetRefresher(probe);
251 const first = refresh(["one", "one"], now);
252 await refresh(["one"], now + WIDGET_REFRESH_INTERVAL);
253 expect(probe).toHaveBeenCalledTimes(1);
254 finish();
255 await first;
256 });
257});
apps/mobile/src/widgets/subscriptionUsageSnapshot.ts created+134
...@@ -0,0 +1,134 @@
1import {
2 collectLimitAccounts,
3 collectLimitPools,
4 type LimitAccount,
5 type LimitPresentations,
6} from "@t3tools/shared/usageLimits";
7
8export interface SubscriptionUsageSnapshot {
9 url?: string;
10 checkedAt: number;
11 providers: Array<{
12 name: string;
13 detail: string;
14 windows: Array<{ kind?: string; label: string; remaining: number; reset: string }>;
15 expiresAt: number;
16 totalWindows: number;
17 }>;
18}
19
20// Snapshots expire after 15 minutes; background refresh needs a
21// separate authenticated transport while the mobile app is suspended.
22const SNAPSHOT_MAX_AGE = 15 * 60_000;
23export const WIDGET_REFRESH_INTERVAL = 5 * 60_000;
24
25/** Bound probes across config updates, reconnects, and foreground transitions. */
26export function createWidgetRefresher<Id>(refresh: (id: Id) => Promise<unknown>) {
27 const attempted = new Map<Id, number>();
28 const pending = new Set<Id>();
29 return async (connected: readonly Id[], now: number) => {
30 await Promise.allSettled(
31 connected.map(async (id) => {
32 if (pending.has(id) || now - (attempted.get(id) ?? -Infinity) < WIDGET_REFRESH_INTERVAL)
33 return;
34 attempted.set(id, now);
35 pending.add(id);
36 try {
37 await refresh(id);
38 } finally {
39 pending.delete(id);
40 }
41 }),
42 );
43 };
44}
45
46function subscriptionUsageProps(
47 accounts: readonly LimitAccount[],
48 now: number,
49): SubscriptionUsageSnapshot {
50 const pools = collectLimitPools(accounts, now);
51 const checked = accounts
52 .filter((account) => account.driver === "codex" || account.driver === "claudeAgent")
53 .map((account) => Date.parse(account.limits.checkedAt));
54 return {
55 checkedAt: checked.length > 0 && checked.every(Number.isFinite) ? Math.min(...checked) : 0,
56 providers: (["codex", "claudeAgent"] as const).map((driver) => {
57 const pool = pools.find((candidate) => candidate.driver === driver);
58 const name = driver === "codex" ? "Codex" : "Claude";
59 if (!pool)
60 return { name, detail: "No limits available", windows: [], expiresAt: 0, totalWindows: 0 };
61 const checkedAt = Math.min(...pool.accounts.map((a) => Date.parse(a.limits.checkedAt)));
62 const expiresAt = Math.min(
63 checkedAt + SNAPSHOT_MAX_AGE,
64 ...pool.windows.flatMap((window) => window.resets.map((reset) => reset.at)),
65 );
66 const fresh = Number.isFinite(expiresAt) && expiresAt > now;
67 const sortedWindows = [...pool.windows].sort(
68 (a, b) => a.remainingPercent - b.remainingPercent,
69 );
70 // Keep a session and weekly limit when scoped limits fill the storage budget.
71 const selectedWindows = [
72 ...new Set([
73 sortedWindows.find((window) => window.kind === "session"),
74 sortedWindows.find((window) => window.kind === "weekly"),
75 ...sortedWindows,
76 ]),
77 ]
78 .filter((window) => window !== undefined)
79 .slice(0, 6)
80 .sort((a, b) => a.remainingPercent - b.remainingPercent);
81 return {
82 name,
83 detail: !fresh
84 ? "Open T3 to refresh"
85 : pool.accounts.length > 1
86 ? `${pool.accounts.length} accounts · pooled`
87 : "Subscription remaining",
88 expiresAt: fresh ? expiresAt : 0,
89 totalWindows: fresh ? pool.windows.length : 0,
90 windows: fresh
91 ? selectedWindows.map((window) => ({
92 kind: window.kind,
93 label: window.label,
94 remaining: Math.round(window.remainingPercent),
95 reset: window.resets[0]
96 ? `Next reset ${new Date(window.resets[0].at).toLocaleString(undefined, {
97 month: "short",
98 day: "numeric",
99 hour: "numeric",
100 minute: "2-digit",
101 })}`
102 : "Reset time unavailable",
103 }))
104 : [],
105 };
106 }),
107 };
108}
109
110/** Deduplicate accounts before pooling, and only publish display data to the OS. */
111export function buildSubscriptionUsageSnapshot(
112 presentations: LimitPresentations,
113 url: string,
114): SubscriptionUsageSnapshot {
115 // Freshness is evaluated at publication/render time, not on unrelated config emissions.
116 return { ...subscriptionUsageProps(collectLimitAccounts(presentations), 0), url };
117}
118
119export function subscriptionUsageTimeline(snapshot: SubscriptionUsageSnapshot, now: number) {
120 const deadlines = [...new Set(snapshot.providers.map((p) => p.expiresAt))]
121 .filter((deadline) => deadline > now)
122 .sort((a, b) => a - b);
123 return [now, ...deadlines].map((date) => ({
124 date: new Date(date),
125 props: {
126 ...snapshot,
127 providers: snapshot.providers.map((provider) =>
128 provider.windows.length > 0 && provider.expiresAt <= date
129 ? { ...provider, detail: "Open T3 to refresh", windows: [], totalWindows: 0 }
130 : provider,
131 ),
132 },
133 }));
134}
apps/mobile/src/widgets/useSubscriptionUsage.ts created+40
...@@ -0,0 +1,40 @@
1import { useAtomValue } from "@effect/atom-react";
2import { useEffect, useMemo } from "react";
3import { AppState } from "react-native";
4
5import { environmentPresentations } from "../state/presentation";
6import { serverEnvironment } from "../state/server";
7import { useAtomCommand } from "../state/use-atom-command";
8import { createWidgetRefresher, WIDGET_REFRESH_INTERVAL } from "./subscriptionUsageSnapshot";
9
10export function useSubscriptionUsage(enabled = true) {
11 const presentations = useAtomValue(environmentPresentations.presentationsAtom);
12 const refreshProviders = useAtomCommand(serverEnvironment.refreshProviders, {
13 reportFailure: false,
14 });
15 const refresh = useMemo(
16 () =>
17 createWidgetRefresher(
18 (environmentId: Parameters<typeof refreshProviders>[0]["environmentId"]) =>
19 refreshProviders({ environmentId, input: {} }),
20 ),
21 [refreshProviders],
22 );
23
24 useEffect(() => {
25 const update = () => {
26 if (!enabled || AppState.currentState !== "active") return;
27 const connected = [...presentations]
28 .filter(([, presentation]) => presentation.connection.phase === "connected")
29 .map(([id]) => id);
30 void refresh(connected, Date.now());
31 };
32 update();
33 const subscription = AppState.addEventListener("change", update);
34 const timer = setInterval(update, WIDGET_REFRESH_INTERVAL);
35 return () => {
36 subscription.remove();
37 clearInterval(timer);
38 };
39 }, [enabled, presentations, refresh]);
40}
docs/user/usage.md+6
...@@ -81,3 +81,9 @@ account and choose **Use reset** to redeem one. No hub plugin is required....@@ -81,3 +81,9 @@ account and choose **Use reset** to redeem one. No hub plugin is required.
81This connection supplies usage information; configure81This connection supplies usage information; configure
82the provider separately to send agent requests through the hub. Remove the hub from the same82the provider separately to send agent requests through the hub. Remove the hub from the same
83settings section when you no longer need it.83settings section when you no longer need it.
84
85## Subscription usage widget
86
87Add **Subscription usage** from your iOS or Android widget gallery to see remaining Codex and
88Claude quotas. Tap it to open **Usage → Limits**. On iOS, use **Edit Widget** to choose Session,
89Weekly, or both for each provider. Reopen T3 to refresh expired readings.
packages/shared/src/usageLimits.test.ts-188
...@@ -3,7 +3,6 @@ import {...@@ -3,7 +3,6 @@ import {
3 ProviderDriverKind,3 ProviderDriverKind,
4 ProviderInstanceId,4 ProviderInstanceId,
5 type ServerProvider,5 type ServerProvider,
6 type UsageLimitSourceAccount,
7 UsageLimitSourceId,6 UsageLimitSourceId,
8} from "@t3tools/contracts";7} from "@t3tools/contracts";
9import { describe, expect, it } from "vite-plus/test";8import { describe, expect, it } from "vite-plus/test";
...@@ -17,8 +16,6 @@ import {...@@ -17,8 +16,6 @@ import {
17 collectLimitAccounts,16 collectLimitAccounts,
18 collectLimitNotices,17 collectLimitNotices,
19 collectLimitPools,18 collectLimitPools,
20 collectLimitSources,
21 collectLimitsGroups,
22 elapsedShare,19 elapsedShare,
23 formatResetsIn,20 formatResetsIn,
24 limitsNotice,21 limitsNotice,
...@@ -129,191 +126,6 @@ describe("providersWithLimits", () => {...@@ -129,191 +126,6 @@ describe("providersWithLimits", () => {
129 });126 });
130});127});
131128
132describe("collectLimitsGroups", () => {
133 it("labels environments only when more than one reports limits", () => {
134 const limits = { checkedAt: "2026-09-03T11:00:00.000Z", windows: [window] };
135 const codex = provider({ usageLimits: limits });
136 const one = new Map([
137 ["env-a", { entry: { target: { label: "Laptop" } }, serverConfig: { providers: [codex] } }],
138 [
139 "env-b",
140 { entry: { target: { label: "Desktop" } }, serverConfig: { providers: [provider({})] } },
141 ],
142 ] as const);
143 expect(collectLimitsGroups(one as never).map((group) => group.environmentLabel)).toEqual([
144 null,
145 ]);
146
147 const two = new Map([
148 ["env-a", { entry: { target: { label: "Laptop" } }, serverConfig: { providers: [codex] } }],
149 ["env-b", { entry: { target: { label: "Desktop" } }, serverConfig: { providers: [codex] } }],
150 ] as const);
151 expect(collectLimitsGroups(two as never).map((group) => group.environmentLabel)).toEqual([
152 "Laptop",
153 "Desktop",
154 ]);
155 });
156});
157
158describe("collectLimitSources", () => {
159 const source = {
160 id: UsageLimitSourceId.make("cliproxy-hub"),
161 kind: "cliproxy" as const,
162 label: "hub",
163 checkedAt: "2026-09-03T11:00:00.000Z",
164 accounts: [],
165 };
166 const limits = { checkedAt: source.checkedAt, windows: [window] };
167 const account: UsageLimitSourceAccount = {
168 id: "codex-personal",
169 driver: ProviderDriverKind.make("codex"),
170 email: "person@example.com",
171 plan: "ChatGPT Pro Subscription",
172 usageLimits: limits,
173 };
174 const native = provider({
175 displayName: "Personal",
176 auth: { status: "authenticated", email: account.email },
177 usageLimits: { ...limits, resetCredits: { availableCount: 2 } },
178 });
179
180 function presentations(
181 providers: readonly ServerProvider[],
182 accounts: readonly UsageLimitSourceAccount[] = [account],
183 ) {
184 return new Map([
185 [
186 EnvironmentId.make("env-a"),
187 {
188 entry: { target: { label: "Laptop" } },
189 serverConfig: { providers, usageLimitSources: [{ ...source, accounts }] },
190 },
191 ],
192 ]);
193 }
194
195 it.each(["codex", "claudeAgent"])(
196 "prefers native %s limits by email without changing provider rows or source snapshots",
197 (kind) => {
198 const driver = ProviderDriverKind.make(kind);
199 const first = { ...native, driver };
200 const second = { ...first, instanceId: ProviderInstanceId.make("work") };
201 const accounts = [{ ...account, driver, email: " Person@Example.COM " }];
202 const input = presentations([first, second], accounts);
203
204 expect(collectLimitSources(input)).toMatchObject([{ accounts: [], hiddenAccountCount: 1 }]);
205 expect(collectLimitsGroups(input)[0]?.providers).toEqual([first, second]);
206 expect(accounts).toHaveLength(1);
207 expect(first.usageLimits?.resetCredits?.availableCount).toBe(2);
208 },
209 );
210
211 it("matches across environments even when the hub is visited before the native provider", () => {
212 const input = presentations([]);
213 input.set(EnvironmentId.make("env-b"), {
214 entry: { target: { label: "Desktop" } },
215 serverConfig: { providers: [native], usageLimitSources: [] },
216 });
217
218 expect(collectLimitSources(input)).toMatchObject([
219 { accounts: [], hiddenAccountCount: 1, environmentId: "env-a" },
220 ]);
221 });
222
223 it("keeps other providers, other emails, and unidentified accounts with the same plan", () => {
224 const accounts = [
225 account,
226 { ...account, id: "other-provider", driver: ProviderDriverKind.make("claudeAgent") },
227 { ...account, id: "other-email", email: "other@example.com" },
228 { ...account, id: "unknown-email", email: undefined },
229 ];
230
231 expect(collectLimitSources(presentations([native], accounts))).toMatchObject([
232 { accounts: accounts.slice(1), hiddenAccountCount: 1 },
233 ]);
234 expect(
235 collectLimitSources(
236 presentations([{ ...native, auth: { status: "authenticated" } }], accounts),
237 )[0]?.accounts,
238 ).toEqual(accounts);
239 });
240
241 it.each([
242 { enabled: false },
243 { installed: false },
244 { availability: "unavailable" },
245 { usageLimits: undefined },
246 { usageLimits: { ...limits, windows: [] } },
247 { usageLimits: { ...limits, unavailable: { reason: "probeFailed" } } },
248 { usageLimits: { ...limits, unavailable: { reason: "unsupported" } } },
249 ] satisfies Partial<ServerProvider>[])(
250 "retains hub limits when the native provider cannot show them: %j",
251 (overrides) => {
252 expect(collectLimitSources(presentations([{ ...native, ...overrides }]))).toMatchObject([
253 { accounts: [account], hiddenAccountCount: 0 },
254 ]);
255 },
256 );
257
258 it("restores the hub account when the matching provider disappears", () => {
259 const input = presentations([native]);
260 expect(collectLimitSources(input)[0]?.accounts).toEqual([]);
261 input.delete(EnvironmentId.make("env-a"));
262 for (const [id, entry] of presentations([])) input.set(id, entry);
263
264 expect(collectLimitSources(input)[0]?.accounts).toEqual([account]);
265 });
266
267 it("keeps source errors and genuinely empty sources distinguishable from hidden accounts", () => {
268 const input = new Map([
269 [
270 EnvironmentId.make("env-a"),
271 {
272 entry: { target: { label: "Laptop" } },
273 serverConfig: {
274 providers: [native],
275 usageLimitSources: [{ ...source, error: "Hub unavailable" }],
276 },
277 },
278 ],
279 ]);
280 expect(collectLimitSources(input)).toMatchObject([
281 { accounts: [], hiddenAccountCount: 0, error: "Hub unavailable" },
282 ]);
283 });
284
285 it("keys sources per environment and names the environment only when several have some", () => {
286 const one = new Map([
287 [
288 "env-a",
289 { entry: { target: { label: "Laptop" } }, serverConfig: { usageLimitSources: [source] } },
290 ],
291 [
292 "env-b",
293 { entry: { target: { label: "Desktop" } }, serverConfig: { usageLimitSources: [] } },
294 ],
295 ] as const);
296 expect(collectLimitSources(one as never).map((entry) => [entry.key, entry.label])).toEqual([
297 ["env-a:cliproxy-hub", "hub"],
298 ]);
299
300 const two = new Map([
301 [
302 "env-a",
303 { entry: { target: { label: "Laptop" } }, serverConfig: { usageLimitSources: [source] } },
304 ],
305 [
306 "env-b",
307 { entry: { target: { label: "Desktop" } }, serverConfig: { usageLimitSources: [source] } },
308 ],
309 ] as const);
310 expect(collectLimitSources(two as never).map((entry) => entry.label)).toEqual([
311 "Laptop · hub",
312 "Desktop · hub",
313 ]);
314 });
315});
316
317describe("pools", () => {129describe("pools", () => {
318 const checkedAt = "2026-09-03T11:00:00.000Z";130 const checkedAt = "2026-09-03T11:00:00.000Z";
319 const weekly = {131 const weekly = {
packages/shared/src/usageLimits.ts+11-108
...@@ -15,7 +15,6 @@ import {...@@ -15,7 +15,6 @@ import {
15 type ServerProvider,15 type ServerProvider,
16 type ServerProviderUsageLimits,16 type ServerProviderUsageLimits,
17 type ServerProviderUsageWindow,17 type ServerProviderUsageWindow,
18 type UsageLimitSourceSnapshot,
19 type UsageLimitSourceSnapshots,18 type UsageLimitSourceSnapshots,
20} from "@t3tools/contracts";19} from "@t3tools/contracts";
2120
...@@ -42,108 +41,16 @@ export function providersWithLimits(...@@ -42,108 +41,16 @@ export function providersWithLimits(
42 );41 );
43}42}
4443
45export interface LimitsGroup {44export type LimitPresentations = ReadonlyMap<
46 readonly environmentId: EnvironmentId;45 EnvironmentId,
47 /** Null while only one environment is connected; there is nothing to tell apart. */46 {
48 readonly environmentLabel: string | null;47 readonly entry: { readonly target: { readonly label: string } };
49 readonly providers: readonly ServerProvider[];48 readonly serverConfig: {
50}49 readonly providers?: readonly ServerProvider[] | undefined;
5150 readonly usageLimitSources?: UsageLimitSourceSnapshots | undefined;
52/**51 } | null;
53 * One group per connected environment with a provider reporting limits.
54 * Provider snapshots come from the config stream every client already holds,
55 * so opening the view costs no extra request.
56 */
57export function collectLimitsGroups(
58 presentations: ReadonlyMap<
59 EnvironmentId,
60 {
61 readonly entry: { readonly target: { readonly label: string } };
62 readonly serverConfig: {
63 readonly providers?: readonly ServerProvider[] | undefined;
64 } | null;
65 }
66 >,
67): readonly LimitsGroup[] {
68 const groups: LimitsGroup[] = [];
69 for (const [environmentId, presentation] of presentations) {
70 const providers = providersWithLimits(presentation.serverConfig?.providers ?? []);
71 if (providers.length === 0) continue;
72 groups.push({ environmentId, environmentLabel: presentation.entry.target.label, providers });
73 }
74 return groups.length > 1 ? groups : groups.map((group) => ({ ...group, environmentLabel: null }));
75}
76
77/**
78 * Every usage-limit source across connected environments, keyed so two
79 * environments pointing at the same hub still get their own rows. The label
80 * carries the environment only when more than one environment has sources.
81 * A native provider with usable limits takes precedence over the same account
82 * in a source, even when it belongs to another connected environment.
83 */
84export function collectLimitSources(
85 presentations: ReadonlyMap<
86 EnvironmentId,
87 {
88 readonly entry: { readonly target: { readonly label: string } };
89 readonly serverConfig: {
90 readonly providers?: readonly ServerProvider[] | undefined;
91 readonly usageLimitSources?: UsageLimitSourceSnapshots | undefined;
92 } | null;
93 }
94 >,
95): ReadonlyArray<
96 UsageLimitSourceSnapshot & {
97 readonly key: string;
98 readonly environmentId: EnvironmentId;
99 readonly hiddenAccountCount: number;
100 }52 }
101> {53>;
102 const nativeAccounts = new Set<string>();
103 for (const presentation of presentations.values()) {
104 for (const provider of providersWithLimits(presentation.serverConfig?.providers ?? [])) {
105 const key = accountKey(provider.driver, provider.auth.email);
106 if (
107 key !== null &&
108 provider.usageLimits?.windows.length &&
109 !provider.usageLimits.unavailable
110 ) {
111 nativeAccounts.add(key);
112 }
113 }
114 }
115 const perEnvironment: Array<{
116 readonly environmentId: EnvironmentId;
117 readonly environmentLabel: string;
118 readonly sources: UsageLimitSourceSnapshots;
119 }> = [];
120 for (const [environmentId, presentation] of presentations) {
121 const sources = presentation.serverConfig?.usageLimitSources ?? [];
122 if (sources.length === 0) continue;
123 perEnvironment.push({
124 environmentId,
125 environmentLabel: presentation.entry.target.label,
126 sources,
127 });
128 }
129 const labelEnvironment = perEnvironment.length > 1;
130 return perEnvironment.flatMap(({ environmentId, environmentLabel, sources }) =>
131 sources.map((source) => {
132 const accounts = source.accounts.filter((account) => {
133 const key = accountKey(account.driver, account.email);
134 return key === null || !nativeAccounts.has(key);
135 });
136 return {
137 ...source,
138 accounts,
139 hiddenAccountCount: source.accounts.length - accounts.length,
140 environmentId,
141 key: `${environmentId}:${source.id}`,
142 label: labelEnvironment ? `${environmentLabel} · ${source.label}` : source.label,
143 };
144 }),
145 );
146}
14754
148function accountKey(driver: ServerProvider["driver"], email: string | undefined): string | null {55function accountKey(driver: ServerProvider["driver"], email: string | undefined): string | null {
149 const normalizedEmail = email?.trim().toLowerCase();56 const normalizedEmail = email?.trim().toLowerCase();
...@@ -184,9 +91,7 @@ export interface LimitAccount {...@@ -184,9 +91,7 @@ export interface LimitAccount {
184 * entry per distinct account. The freshest reads supply windows and credits;91 * entry per distinct account. The freshest reads supply windows and credits;
185 * native instances supply names and environment labels.92 * native instances supply names and environment labels.
186 */93 */
187export function collectLimitAccounts(94export function collectLimitAccounts(presentations: LimitPresentations): readonly LimitAccount[] {
188 presentations: Parameters<typeof collectLimitSources>[0],
189): readonly LimitAccount[] {
190 const accounts = new Map<string, LimitAccount>();95 const accounts = new Map<string, LimitAccount>();
191 const creditSources = new Map<string, LimitAccount>();96 const creditSources = new Map<string, LimitAccount>();
192 const hubRedeems = new Map<string, LimitAccount>();97 const hubRedeems = new Map<string, LimitAccount>();
...@@ -315,9 +220,7 @@ export function collectLimitAccounts(...@@ -315,9 +220,7 @@ export function collectLimitAccounts(
315 * are left out; there is nothing for the user to act on. The environment220 * are left out; there is nothing for the user to act on. The environment
316 * is named only when more than one is connected.221 * is named only when more than one is connected.
317 */222 */
318export function collectLimitNotices(223export function collectLimitNotices(presentations: LimitPresentations): readonly string[] {
319 presentations: Parameters<typeof collectLimitSources>[0],
320): readonly string[] {
321 const label = (environmentLabel: string, subject: string) =>224 const label = (environmentLabel: string, subject: string) =>
322 presentations.size > 1 ? `${environmentLabel} · ${subject}` : subject;225 presentations.size > 1 ? `${environmentLabel} · ${subject}` : subject;
323 const notices: string[] = [];226 const notices: string[] = [];