From b69c79065688cd98b26fef76dc372d5c50808f89 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Sat, 5 Sep 2026 18:34:06 +1000 Subject: [PATCH 1/6] feat(mobile): add subscription usage home screen widgets --- apps/mobile/app.config.ts | 6 + .../android/build.gradle | 18 ++ .../android/src/main/AndroidManifest.xml | 8 + .../SubscriptionUsageWidget.kt | 79 +++++++++ .../T3SubscriptionWidgetModule.kt | 18 ++ .../t3_subscription_widget_background.xml | 4 + .../res/layout/t3_subscription_widget.xml | 9 + .../res/layout/t3_subscription_widget_row.xml | 10 ++ .../src/main/res/values-night/colors.xml | 5 + .../android/src/main/res/values/colors.xml | 5 + .../android/src/main/res/values/strings.xml | 10 ++ .../res/xml/t3_subscription_widget_info.xml | 9 + .../expo-module.config.json | 4 + apps/mobile/src/App.tsx | 3 + .../src/features/usage/UsageRouteScreen.tsx | 18 +- .../src/widgets/SubscriptionUsage.test.ts | 70 ++++++++ apps/mobile/src/widgets/SubscriptionUsage.tsx | 87 ++++++++++ .../widgets/SubscriptionUsageCoordinator.tsx | 30 ++++ .../publishSubscriptionUsage.android.ts | 8 + .../widgets/publishSubscriptionUsage.ios.ts | 11 ++ .../src/widgets/publishSubscriptionUsage.ts | 3 + .../widgets/subscriptionUsageSnapshot.test.ts | 157 ++++++++++++++++++ .../src/widgets/subscriptionUsageSnapshot.ts | 97 +++++++++++ docs/user/usage.md | 19 +++ 24 files changed, 684 insertions(+), 4 deletions(-) create mode 100644 apps/mobile/modules/t3-subscription-widget/android/build.gradle create mode 100644 apps/mobile/modules/t3-subscription-widget/android/src/main/AndroidManifest.xml create mode 100644 apps/mobile/modules/t3-subscription-widget/android/src/main/java/expo/modules/t3subscriptionwidget/SubscriptionUsageWidget.kt create mode 100644 apps/mobile/modules/t3-subscription-widget/android/src/main/java/expo/modules/t3subscriptionwidget/T3SubscriptionWidgetModule.kt create mode 100644 apps/mobile/modules/t3-subscription-widget/android/src/main/res/drawable/t3_subscription_widget_background.xml create mode 100644 apps/mobile/modules/t3-subscription-widget/android/src/main/res/layout/t3_subscription_widget.xml create mode 100644 apps/mobile/modules/t3-subscription-widget/android/src/main/res/layout/t3_subscription_widget_row.xml create mode 100644 apps/mobile/modules/t3-subscription-widget/android/src/main/res/values-night/colors.xml create mode 100644 apps/mobile/modules/t3-subscription-widget/android/src/main/res/values/colors.xml create mode 100644 apps/mobile/modules/t3-subscription-widget/android/src/main/res/values/strings.xml create mode 100644 apps/mobile/modules/t3-subscription-widget/android/src/main/res/xml/t3_subscription_widget_info.xml create mode 100644 apps/mobile/modules/t3-subscription-widget/expo-module.config.json create mode 100644 apps/mobile/src/widgets/SubscriptionUsage.test.ts create mode 100644 apps/mobile/src/widgets/SubscriptionUsage.tsx create mode 100644 apps/mobile/src/widgets/SubscriptionUsageCoordinator.tsx create mode 100644 apps/mobile/src/widgets/publishSubscriptionUsage.android.ts create mode 100644 apps/mobile/src/widgets/publishSubscriptionUsage.ios.ts create mode 100644 apps/mobile/src/widgets/publishSubscriptionUsage.ts create mode 100644 apps/mobile/src/widgets/subscriptionUsageSnapshot.test.ts create mode 100644 apps/mobile/src/widgets/subscriptionUsageSnapshot.ts diff --git a/apps/mobile/app.config.ts b/apps/mobile/app.config.ts index f34f9c4b39d8..15f3cb5e3148 100644 --- a/apps/mobile/app.config.ts +++ b/apps/mobile/app.config.ts @@ -130,6 +130,12 @@ const widgetsPlugin: NonNullable[number] = [ // frequent-updates entitlement iOS throttles the update budget sooner. frequentUpdates: true, widgets: [ + { + name: "SubscriptionUsage", + displayName: "Subscription usage", + description: "Subscription quotas from your connected T3 Code environments.", + supportedFamilies: ["systemSmall", "systemMedium", "systemLarge"], + }, { name: "AgentActivity", displayName: "Agent Activity", diff --git a/apps/mobile/modules/t3-subscription-widget/android/build.gradle b/apps/mobile/modules/t3-subscription-widget/android/build.gradle new file mode 100644 index 000000000000..7de5052417c0 --- /dev/null +++ b/apps/mobile/modules/t3-subscription-widget/android/build.gradle @@ -0,0 +1,18 @@ +apply plugin: 'com.android.library' +apply plugin: 'org.jetbrains.kotlin.android' + +group = 'com.t3tools.subscriptionwidget' +version = '0.0.0' + +android { + namespace 'expo.modules.t3subscriptionwidget' + compileSdk rootProject.ext.compileSdkVersion + defaultConfig { + minSdkVersion rootProject.ext.minSdkVersion + targetSdkVersion rootProject.ext.targetSdkVersion + } +} + +dependencies { + implementation project(':expo-modules-core') +} diff --git a/apps/mobile/modules/t3-subscription-widget/android/src/main/AndroidManifest.xml b/apps/mobile/modules/t3-subscription-widget/android/src/main/AndroidManifest.xml new file mode 100644 index 000000000000..e66f03cffae5 --- /dev/null +++ b/apps/mobile/modules/t3-subscription-widget/android/src/main/AndroidManifest.xml @@ -0,0 +1,8 @@ + + + + + + + + diff --git a/apps/mobile/modules/t3-subscription-widget/android/src/main/java/expo/modules/t3subscriptionwidget/SubscriptionUsageWidget.kt b/apps/mobile/modules/t3-subscription-widget/android/src/main/java/expo/modules/t3subscriptionwidget/SubscriptionUsageWidget.kt new file mode 100644 index 000000000000..ca33ab71f53a --- /dev/null +++ b/apps/mobile/modules/t3-subscription-widget/android/src/main/java/expo/modules/t3subscriptionwidget/SubscriptionUsageWidget.kt @@ -0,0 +1,79 @@ +package expo.modules.t3subscriptionwidget + +import android.app.PendingIntent +import android.appwidget.AppWidgetManager +import android.appwidget.AppWidgetProvider +import android.content.ComponentName +import android.content.Context +import android.content.Intent +import android.net.Uri +import android.os.Bundle +import android.view.View +import android.widget.RemoteViews +import org.json.JSONObject +import java.text.DateFormat +import java.util.Date + +class SubscriptionUsageWidget : AppWidgetProvider() { + override fun onUpdate(context: Context, manager: AppWidgetManager, ids: IntArray) { + ids.forEach { update(context, manager, it) } + } + + override fun onAppWidgetOptionsChanged(context: Context, manager: AppWidgetManager, id: Int, options: Bundle) { + update(context, manager, id) + } + + companion object { + const val PREFERENCES = "t3_subscription_widget" + + fun updateAll(context: Context) { + val manager = AppWidgetManager.getInstance(context) + manager.getAppWidgetIds(ComponentName(context, SubscriptionUsageWidget::class.java)) + .forEach { update(context, manager, it) } + } + + private fun update(context: Context, manager: AppWidgetManager, id: Int) { + val saved = context.getSharedPreferences(PREFERENCES, 0).getString("snapshot", null) + val snapshot = try { saved?.let { JSONObject(it) } } catch (_: Exception) { null } + val views = RemoteViews(context.packageName, R.layout.t3_subscription_widget) + // Explicitly target this variant's launcher so co-installed builds cannot steal the tap. + val intent = context.packageManager.getLaunchIntentForPackage(context.packageName)?.apply { + action = Intent.ACTION_VIEW + data = Uri.parse(snapshot?.optString("deepLink")?.takeIf { it.isNotBlank() } + ?: "t3code://settings/usage?tab=limits") + flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TOP + } + if (intent != null) { + views.setOnClickPendingIntent(R.id.t3_widget_root, PendingIntent.getActivity( + context, id, intent, PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE)) + } + val rows = snapshot?.optJSONArray("rows") + if (rows != null && rows.length() > 0) { + views.removeAllViews(R.id.t3_widget_rows) + val height = manager.getAppWidgetOptions(id).getInt(AppWidgetManager.OPTION_APPWIDGET_MIN_HEIGHT, 180) + val count = ((height - 64) / 66).coerceIn(1, 8).coerceAtMost(rows.length()) + var oldest = Long.MAX_VALUE + for (index in 0 until count) { + val row = rows.optJSONObject(index) ?: continue + val child = RemoteViews(context.packageName, R.layout.t3_subscription_widget_row) + val used = if (row.isNull("usedPercent")) null else row.optInt("usedPercent").coerceIn(0, 100) + child.setTextViewText(R.id.t3_widget_label, row.optString("label")) + child.setTextViewText(R.id.t3_widget_window, row.optString("window")) + child.setTextViewText(R.id.t3_widget_percent, used?.let { context.getString(R.string.t3_subscription_widget_used, it) } ?: "—") + child.setViewVisibility(R.id.t3_widget_progress, if (used == null) View.GONE else View.VISIBLE) + if (used != null) child.setProgressBar(R.id.t3_widget_progress, 100, used, false) + child.setTextViewText(R.id.t3_widget_reset, + if (row.optLong("expiresAt") <= System.currentTimeMillis()) context.getString(R.string.t3_subscription_widget_refresh) + else row.optString("resetLabel")) + oldest = minOf(oldest, row.optLong("checkedAt")) + views.addView(R.id.t3_widget_rows, child) + } + val remaining = (snapshot?.optInt("totalRows", rows.length()) ?: rows.length()) - count + val formatted = DateFormat.getDateTimeInstance(DateFormat.SHORT, DateFormat.SHORT).format(Date(oldest)) + views.setTextViewText(R.id.t3_widget_footer, context.getString(R.string.t3_subscription_widget_as_of, formatted) + + if (remaining > 0) context.getString(R.string.t3_subscription_widget_more, remaining) else "") + } + manager.updateAppWidget(id, views) + } + } +} diff --git a/apps/mobile/modules/t3-subscription-widget/android/src/main/java/expo/modules/t3subscriptionwidget/T3SubscriptionWidgetModule.kt b/apps/mobile/modules/t3-subscription-widget/android/src/main/java/expo/modules/t3subscriptionwidget/T3SubscriptionWidgetModule.kt new file mode 100644 index 000000000000..6557b70958de --- /dev/null +++ b/apps/mobile/modules/t3-subscription-widget/android/src/main/java/expo/modules/t3subscriptionwidget/T3SubscriptionWidgetModule.kt @@ -0,0 +1,18 @@ +package expo.modules.t3subscriptionwidget + +import expo.modules.kotlin.modules.Module +import expo.modules.kotlin.modules.ModuleDefinition +import org.json.JSONObject + +class T3SubscriptionWidgetModule : Module() { + override fun definition() = ModuleDefinition { + Name("T3SubscriptionWidget") + Function("updateSnapshot") { snapshot: String -> + val context = appContext.reactContext ?: return@Function + JSONObject(snapshot) // Reject malformed writes before replacing the saved snapshot. + context.getSharedPreferences(SubscriptionUsageWidget.PREFERENCES, 0) + .edit().putString("snapshot", snapshot).apply() + SubscriptionUsageWidget.updateAll(context) + } + } +} diff --git a/apps/mobile/modules/t3-subscription-widget/android/src/main/res/drawable/t3_subscription_widget_background.xml b/apps/mobile/modules/t3-subscription-widget/android/src/main/res/drawable/t3_subscription_widget_background.xml new file mode 100644 index 000000000000..69ec028ee8af --- /dev/null +++ b/apps/mobile/modules/t3-subscription-widget/android/src/main/res/drawable/t3_subscription_widget_background.xml @@ -0,0 +1,4 @@ + + + + diff --git a/apps/mobile/modules/t3-subscription-widget/android/src/main/res/layout/t3_subscription_widget.xml b/apps/mobile/modules/t3-subscription-widget/android/src/main/res/layout/t3_subscription_widget.xml new file mode 100644 index 000000000000..58e55bc2c81e --- /dev/null +++ b/apps/mobile/modules/t3-subscription-widget/android/src/main/res/layout/t3_subscription_widget.xml @@ -0,0 +1,9 @@ + + + + + + + diff --git a/apps/mobile/modules/t3-subscription-widget/android/src/main/res/layout/t3_subscription_widget_row.xml b/apps/mobile/modules/t3-subscription-widget/android/src/main/res/layout/t3_subscription_widget_row.xml new file mode 100644 index 000000000000..7c98f66ec137 --- /dev/null +++ b/apps/mobile/modules/t3-subscription-widget/android/src/main/res/layout/t3_subscription_widget_row.xml @@ -0,0 +1,10 @@ + + + + + + + + + diff --git a/apps/mobile/modules/t3-subscription-widget/android/src/main/res/values-night/colors.xml b/apps/mobile/modules/t3-subscription-widget/android/src/main/res/values-night/colors.xml new file mode 100644 index 000000000000..0647eb1a8ea7 --- /dev/null +++ b/apps/mobile/modules/t3-subscription-widget/android/src/main/res/values-night/colors.xml @@ -0,0 +1,5 @@ + + #18181B + #FAFAFA + #A1A1AA + diff --git a/apps/mobile/modules/t3-subscription-widget/android/src/main/res/values/colors.xml b/apps/mobile/modules/t3-subscription-widget/android/src/main/res/values/colors.xml new file mode 100644 index 000000000000..bf4ca10ecfc4 --- /dev/null +++ b/apps/mobile/modules/t3-subscription-widget/android/src/main/res/values/colors.xml @@ -0,0 +1,5 @@ + + #FAFAFA + #18181B + #52525B + diff --git a/apps/mobile/modules/t3-subscription-widget/android/src/main/res/values/strings.xml b/apps/mobile/modules/t3-subscription-widget/android/src/main/res/values/strings.xml new file mode 100644 index 000000000000..68bd26c191cf --- /dev/null +++ b/apps/mobile/modules/t3-subscription-widget/android/src/main/res/values/strings.xml @@ -0,0 +1,10 @@ + + Subscription usage + Saved subscription quotas from your T3 Code environments. Tap to refresh in the app. + Open T3 Code and connect an environment to see limits. + Tap to open Usage + Open app to refresh + %1$d%% used + As of %1$s + · +%1$d more + diff --git a/apps/mobile/modules/t3-subscription-widget/android/src/main/res/xml/t3_subscription_widget_info.xml b/apps/mobile/modules/t3-subscription-widget/android/src/main/res/xml/t3_subscription_widget_info.xml new file mode 100644 index 000000000000..4dfdad5b0fec --- /dev/null +++ b/apps/mobile/modules/t3-subscription-widget/android/src/main/res/xml/t3_subscription_widget_info.xml @@ -0,0 +1,9 @@ + diff --git a/apps/mobile/modules/t3-subscription-widget/expo-module.config.json b/apps/mobile/modules/t3-subscription-widget/expo-module.config.json new file mode 100644 index 000000000000..7ddc9c09e221 --- /dev/null +++ b/apps/mobile/modules/t3-subscription-widget/expo-module.config.json @@ -0,0 +1,4 @@ +{ + "platforms": ["android"], + "android": { "modules": ["expo.modules.t3subscriptionwidget.T3SubscriptionWidgetModule"] } +} diff --git a/apps/mobile/src/App.tsx b/apps/mobile/src/App.tsx index b7f1db54e8c0..852b6c0560e3 100644 --- a/apps/mobile/src/App.tsx +++ b/apps/mobile/src/App.tsx @@ -24,6 +24,8 @@ import { OverlayPortalHost } from "./components/OverlayPortal"; import { appBlurTargetRef } from "./lib/appBlurTarget"; import { useMobileNavigationTheme } from "./lib/useMobileNavigationTheme"; +import { SubscriptionUsageCoordinator } from "./widgets/SubscriptionUsageCoordinator"; + import "../global.css"; if (process.env.EXPO_PUBLIC_SHOWCASE === "1") { @@ -77,6 +79,7 @@ function AppContent() { return ( <> + diff --git a/apps/mobile/src/features/usage/UsageRouteScreen.tsx b/apps/mobile/src/features/usage/UsageRouteScreen.tsx index b3232acc6810..8af6bd69ff33 100644 --- a/apps/mobile/src/features/usage/UsageRouteScreen.tsx +++ b/apps/mobile/src/features/usage/UsageRouteScreen.tsx @@ -1,5 +1,5 @@ import { EnvironmentId, USAGE_CONTRACT_VERSION } from "@t3tools/contracts"; -import { useNavigation } from "@react-navigation/native"; +import { type RouteProp, useNavigation, useRoute } from "@react-navigation/native"; import { isCompatibleUsageContractVersion, isModelCostUnknown, @@ -65,11 +65,21 @@ const CHART_HEIGHT = 180; * pull to refresh, each refreshing its own data. */ export function UsageRouteScreen() { + const route = useRoute>(); const navigation = useNavigation(); const insets = useSafeAreaInsets(); - // Limits first: remaining quota and reset time are what most people open - // the screen for. - const [tab, setTab] = useState("limits"); + const [selection, setSelection] = useState(() => ({ + params: route.params, + tab: (route.params?.tab === "usage" ? "usage" : "limits") as UsageTab, + })); + if (selection.params !== route.params) { + setSelection({ + params: route.params, + tab: route.params?.tab === "usage" ? "usage" : "limits", + }); + } + const { tab } = selection; + const setTab = (tab: UsageTab) => setSelection({ params: route.params, tab }); const [windowSelection, setWindowSelection] = useState(() => ({ days: 30, window: makeWindow(30), diff --git a/apps/mobile/src/widgets/SubscriptionUsage.test.ts b/apps/mobile/src/widgets/SubscriptionUsage.test.ts new file mode 100644 index 000000000000..5f85e6411dbb --- /dev/null +++ b/apps/mobile/src/widgets/SubscriptionUsage.test.ts @@ -0,0 +1,70 @@ +import { describe, expect, it, vi } from "vite-plus/test"; +vi.mock("@expo/ui/swift-ui", () => + Object.fromEntries( + ["HStack", "ProgressView", "Spacer", "Text", "VStack"].map((name) => [name, name]), + ), +); +vi.mock("@expo/ui/swift-ui/modifiers", () => + Object.fromEntries( + ["font", "foregroundStyle", "lineLimit", "tint", "widgetURL"].map((name) => [ + name, + (value: unknown) => ({ [name]: value }), + ]), + ), +); +vi.mock("expo-widgets", () => ({ createWidget: (name: string) => ({ name }) })); +import { SubscriptionUsage } from "./SubscriptionUsage"; +import type { SubscriptionUsageSnapshot } from "./subscriptionUsageSnapshot"; + +const now = Date.parse("2026-09-05T12:00:00Z"); +const snapshot: SubscriptionUsageSnapshot = { + deepLink: "t3code-dev://settings/usage?tab=limits", + totalRows: 8, + rows: Array.from({ length: 8 }, (_, index) => ({ + label: `Provider ${index}`, + window: "Weekly", + usedPercent: 90, + resetLabel: "Resets tomorrow", + checkedAt: now, + expiresAt: now + 600_000, + })), +}; + +describe("iOS subscription widget", () => { + it.each([ + ["systemSmall", 1], + ["systemMedium", 2], + ["systemLarge", 4], + ] as const)("bounds %s content and reports omitted windows", (widgetFamily, count) => { + const tree = JSON.stringify( + SubscriptionUsage(snapshot, { date: new Date(now), widgetFamily, configuration: undefined }), + ); + expect(tree).toContain(`Provider ${count - 1}`); + expect(tree).not.toContain(`Provider ${count}`); + expect(tree).toContain(`+${8 - count} more`); + expect(tree).toContain(snapshot.deepLink); + }); + it("renders gallery props without account data", () => { + const tree = JSON.stringify( + SubscriptionUsage({} as SubscriptionUsageSnapshot, { + date: new Date(now), + widgetFamily: "systemSmall", + configuration: undefined, + }), + ); + expect(tree).toContain("connect an environment"); + expect(tree).not.toContain("Invalid Date"); + }); + it("replaces reset copy when the snapshot expires", () => { + const tree = JSON.stringify( + SubscriptionUsage(snapshot, { + date: new Date(now + 600_000), + widgetFamily: "systemSmall", + configuration: undefined, + }), + ); + expect(tree).toContain("Open app to refresh"); + expect(tree).not.toContain("Resets tomorrow"); + expect(tree).toContain("90% used"); + }); +}); diff --git a/apps/mobile/src/widgets/SubscriptionUsage.tsx b/apps/mobile/src/widgets/SubscriptionUsage.tsx new file mode 100644 index 000000000000..883484172146 --- /dev/null +++ b/apps/mobile/src/widgets/SubscriptionUsage.tsx @@ -0,0 +1,87 @@ +import { HStack, ProgressView, Spacer, Text, VStack } from "@expo/ui/swift-ui"; +import { font, foregroundStyle, lineLimit, tint, widgetURL } from "@expo/ui/swift-ui/modifiers"; +import { createWidget, type WidgetEnvironment } from "expo-widgets"; +import type { SubscriptionUsageSnapshot } from "./subscriptionUsageSnapshot"; + +export function SubscriptionUsage( + props: SubscriptionUsageSnapshot, + environment: WidgetEnvironment, +) { + "widget"; + const rows = props.rows ?? []; + const count = + environment.widgetFamily === "systemLarge" + ? 4 + : environment.widgetFamily === "systemMedium" + ? 2 + : 1; + const visible = rows.slice(0, count); + const now = environment.date.getTime(); + const renderRow = (row: SubscriptionUsageSnapshot["rows"][number], index: number) => ( + + + + {row.label} + + + + {row.usedPercent === null ? "—" : `${row.usedPercent}% used`} + + + + {row.window} + + {row.usedPercent !== null ? ( + = 90 ? "#dc2626" : row.usedPercent >= 70 ? "#d97706" : "#0284c7"), + ]} + /> + ) : null} + + {row.expiresAt <= now ? "Open app to refresh" : row.resetLabel} + + + ); + + return ( + + + Subscription usage + + {visible.length === 0 ? ( + + Open T3 Code and connect an environment to see limits. + + ) : null} + {environment.widgetFamily === "systemMedium" ? ( + + {visible.map(renderRow)} + + ) : ( + visible.map(renderRow) + )} + + + {visible.length > 0 + ? `As of ${new Date(Math.min(...visible.map((row) => row.checkedAt))).toLocaleString(undefined, { month: "short", day: "numeric", hour: "numeric", minute: "2-digit" })}${props.totalRows > visible.length ? ` · +${props.totalRows - visible.length} more` : ""}` + : "Tap to open Usage"} + + + ); +} + +export default createWidget("SubscriptionUsage", SubscriptionUsage); diff --git a/apps/mobile/src/widgets/SubscriptionUsageCoordinator.tsx b/apps/mobile/src/widgets/SubscriptionUsageCoordinator.tsx new file mode 100644 index 000000000000..3d4ad4ab6c4a --- /dev/null +++ b/apps/mobile/src/widgets/SubscriptionUsageCoordinator.tsx @@ -0,0 +1,30 @@ +import { useAtomValue } from "@effect/atom-react"; +import { Atom } from "effect/unstable/reactivity"; +import * as Linking from "expo-linking"; +import { useEffect } from "react"; +import { environmentCatalog } from "../connection/catalog"; +import { environmentPresentations } from "../state/presentation"; +import { publishSubscriptionUsage } from "./publishSubscriptionUsage"; +import { buildSubscriptionUsageSnapshot } from "./subscriptionUsageSnapshot"; + +// Isolate quota changes from the much busier thread/config presentation stream. +const snapshotAtom = Atom.make((get) => + buildSubscriptionUsageSnapshot( + get(environmentPresentations.presentationsAtom), + Linking.createURL("settings/usage", { queryParams: { tab: "limits" } }), + ), +).pipe(Atom.withEquality((a, b) => JSON.stringify(a) === JSON.stringify(b))); + +export function SubscriptionUsageCoordinator() { + const catalog = useAtomValue(environmentCatalog.catalogValueAtom); + const snapshot = useAtomValue(snapshotAtom); + useEffect(() => { + if (!catalog.isReady) return; + void Promise.resolve() + .then(() => publishSubscriptionUsage(snapshot)) + .catch((error: unknown) => { + console.warn("Could not update subscription usage widget", error); + }); + }, [catalog.isReady, snapshot]); + return null; +} diff --git a/apps/mobile/src/widgets/publishSubscriptionUsage.android.ts b/apps/mobile/src/widgets/publishSubscriptionUsage.android.ts new file mode 100644 index 000000000000..a37b4f61eca3 --- /dev/null +++ b/apps/mobile/src/widgets/publishSubscriptionUsage.android.ts @@ -0,0 +1,8 @@ +import { requireOptionalNativeModule } from "expo"; +import type { SubscriptionUsageSnapshot } from "./subscriptionUsageSnapshot"; + +export function publishSubscriptionUsage(snapshot: SubscriptionUsageSnapshot) { + requireOptionalNativeModule<{ updateSnapshot: (snapshot: string) => void }>( + "T3SubscriptionWidget", + )?.updateSnapshot(JSON.stringify(snapshot)); +} diff --git a/apps/mobile/src/widgets/publishSubscriptionUsage.ios.ts b/apps/mobile/src/widgets/publishSubscriptionUsage.ios.ts new file mode 100644 index 000000000000..c386f927f942 --- /dev/null +++ b/apps/mobile/src/widgets/publishSubscriptionUsage.ios.ts @@ -0,0 +1,11 @@ +import { requireOptionalNativeModule } from "expo"; +import { + subscriptionUsageTimeline, + type SubscriptionUsageSnapshot, +} from "./subscriptionUsageSnapshot"; + +export async function publishSubscriptionUsage(snapshot: SubscriptionUsageSnapshot) { + if (!requireOptionalNativeModule("ExpoWidgets")) return; + const { default: widget } = await import("./SubscriptionUsage"); + widget.updateTimeline(subscriptionUsageTimeline(snapshot, Date.now())); +} diff --git a/apps/mobile/src/widgets/publishSubscriptionUsage.ts b/apps/mobile/src/widgets/publishSubscriptionUsage.ts new file mode 100644 index 000000000000..3a50eb20d820 --- /dev/null +++ b/apps/mobile/src/widgets/publishSubscriptionUsage.ts @@ -0,0 +1,3 @@ +import type { SubscriptionUsageSnapshot } from "./subscriptionUsageSnapshot"; + +export function publishSubscriptionUsage(_snapshot: SubscriptionUsageSnapshot) {} diff --git a/apps/mobile/src/widgets/subscriptionUsageSnapshot.test.ts b/apps/mobile/src/widgets/subscriptionUsageSnapshot.test.ts new file mode 100644 index 000000000000..2641fef7cbe2 --- /dev/null +++ b/apps/mobile/src/widgets/subscriptionUsageSnapshot.test.ts @@ -0,0 +1,157 @@ +import { + EnvironmentId, + ProviderDriverKind, + ProviderInstanceId, + UsageLimitSourceId, + type ServerProvider, +} from "@t3tools/contracts"; +import { describe, expect, it } from "vite-plus/test"; +import { + buildSubscriptionUsageSnapshot, + subscriptionUsageTimeline, +} from "./subscriptionUsageSnapshot"; + +const checkedAt = "2026-09-05T12:00:00.000Z"; +const now = Date.parse(checkedAt); +const window = { + id: "session", + kind: "session", + label: "5 hours", + usedPercent: 40, + resetsAt: "2026-09-05T12:10:00.000Z", +} as const; +const limits = { checkedAt, windows: [window] }; +const deepLink = "t3code-dev://settings/usage?tab=limits"; +function provider(overrides: Partial = {}): ServerProvider { + return { + instanceId: ProviderInstanceId.make("codex"), + driver: ProviderDriverKind.make("codex"), + enabled: true, + installed: true, + version: null, + status: "ready", + auth: { status: "authenticated", email: "private@example.com" }, + checkedAt, + models: [], + slashCommands: [], + skills: [], + usageLimits: limits, + ...overrides, + }; +} +function presentations(providers: readonly ServerProvider[] = [provider()]) { + return new Map([ + [ + EnvironmentId.make("env"), + { entry: { target: { label: "Remote" } }, serverConfig: { providers } }, + ], + ]); +} + +describe("subscription widget snapshots", () => { + it("uses provider data and its observation time without exposing account emails", () => { + const snapshot = buildSubscriptionUsageSnapshot(presentations(), deepLink); + expect(snapshot.rows[0]).toMatchObject({ + label: "Codex", + usedPercent: 40, + checkedAt: now, + expiresAt: now + 10 * 60_000, + }); + expect(snapshot.deepLink).toBe(deepLink); + expect(JSON.stringify(snapshot)).not.toContain("private@example.com"); + }); + it("clears data after removing environments and hides disabled providers", () => { + expect(buildSubscriptionUsageSnapshot(new Map(), deepLink).rows).toEqual([]); + expect( + buildSubscriptionUsageSnapshot(presentations([provider({ enabled: false })]), deepLink).rows, + ).toEqual([]); + }); + it("uses upstream deduplication for a native account also present in a proxy hub", () => { + const input = new Map([ + [ + EnvironmentId.make("env"), + { + entry: { target: { label: "Remote" } }, + serverConfig: { + providers: [provider()], + usageLimitSources: [ + { + id: UsageLimitSourceId.make("hub"), + kind: "cliproxy" as const, + label: "Hub", + checkedAt, + accounts: [ + { + id: "account", + driver: ProviderDriverKind.make("codex"), + email: " PRIVATE@example.com ", + usageLimits: limits, + }, + ], + }, + ], + }, + }, + ], + ]); + expect(buildSubscriptionUsageSnapshot(input, deepLink).rows).toHaveLength(1); + input.get(EnvironmentId.make("env"))!.serverConfig.providers = []; + const snapshot = buildSubscriptionUsageSnapshot(input, deepLink); + expect(snapshot.rows[0]?.label).toBe("Hub · Codex 1"); + expect(JSON.stringify(snapshot)).not.toContain("example.com"); + }); + it("keeps unavailable quotas distinct from zero usage and omits provider error messages", () => { + const snapshot = buildSubscriptionUsageSnapshot( + presentations([ + provider({ + usageLimits: { + ...limits, + unavailable: { reason: "probeFailed", message: "token secret" }, + }, + }), + ]), + deepLink, + ); + expect(snapshot.rows[0]).toMatchObject({ usedPercent: null, window: "Limits unavailable" }); + expect(JSON.stringify(snapshot)).not.toContain("token secret"); + expect( + buildSubscriptionUsageSnapshot( + presentations([ + provider({ usageLimits: { checkedAt, windows: [{ ...window, usedPercent: 0 }] } }), + ]), + deepLink, + ).rows[0]?.usedPercent, + ).toBe(0); + }); + it("bounds OS storage and puts the most constrained windows first", () => { + const windows = Array.from({ length: 20 }, (_, index) => ({ + ...window, + id: `${index}`, + usedPercent: index * 5, + })); + const snapshot = buildSubscriptionUsageSnapshot( + presentations([provider({ usageLimits: { checkedAt, windows } })]), + deepLink, + ); + expect(snapshot.rows).toHaveLength(8); + expect(snapshot.totalRows).toBe(20); + expect(snapshot.rows[0]?.usedPercent).toBe(95); + }); + it("marks unknown or distant reset times stale after thirty minutes", () => { + const snapshot = buildSubscriptionUsageSnapshot( + presentations([ + provider({ usageLimits: { checkedAt, windows: [{ ...window, resetsAt: undefined }] } }), + ]), + deepLink, + ); + expect(snapshot.rows[0]?.expiresAt).toBe(now + 30 * 60_000); + expect(snapshot.rows[0]?.resetLabel).toBe("Reset time unavailable"); + }); + it("schedules a reset boundary without inventing a zero quota", () => { + const snapshot = buildSubscriptionUsageSnapshot(presentations(), deepLink); + const timeline = subscriptionUsageTimeline(snapshot, now); + expect(timeline.map((entry) => entry.date.getTime())).toEqual([now, now + 10 * 60_000]); + expect(timeline[1]?.props.rows[0]?.usedPercent).toBe(40); + expect(subscriptionUsageTimeline(snapshot, now + 60 * 60_000)).toHaveLength(1); + }); +}); diff --git a/apps/mobile/src/widgets/subscriptionUsageSnapshot.ts b/apps/mobile/src/widgets/subscriptionUsageSnapshot.ts new file mode 100644 index 000000000000..1eb35f5fff52 --- /dev/null +++ b/apps/mobile/src/widgets/subscriptionUsageSnapshot.ts @@ -0,0 +1,97 @@ +import type { ServerProvider, ServerProviderUsageLimits } from "@t3tools/contracts"; +import { collectLimitSources, collectLimitsGroups } from "@t3tools/shared/usageLimits"; + +export interface SubscriptionUsageRow { + readonly label: string; + readonly window: string; + readonly usedPercent: number | null; + readonly resetLabel: string; + readonly expiresAt: number; + readonly checkedAt: number; +} + +export interface SubscriptionUsageSnapshot { + readonly rows: readonly SubscriptionUsageRow[]; + readonly totalRows: number; + readonly deepLink: string; +} + +type Presentations = Parameters[0] & + Parameters[0]; +const MAX_AGE = 30 * 60_000; + +/** Only display data crosses into OS storage; credentials and emails stay in the app. */ +export function buildSubscriptionUsageSnapshot( + presentations: Presentations, + deepLink: string, +): SubscriptionUsageSnapshot { + const rows: SubscriptionUsageRow[] = []; + const add = (label: string, limits: ServerProviderUsageLimits, sourceFailed = false) => { + const checkedAt = Date.parse(limits.checkedAt); + if (limits.unavailable || sourceFailed || limits.windows.length === 0) { + rows.push({ + label, + window: + limits.unavailable?.reason === "unsupported" + ? "No subscription limits" + : "Limits unavailable", + usedPercent: null, + resetLabel: "Open app for details", + checkedAt, + expiresAt: 0, + }); + return; + } + for (const window of limits.windows) { + const reset = window.resetsAt ? Date.parse(window.resetsAt) : NaN; + rows.push({ + label, + window: window.label, + usedPercent: Math.round(Math.max(0, Math.min(100, window.usedPercent))), + resetLabel: Number.isFinite(reset) + ? `Resets ${new Date(reset).toLocaleString(undefined, { month: "short", day: "numeric", hour: "numeric", minute: "2-digit" })}` + : "Reset time unavailable", + checkedAt, + expiresAt: Math.min(checkedAt + MAX_AGE, Number.isFinite(reset) ? reset : Infinity), + }); + } + }; + const driverLabel = (driver: string) => ({ codex: "Codex", claudeAgent: "Claude" })[driver]; + const providerLimitsLabel = (provider: ServerProvider) => + provider.displayName?.trim() || driverLabel(provider.driver) || String(provider.driver); + for (const group of collectLimitsGroups(presentations)) { + for (const provider of group.providers) { + if (!provider.usageLimits) continue; + const label = providerLimitsLabel(provider); + add( + group.environmentLabel ? `${group.environmentLabel} · ${label}` : label, + provider.usageLimits, + ); + } + } + for (const source of collectLimitSources(presentations)) { + for (const [index, account] of source.accounts.entries()) { + add( + `${source.label} · ${driverLabel(account.driver) ?? account.driver} ${index + 1}`, + account.usageLimits, + Boolean(source.error), + ); + } + if (source.error && source.accounts.length === 0) { + add(source.label, { checkedAt: source.checkedAt, windows: [] }); + } + } + // Most constrained windows stay visible in the smallest families. Stable + // sorting preserves account order when two windows have the same quota. + rows.sort((a, b) => (b.usedPercent ?? -1) - (a.usedPercent ?? -1)); + return { rows: rows.slice(0, 8), totalRows: rows.length, deepLink }; +} + +/** Schedule expiry without pretending that a reset supplies a fresh quota reading. */ +export function subscriptionUsageTimeline(snapshot: SubscriptionUsageSnapshot, now: number) { + const dates = [ + now, + ...new Set(snapshot.rows.map((row) => row.expiresAt).filter((at) => at > now)), + ]; + return dates.sort((a, b) => a - b).map((at) => ({ date: new Date(at), props: snapshot })); +} diff --git a/docs/user/usage.md b/docs/user/usage.md index f4fdb5cca002..481bab2518da 100644 --- a/docs/user/usage.md +++ b/docs/user/usage.md @@ -81,3 +81,22 @@ account and choose **Use reset** to redeem one. No hub plugin is required. This connection supplies usage information; configure the provider separately to send agent requests through the hub. Remove the hub from the same settings section when you no longer need it. + +## Add subscription usage to your home screen + +On iOS or Android, open T3 Code and connect your environments, then add the **Subscription usage** +widget from your phone's widget gallery. On iOS, choose small, medium, or large. On Android, +resize the widget to show more quota windows. Tap it to open **Usage → Limits**. + +The widget shows saved quota percentages, reset times, and when the displayed data was checked. +The most-used quota windows appear first; a “more” count indicates additional windows in the app. +Account email addresses are omitted. Providers without subscription usage data do not appear. + +Data updates while the app receives information from connected environments. The widget does +not fetch quotas while the app is closed. After thirty minutes or a reported reset time, it asks +you to open the app to refresh; Android may show this notice at its next system widget update. +Pull to refresh on **Limits** to request a new reading. A reset never makes the saved percentage +zero automatically. Removing an environment in the app removes its data from the widget. + +If the widget is missing from the gallery, update the installed app and open it once. Widgets +require an app build that includes them; iOS builds without widget extensions do not offer them. From 29484cca6d6c367ea7523331f8b9596339783924 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Sat, 5 Sep 2026 18:40:34 +1000 Subject: [PATCH 2/6] fix(mobile): satisfy native widget lint rules --- .../SubscriptionUsageWidget.kt | 91 +++++++++++++------ 1 file changed, 65 insertions(+), 26 deletions(-) diff --git a/apps/mobile/modules/t3-subscription-widget/android/src/main/java/expo/modules/t3subscriptionwidget/SubscriptionUsageWidget.kt b/apps/mobile/modules/t3-subscription-widget/android/src/main/java/expo/modules/t3subscriptionwidget/SubscriptionUsageWidget.kt index ca33ab71f53a..15a7795e19b4 100644 --- a/apps/mobile/modules/t3-subscription-widget/android/src/main/java/expo/modules/t3subscriptionwidget/SubscriptionUsageWidget.kt +++ b/apps/mobile/modules/t3-subscription-widget/android/src/main/java/expo/modules/t3subscriptionwidget/SubscriptionUsageWidget.kt @@ -19,7 +19,12 @@ class SubscriptionUsageWidget : AppWidgetProvider() { ids.forEach { update(context, manager, it) } } - override fun onAppWidgetOptionsChanged(context: Context, manager: AppWidgetManager, id: Int, options: Bundle) { + override fun onAppWidgetOptionsChanged( + context: Context, + manager: AppWidgetManager, + id: Int, + options: Bundle + ) { update(context, manager, id) } @@ -34,46 +39,80 @@ class SubscriptionUsageWidget : AppWidgetProvider() { private fun update(context: Context, manager: AppWidgetManager, id: Int) { val saved = context.getSharedPreferences(PREFERENCES, 0).getString("snapshot", null) - val snapshot = try { saved?.let { JSONObject(it) } } catch (_: Exception) { null } - val views = RemoteViews(context.packageName, R.layout.t3_subscription_widget) - // Explicitly target this variant's launcher so co-installed builds cannot steal the tap. - val intent = context.packageManager.getLaunchIntentForPackage(context.packageName)?.apply { - action = Intent.ACTION_VIEW - data = Uri.parse(snapshot?.optString("deepLink")?.takeIf { it.isNotBlank() } - ?: "t3code://settings/usage?tab=limits") - flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TOP + val snapshot = try { + saved?.let { JSONObject(it) } + } catch (_: Exception) { + null } - if (intent != null) { - views.setOnClickPendingIntent(R.id.t3_widget_root, PendingIntent.getActivity( - context, id, intent, PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE)) + val views = RemoteViews(context.packageName, R.layout.t3_subscription_widget) + openAppIntent(context, id, snapshot)?.let { + views.setOnClickPendingIntent(R.id.t3_widget_root, it) } val rows = snapshot?.optJSONArray("rows") if (rows != null && rows.length() > 0) { views.removeAllViews(R.id.t3_widget_rows) - val height = manager.getAppWidgetOptions(id).getInt(AppWidgetManager.OPTION_APPWIDGET_MIN_HEIGHT, 180) + val options = manager.getAppWidgetOptions(id) + val height = options.getInt(AppWidgetManager.OPTION_APPWIDGET_MIN_HEIGHT, 180) val count = ((height - 64) / 66).coerceIn(1, 8).coerceAtMost(rows.length()) var oldest = Long.MAX_VALUE for (index in 0 until count) { val row = rows.optJSONObject(index) ?: continue - val child = RemoteViews(context.packageName, R.layout.t3_subscription_widget_row) - val used = if (row.isNull("usedPercent")) null else row.optInt("usedPercent").coerceIn(0, 100) - child.setTextViewText(R.id.t3_widget_label, row.optString("label")) - child.setTextViewText(R.id.t3_widget_window, row.optString("window")) - child.setTextViewText(R.id.t3_widget_percent, used?.let { context.getString(R.string.t3_subscription_widget_used, it) } ?: "—") - child.setViewVisibility(R.id.t3_widget_progress, if (used == null) View.GONE else View.VISIBLE) - if (used != null) child.setProgressBar(R.id.t3_widget_progress, 100, used, false) - child.setTextViewText(R.id.t3_widget_reset, - if (row.optLong("expiresAt") <= System.currentTimeMillis()) context.getString(R.string.t3_subscription_widget_refresh) - else row.optString("resetLabel")) + val child = rowView(context, row) oldest = minOf(oldest, row.optLong("checkedAt")) views.addView(R.id.t3_widget_rows, child) } val remaining = (snapshot?.optInt("totalRows", rows.length()) ?: rows.length()) - count - val formatted = DateFormat.getDateTimeInstance(DateFormat.SHORT, DateFormat.SHORT).format(Date(oldest)) - views.setTextViewText(R.id.t3_widget_footer, context.getString(R.string.t3_subscription_widget_as_of, formatted) + - if (remaining > 0) context.getString(R.string.t3_subscription_widget_more, remaining) else "") + val formatted = DateFormat.getDateTimeInstance( + DateFormat.SHORT, + DateFormat.SHORT + ).format(Date(oldest)) + val more = if (remaining > 0) { + context.getString(R.string.t3_subscription_widget_more, remaining) + } else { + "" + } + views.setTextViewText( + R.id.t3_widget_footer, + context.getString(R.string.t3_subscription_widget_as_of, formatted) + more + ) } manager.updateAppWidget(id, views) } + + private fun openAppIntent(context: Context, id: Int, snapshot: JSONObject?): PendingIntent? { + // Target this variant's launcher so co-installed builds cannot steal the tap. + val intent = + context.packageManager.getLaunchIntentForPackage(context.packageName) ?: return null + intent.action = Intent.ACTION_VIEW + val deepLink = snapshot?.optString("deepLink")?.takeIf { it.isNotBlank() } + ?: "t3code://settings/usage?tab=limits" + intent.data = Uri.parse(deepLink) + intent.flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TOP + return PendingIntent.getActivity( + context, + id, + intent, + PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE + ) + } + + private fun rowView(context: Context, row: JSONObject): RemoteViews { + val child = RemoteViews(context.packageName, R.layout.t3_subscription_widget_row) + val used = if (row.isNull("usedPercent")) null else row.optInt("usedPercent").coerceIn(0, 100) + child.setTextViewText(R.id.t3_widget_label, row.optString("label")) + child.setTextViewText(R.id.t3_widget_window, row.optString("window")) + val percent = used?.let { context.getString(R.string.t3_subscription_widget_used, it) } ?: "—" + child.setTextViewText(R.id.t3_widget_percent, percent) + val visibility = if (used == null) View.GONE else View.VISIBLE + child.setViewVisibility(R.id.t3_widget_progress, visibility) + if (used != null) child.setProgressBar(R.id.t3_widget_progress, 100, used, false) + val reset = if (row.optLong("expiresAt") <= System.currentTimeMillis()) { + context.getString(R.string.t3_subscription_widget_refresh) + } else { + row.optString("resetLabel") + } + child.setTextViewText(R.id.t3_widget_reset, reset) + return child + } } } From e6578aa92147535261da40ecd218f1c5be390ac9 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Sat, 5 Sep 2026 19:21:05 +1000 Subject: [PATCH 3/6] fix(mobile): persist unavailable quotas in iOS widgets --- .../src/widgets/SubscriptionUsage.test.ts | 24 +++++++++++++++++++ apps/mobile/src/widgets/SubscriptionUsage.tsx | 13 ++++++---- .../widgets/subscriptionUsageSnapshot.test.ts | 3 ++- .../src/widgets/subscriptionUsageSnapshot.ts | 4 ++-- 4 files changed, 37 insertions(+), 7 deletions(-) diff --git a/apps/mobile/src/widgets/SubscriptionUsage.test.ts b/apps/mobile/src/widgets/SubscriptionUsage.test.ts index 5f85e6411dbb..601043a83285 100644 --- a/apps/mobile/src/widgets/SubscriptionUsage.test.ts +++ b/apps/mobile/src/widgets/SubscriptionUsage.test.ts @@ -67,4 +67,28 @@ describe("iOS subscription widget", () => { expect(tree).not.toContain("Resets tomorrow"); expect(tree).toContain("90% used"); }); + it("renders an omitted quota as unavailable rather than zero", () => { + const tree = JSON.stringify( + SubscriptionUsage( + { + ...snapshot, + rows: [ + { + label: "Claude", + window: "No subscription limits", + resetLabel: "Open app for details", + checkedAt: now, + expiresAt: 0, + }, + ], + totalRows: 1, + }, + { date: new Date(now), widgetFamily: "systemSmall", configuration: undefined }, + ), + ); + expect(tree).toContain("—"); + expect(tree).toContain("No subscription limits"); + expect(tree).not.toContain("ProgressView"); + expect(tree).not.toContain("0% used"); + }); }); diff --git a/apps/mobile/src/widgets/SubscriptionUsage.tsx b/apps/mobile/src/widgets/SubscriptionUsage.tsx index 883484172146..d68be5109ef2 100644 --- a/apps/mobile/src/widgets/SubscriptionUsage.tsx +++ b/apps/mobile/src/widgets/SubscriptionUsage.tsx @@ -31,13 +31,13 @@ export function SubscriptionUsage( - {row.usedPercent === null ? "—" : `${row.usedPercent}% used`} + {typeof row.usedPercent !== "number" ? "—" : `${row.usedPercent}% used`} {row.window} - {row.usedPercent !== null ? ( + {typeof row.usedPercent === "number" ? ( - Subscription usage + {environment.widgetFamily === "systemSmall" ? "Usage limits" : "Subscription usage"} {visible.length === 0 ? ( @@ -77,9 +77,14 @@ export function SubscriptionUsage( {visible.length > 0 - ? `As of ${new Date(Math.min(...visible.map((row) => row.checkedAt))).toLocaleString(undefined, { month: "short", day: "numeric", hour: "numeric", minute: "2-digit" })}${props.totalRows > visible.length ? ` · +${props.totalRows - visible.length} more` : ""}` + ? `As of ${new Date(Math.min(...visible.map((row) => row.checkedAt))).toLocaleString(undefined, { month: "short", day: "numeric", hour: "numeric", minute: "2-digit" })}` : "Tap to open Usage"} + {props.totalRows > visible.length ? ( + + {`+${props.totalRows - visible.length} more`} + + ) : null} ); } diff --git a/apps/mobile/src/widgets/subscriptionUsageSnapshot.test.ts b/apps/mobile/src/widgets/subscriptionUsageSnapshot.test.ts index 2641fef7cbe2..7b1539ffdca3 100644 --- a/apps/mobile/src/widgets/subscriptionUsageSnapshot.test.ts +++ b/apps/mobile/src/widgets/subscriptionUsageSnapshot.test.ts @@ -112,7 +112,8 @@ describe("subscription widget snapshots", () => { ]), deepLink, ); - expect(snapshot.rows[0]).toMatchObject({ usedPercent: null, window: "Limits unavailable" }); + expect(snapshot.rows[0]).toMatchObject({ window: "Limits unavailable" }); + expect(snapshot.rows[0]).not.toHaveProperty("usedPercent"); expect(JSON.stringify(snapshot)).not.toContain("token secret"); expect( buildSubscriptionUsageSnapshot( diff --git a/apps/mobile/src/widgets/subscriptionUsageSnapshot.ts b/apps/mobile/src/widgets/subscriptionUsageSnapshot.ts index 1eb35f5fff52..08e88ae1bf6b 100644 --- a/apps/mobile/src/widgets/subscriptionUsageSnapshot.ts +++ b/apps/mobile/src/widgets/subscriptionUsageSnapshot.ts @@ -4,7 +4,8 @@ import { collectLimitSources, collectLimitsGroups } from "@t3tools/shared/usageL export interface SubscriptionUsageRow { readonly label: string; readonly window: string; - readonly usedPercent: number | null; + // Omit unavailable values: iOS widget storage accepts property lists, not null. + readonly usedPercent?: number; readonly resetLabel: string; readonly expiresAt: number; readonly checkedAt: number; @@ -35,7 +36,6 @@ export function buildSubscriptionUsageSnapshot( limits.unavailable?.reason === "unsupported" ? "No subscription limits" : "Limits unavailable", - usedPercent: null, resetLabel: "Open app for details", checkedAt, expiresAt: 0, From 480aa832b2aac1d1e318206618a69e728f8ea618 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Sat, 5 Sep 2026 19:27:04 +1000 Subject: [PATCH 4/6] fix(mobile): expire widget readings with invalid timestamps --- .../t3subscriptionwidget/SubscriptionUsageWidget.kt | 8 +++++++- .../android/src/main/res/values/strings.xml | 1 + apps/mobile/src/widgets/SubscriptionUsage.test.ts | 4 +++- apps/mobile/src/widgets/SubscriptionUsage.tsx | 5 ++++- .../mobile/src/widgets/subscriptionUsageSnapshot.test.ts | 9 +++++++++ apps/mobile/src/widgets/subscriptionUsageSnapshot.ts | 8 ++++++-- 6 files changed, 30 insertions(+), 5 deletions(-) diff --git a/apps/mobile/modules/t3-subscription-widget/android/src/main/java/expo/modules/t3subscriptionwidget/SubscriptionUsageWidget.kt b/apps/mobile/modules/t3-subscription-widget/android/src/main/java/expo/modules/t3subscriptionwidget/SubscriptionUsageWidget.kt index 15a7795e19b4..8cd69b187b86 100644 --- a/apps/mobile/modules/t3-subscription-widget/android/src/main/java/expo/modules/t3subscriptionwidget/SubscriptionUsageWidget.kt +++ b/apps/mobile/modules/t3-subscription-widget/android/src/main/java/expo/modules/t3subscriptionwidget/SubscriptionUsageWidget.kt @@ -73,7 +73,13 @@ class SubscriptionUsageWidget : AppWidgetProvider() { } views.setTextViewText( R.id.t3_widget_footer, - context.getString(R.string.t3_subscription_widget_as_of, formatted) + more + ( + if (oldest > 0) { + context.getString(R.string.t3_subscription_widget_as_of, formatted) + } else { + context.getString(R.string.t3_subscription_widget_unknown_check) + } + ) + more ) } manager.updateAppWidget(id, views) diff --git a/apps/mobile/modules/t3-subscription-widget/android/src/main/res/values/strings.xml b/apps/mobile/modules/t3-subscription-widget/android/src/main/res/values/strings.xml index 68bd26c191cf..d41800f6c507 100644 --- a/apps/mobile/modules/t3-subscription-widget/android/src/main/res/values/strings.xml +++ b/apps/mobile/modules/t3-subscription-widget/android/src/main/res/values/strings.xml @@ -1,4 +1,5 @@ + Last checked unavailable Subscription usage Saved subscription quotas from your T3 Code environments. Tap to refresh in the app. Open T3 Code and connect an environment to see limits. diff --git a/apps/mobile/src/widgets/SubscriptionUsage.test.ts b/apps/mobile/src/widgets/SubscriptionUsage.test.ts index 601043a83285..14952bc787b0 100644 --- a/apps/mobile/src/widgets/SubscriptionUsage.test.ts +++ b/apps/mobile/src/widgets/SubscriptionUsage.test.ts @@ -77,7 +77,7 @@ describe("iOS subscription widget", () => { label: "Claude", window: "No subscription limits", resetLabel: "Open app for details", - checkedAt: now, + checkedAt: 0, expiresAt: 0, }, ], @@ -88,6 +88,8 @@ describe("iOS subscription widget", () => { ); expect(tree).toContain("—"); expect(tree).toContain("No subscription limits"); + expect(tree).toContain("Last checked unavailable"); + expect(tree).not.toContain("1970"); expect(tree).not.toContain("ProgressView"); expect(tree).not.toContain("0% used"); }); diff --git a/apps/mobile/src/widgets/SubscriptionUsage.tsx b/apps/mobile/src/widgets/SubscriptionUsage.tsx index d68be5109ef2..00eca6d71a0a 100644 --- a/apps/mobile/src/widgets/SubscriptionUsage.tsx +++ b/apps/mobile/src/widgets/SubscriptionUsage.tsx @@ -16,6 +16,7 @@ export function SubscriptionUsage( ? 2 : 1; const visible = rows.slice(0, count); + const oldest = Math.min(...visible.map((row) => row.checkedAt)); const now = environment.date.getTime(); const renderRow = (row: SubscriptionUsageSnapshot["rows"][number], index: number) => ( @@ -77,7 +78,9 @@ export function SubscriptionUsage( {visible.length > 0 - ? `As of ${new Date(Math.min(...visible.map((row) => row.checkedAt))).toLocaleString(undefined, { month: "short", day: "numeric", hour: "numeric", minute: "2-digit" })}` + ? oldest > 0 + ? `As of ${new Date(oldest).toLocaleString(undefined, { month: "short", day: "numeric", hour: "numeric", minute: "2-digit" })}` + : "Last checked unavailable" : "Tap to open Usage"} {props.totalRows > visible.length ? ( diff --git a/apps/mobile/src/widgets/subscriptionUsageSnapshot.test.ts b/apps/mobile/src/widgets/subscriptionUsageSnapshot.test.ts index 7b1539ffdca3..652efa6792fe 100644 --- a/apps/mobile/src/widgets/subscriptionUsageSnapshot.test.ts +++ b/apps/mobile/src/widgets/subscriptionUsageSnapshot.test.ts @@ -155,4 +155,13 @@ describe("subscription widget snapshots", () => { expect(timeline[1]?.props.rows[0]?.usedPercent).toBe(40); expect(subscriptionUsageTimeline(snapshot, now + 60 * 60_000)).toHaveLength(1); }); + it("marks a malformed check time immediately stale without changing the quota", () => { + const snapshot = buildSubscriptionUsageSnapshot( + presentations([provider({ usageLimits: { ...limits, checkedAt: "invalid" } })]), + deepLink, + ); + expect(snapshot.rows[0]).toMatchObject({ checkedAt: 0, expiresAt: 0, usedPercent: 40 }); + expect(subscriptionUsageTimeline(snapshot, now)).toHaveLength(1); + expect(JSON.stringify(snapshot)).not.toContain("null"); + }); }); diff --git a/apps/mobile/src/widgets/subscriptionUsageSnapshot.ts b/apps/mobile/src/widgets/subscriptionUsageSnapshot.ts index 08e88ae1bf6b..efd94c18f68f 100644 --- a/apps/mobile/src/widgets/subscriptionUsageSnapshot.ts +++ b/apps/mobile/src/widgets/subscriptionUsageSnapshot.ts @@ -28,7 +28,8 @@ export function buildSubscriptionUsageSnapshot( ): SubscriptionUsageSnapshot { const rows: SubscriptionUsageRow[] = []; const add = (label: string, limits: ServerProviderUsageLimits, sourceFailed = false) => { - const checkedAt = Date.parse(limits.checkedAt); + const parsedCheckedAt = Date.parse(limits.checkedAt); + const checkedAt = Number.isFinite(parsedCheckedAt) ? parsedCheckedAt : 0; if (limits.unavailable || sourceFailed || limits.windows.length === 0) { rows.push({ label, @@ -52,7 +53,10 @@ export function buildSubscriptionUsageSnapshot( ? `Resets ${new Date(reset).toLocaleString(undefined, { month: "short", day: "numeric", hour: "numeric", minute: "2-digit" })}` : "Reset time unavailable", checkedAt, - expiresAt: Math.min(checkedAt + MAX_AGE, Number.isFinite(reset) ? reset : Infinity), + expiresAt: + checkedAt === 0 + ? 0 + : Math.min(checkedAt + MAX_AGE, Number.isFinite(reset) ? reset : Infinity), }); } }; From bfd493fcfcc3bfabaf6324a5fcea71faf92fbfc2 Mon Sep 17 00:00:00 2001 From: Alex Southwell Date: Fri, 11 Sep 2026 17:23:45 +1000 Subject: [PATCH 5/6] fix(mobile): keep email-bearing instance names off usage widgets Co-Authored-By: Claude Opus 5 (1M context) --- .../src/widgets/subscriptionUsageSnapshot.test.ts | 8 ++++++++ apps/mobile/src/widgets/subscriptionUsageSnapshot.ts | 11 +++++++++-- 2 files changed, 17 insertions(+), 2 deletions(-) diff --git a/apps/mobile/src/widgets/subscriptionUsageSnapshot.test.ts b/apps/mobile/src/widgets/subscriptionUsageSnapshot.test.ts index 652efa6792fe..43f117797b43 100644 --- a/apps/mobile/src/widgets/subscriptionUsageSnapshot.test.ts +++ b/apps/mobile/src/widgets/subscriptionUsageSnapshot.test.ts @@ -60,6 +60,14 @@ describe("subscription widget snapshots", () => { expect(snapshot.deepLink).toBe(deepLink); expect(JSON.stringify(snapshot)).not.toContain("private@example.com"); }); + it("never shows an email-bearing instance name on the home screen", () => { + const snapshot = buildSubscriptionUsageSnapshot( + presentations([provider({ displayName: "work@example.com" })]), + deepLink, + ); + expect(snapshot.rows[0]?.label).toBe("Codex"); + expect(JSON.stringify(snapshot)).not.toContain("example.com"); + }); it("clears data after removing environments and hides disabled providers", () => { expect(buildSubscriptionUsageSnapshot(new Map(), deepLink).rows).toEqual([]); expect( diff --git a/apps/mobile/src/widgets/subscriptionUsageSnapshot.ts b/apps/mobile/src/widgets/subscriptionUsageSnapshot.ts index efd94c18f68f..38425791ade8 100644 --- a/apps/mobile/src/widgets/subscriptionUsageSnapshot.ts +++ b/apps/mobile/src/widgets/subscriptionUsageSnapshot.ts @@ -61,8 +61,15 @@ export function buildSubscriptionUsageSnapshot( } }; const driverLabel = (driver: string) => ({ codex: "Codex", claudeAgent: "Claude" })[driver]; - const providerLimitsLabel = (provider: ServerProvider) => - provider.displayName?.trim() || driverLabel(provider.driver) || String(provider.driver); + // Home screens have no reveal control, so email-bearing names fall back to the driver. + const providerLimitsLabel = (provider: ServerProvider) => { + const displayName = provider.displayName?.trim(); + return ( + (displayName && !displayName.includes("@") ? displayName : undefined) || + driverLabel(provider.driver) || + String(provider.driver) + ); + }; for (const group of collectLimitsGroups(presentations)) { for (const provider of group.providers) { if (!provider.usageLimits) continue; From 0c07b930dc073407cd7c25b3e232dc9f6a6b7003 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Sat, 12 Sep 2026 18:36:52 -0700 Subject: [PATCH 6/6] fix(mobile): select freshest widget account limits --- .../widgets/subscriptionUsageSnapshot.test.ts | 63 ++++++ .../src/widgets/subscriptionUsageSnapshot.ts | 82 +++++--- packages/shared/src/usageLimits.test.ts | 188 ------------------ packages/shared/src/usageLimits.ts | 119 +---------- 4 files changed, 124 insertions(+), 328 deletions(-) diff --git a/apps/mobile/src/widgets/subscriptionUsageSnapshot.test.ts b/apps/mobile/src/widgets/subscriptionUsageSnapshot.test.ts index 43f117797b43..1bee523b93be 100644 --- a/apps/mobile/src/widgets/subscriptionUsageSnapshot.test.ts +++ b/apps/mobile/src/widgets/subscriptionUsageSnapshot.test.ts @@ -108,6 +108,69 @@ describe("subscription widget snapshots", () => { expect(snapshot.rows[0]?.label).toBe("Hub · Codex 1"); expect(JSON.stringify(snapshot)).not.toContain("example.com"); }); + it.each(["hub", "environment"] as const)( + "uses the freshest reading for the same account reported by another %s", + (origin) => { + const newerLimits = { + checkedAt: "2026-09-05T12:05:00.000Z", + windows: [{ ...window, usedPercent: 10 }], + }; + const input = new Map([ + [ + EnvironmentId.make("native"), + { + entry: { target: { label: "Laptop" } }, + serverConfig: { + providers: [ + provider({ usageLimits: { ...limits, windows: [{ ...window, usedPercent: 90 }] } }), + ], + usageLimitSources: [], + }, + }, + ], + [ + EnvironmentId.make("other"), + { + entry: { target: { label: "Desktop" } }, + serverConfig: { + providers: origin === "environment" ? [provider({ usageLimits: newerLimits })] : [], + usageLimitSources: + origin === "hub" + ? [ + { + id: UsageLimitSourceId.make("hub"), + kind: "cliproxy" as const, + label: "Hub", + checkedAt: newerLimits.checkedAt, + accounts: [ + { + id: "account", + driver: ProviderDriverKind.make("codex"), + email: " PRIVATE@example.com ", + usageLimits: newerLimits, + }, + ], + }, + ] + : [], + }, + }, + ], + ]); + for (const entries of [input, new Map([...input].toReversed())]) { + const snapshot = buildSubscriptionUsageSnapshot(entries, deepLink); + expect(snapshot.totalRows).toBe(1); + expect(snapshot.rows).toMatchObject([ + { usedPercent: 10, checkedAt: Date.parse(newerLimits.checkedAt) }, + ]); + expect(JSON.stringify(snapshot)).not.toContain("example.com"); + } + input.delete(EnvironmentId.make("other")); + expect(buildSubscriptionUsageSnapshot(input, deepLink).rows).toMatchObject([ + { usedPercent: 90 }, + ]); + }, + ); it("keeps unavailable quotas distinct from zero usage and omits provider error messages", () => { const snapshot = buildSubscriptionUsageSnapshot( presentations([ diff --git a/apps/mobile/src/widgets/subscriptionUsageSnapshot.ts b/apps/mobile/src/widgets/subscriptionUsageSnapshot.ts index 38425791ade8..7d421ddc1ae2 100644 --- a/apps/mobile/src/widgets/subscriptionUsageSnapshot.ts +++ b/apps/mobile/src/widgets/subscriptionUsageSnapshot.ts @@ -1,5 +1,10 @@ -import type { ServerProvider, ServerProviderUsageLimits } from "@t3tools/contracts"; -import { collectLimitSources, collectLimitsGroups } from "@t3tools/shared/usageLimits"; +import type { ServerProviderUsageLimits } from "@t3tools/contracts"; +import { + collectLimitAccounts, + limitsNotice, + providersWithLimits, + type LimitPresentations, +} from "@t3tools/shared/usageLimits"; export interface SubscriptionUsageRow { readonly label: string; @@ -17,20 +22,18 @@ export interface SubscriptionUsageSnapshot { readonly deepLink: string; } -type Presentations = Parameters[0] & - Parameters[0]; const MAX_AGE = 30 * 60_000; /** Only display data crosses into OS storage; credentials and emails stay in the app. */ export function buildSubscriptionUsageSnapshot( - presentations: Presentations, + presentations: LimitPresentations, deepLink: string, ): SubscriptionUsageSnapshot { const rows: SubscriptionUsageRow[] = []; - const add = (label: string, limits: ServerProviderUsageLimits, sourceFailed = false) => { + const add = (label: string, limits: ServerProviderUsageLimits) => { const parsedCheckedAt = Date.parse(limits.checkedAt); const checkedAt = Number.isFinite(parsedCheckedAt) ? parsedCheckedAt : 0; - if (limits.unavailable || sourceFailed || limits.windows.length === 0) { + if (limits.unavailable || limits.windows.length === 0) { rows.push({ label, window: @@ -60,36 +63,51 @@ export function buildSubscriptionUsageSnapshot( }); } }; - const driverLabel = (driver: string) => ({ codex: "Codex", claudeAgent: "Claude" })[driver]; - // Home screens have no reveal control, so email-bearing names fall back to the driver. - const providerLimitsLabel = (provider: ServerProvider) => { - const displayName = provider.displayName?.trim(); - return ( - (displayName && !displayName.includes("@") ? displayName : undefined) || - driverLabel(provider.driver) || - String(provider.driver) + const driverLabels: Readonly> = { codex: "Codex", claudeAgent: "Claude" }; + // Home screens have no reveal control, so email-bearing names use a generic label. + const safeLabel = (value: string | null | undefined, fallback: string) => + value?.trim() && !value.includes("@") ? value.trim() : fallback; + for (const [index, account] of collectLimitAccounts(presentations).entries()) { + const driver = driverLabels[account.driver] ?? String(account.driver); + const name = safeLabel(account.displayName, driver); + const origin = account.sourceLabel + ? safeLabel(account.sourceLabel, "Hub") + : presentations.size > 1 + ? account.environments.map((entry) => safeLabel(entry.label, "Environment")).join(", ") + : ""; + add( + origin ? `${origin} · ${name}${account.sourceLabel ? ` ${index + 1}` : ""}` : name, + account.limits, ); - }; - for (const group of collectLimitsGroups(presentations)) { - for (const provider of group.providers) { - if (!provider.usageLimits) continue; - const label = providerLimitsLabel(provider); - add( - group.environmentLabel ? `${group.environmentLabel} · ${label}` : label, - provider.usageLimits, - ); - } } - for (const source of collectLimitSources(presentations)) { - for (const [index, account] of source.accounts.entries()) { + // Usable quotas come exclusively from the shared account selection. Keep + // unavailable readings visible without copying private probe errors to OS storage. + for (const presentation of presentations.values()) { + const label = (name: string) => + presentations.size > 1 + ? `${safeLabel(presentation.entry.target.label, "Environment")} · ${name}` + : name; + for (const provider of providersWithLimits(presentation.serverConfig?.providers ?? [])) { + if (!provider.usageLimits || limitsNotice(provider.usageLimits) === null) continue; add( - `${source.label} · ${driverLabel(account.driver) ?? account.driver} ${index + 1}`, - account.usageLimits, - Boolean(source.error), + label( + safeLabel(provider.displayName, driverLabels[provider.driver] ?? String(provider.driver)), + ), + provider.usageLimits, ); } - if (source.error && source.accounts.length === 0) { - add(source.label, { checkedAt: source.checkedAt, windows: [] }); + for (const source of presentation.serverConfig?.usageLimitSources ?? []) { + const name = label(safeLabel(source.label, "Hub")); + for (const [index, account] of source.accounts.entries()) { + if (limitsNotice(account.usageLimits) === null) continue; + add( + `${name} · ${driverLabels[account.driver] ?? account.driver} ${index + 1}`, + account.usageLimits, + ); + } + if (source.error && source.accounts.length === 0) { + add(name, { checkedAt: source.checkedAt, windows: [] }); + } } } // Most constrained windows stay visible in the smallest families. Stable diff --git a/packages/shared/src/usageLimits.test.ts b/packages/shared/src/usageLimits.test.ts index b814e66da459..0ef53b1e7f55 100644 --- a/packages/shared/src/usageLimits.test.ts +++ b/packages/shared/src/usageLimits.test.ts @@ -3,7 +3,6 @@ import { ProviderDriverKind, ProviderInstanceId, type ServerProvider, - type UsageLimitSourceAccount, UsageLimitSourceId, } from "@t3tools/contracts"; import { describe, expect, it } from "vite-plus/test"; @@ -17,8 +16,6 @@ import { collectLimitAccounts, collectLimitNotices, collectLimitPools, - collectLimitSources, - collectLimitsGroups, elapsedShare, formatResetsIn, limitsNotice, @@ -129,191 +126,6 @@ describe("providersWithLimits", () => { }); }); -describe("collectLimitsGroups", () => { - it("labels environments only when more than one reports limits", () => { - const limits = { checkedAt: "2026-09-03T11:00:00.000Z", windows: [window] }; - const codex = provider({ usageLimits: limits }); - const one = new Map([ - ["env-a", { entry: { target: { label: "Laptop" } }, serverConfig: { providers: [codex] } }], - [ - "env-b", - { entry: { target: { label: "Desktop" } }, serverConfig: { providers: [provider({})] } }, - ], - ] as const); - expect(collectLimitsGroups(one as never).map((group) => group.environmentLabel)).toEqual([ - null, - ]); - - const two = new Map([ - ["env-a", { entry: { target: { label: "Laptop" } }, serverConfig: { providers: [codex] } }], - ["env-b", { entry: { target: { label: "Desktop" } }, serverConfig: { providers: [codex] } }], - ] as const); - expect(collectLimitsGroups(two as never).map((group) => group.environmentLabel)).toEqual([ - "Laptop", - "Desktop", - ]); - }); -}); - -describe("collectLimitSources", () => { - const source = { - id: UsageLimitSourceId.make("cliproxy-hub"), - kind: "cliproxy" as const, - label: "hub", - checkedAt: "2026-09-03T11:00:00.000Z", - accounts: [], - }; - const limits = { checkedAt: source.checkedAt, windows: [window] }; - const account: UsageLimitSourceAccount = { - id: "codex-personal", - driver: ProviderDriverKind.make("codex"), - email: "person@example.com", - plan: "ChatGPT Pro Subscription", - usageLimits: limits, - }; - const native = provider({ - displayName: "Personal", - auth: { status: "authenticated", email: account.email }, - usageLimits: { ...limits, resetCredits: { availableCount: 2 } }, - }); - - function presentations( - providers: readonly ServerProvider[], - accounts: readonly UsageLimitSourceAccount[] = [account], - ) { - return new Map([ - [ - EnvironmentId.make("env-a"), - { - entry: { target: { label: "Laptop" } }, - serverConfig: { providers, usageLimitSources: [{ ...source, accounts }] }, - }, - ], - ]); - } - - it.each(["codex", "claudeAgent"])( - "prefers native %s limits by email without changing provider rows or source snapshots", - (kind) => { - const driver = ProviderDriverKind.make(kind); - const first = { ...native, driver }; - const second = { ...first, instanceId: ProviderInstanceId.make("work") }; - const accounts = [{ ...account, driver, email: " Person@Example.COM " }]; - const input = presentations([first, second], accounts); - - expect(collectLimitSources(input)).toMatchObject([{ accounts: [], hiddenAccountCount: 1 }]); - expect(collectLimitsGroups(input)[0]?.providers).toEqual([first, second]); - expect(accounts).toHaveLength(1); - expect(first.usageLimits?.resetCredits?.availableCount).toBe(2); - }, - ); - - it("matches across environments even when the hub is visited before the native provider", () => { - const input = presentations([]); - input.set(EnvironmentId.make("env-b"), { - entry: { target: { label: "Desktop" } }, - serverConfig: { providers: [native], usageLimitSources: [] }, - }); - - expect(collectLimitSources(input)).toMatchObject([ - { accounts: [], hiddenAccountCount: 1, environmentId: "env-a" }, - ]); - }); - - it("keeps other providers, other emails, and unidentified accounts with the same plan", () => { - const accounts = [ - account, - { ...account, id: "other-provider", driver: ProviderDriverKind.make("claudeAgent") }, - { ...account, id: "other-email", email: "other@example.com" }, - { ...account, id: "unknown-email", email: undefined }, - ]; - - expect(collectLimitSources(presentations([native], accounts))).toMatchObject([ - { accounts: accounts.slice(1), hiddenAccountCount: 1 }, - ]); - expect( - collectLimitSources( - presentations([{ ...native, auth: { status: "authenticated" } }], accounts), - )[0]?.accounts, - ).toEqual(accounts); - }); - - it.each([ - { enabled: false }, - { installed: false }, - { availability: "unavailable" }, - { usageLimits: undefined }, - { usageLimits: { ...limits, windows: [] } }, - { usageLimits: { ...limits, unavailable: { reason: "probeFailed" } } }, - { usageLimits: { ...limits, unavailable: { reason: "unsupported" } } }, - ] satisfies Partial[])( - "retains hub limits when the native provider cannot show them: %j", - (overrides) => { - expect(collectLimitSources(presentations([{ ...native, ...overrides }]))).toMatchObject([ - { accounts: [account], hiddenAccountCount: 0 }, - ]); - }, - ); - - it("restores the hub account when the matching provider disappears", () => { - const input = presentations([native]); - expect(collectLimitSources(input)[0]?.accounts).toEqual([]); - input.delete(EnvironmentId.make("env-a")); - for (const [id, entry] of presentations([])) input.set(id, entry); - - expect(collectLimitSources(input)[0]?.accounts).toEqual([account]); - }); - - it("keeps source errors and genuinely empty sources distinguishable from hidden accounts", () => { - const input = new Map([ - [ - EnvironmentId.make("env-a"), - { - entry: { target: { label: "Laptop" } }, - serverConfig: { - providers: [native], - usageLimitSources: [{ ...source, error: "Hub unavailable" }], - }, - }, - ], - ]); - expect(collectLimitSources(input)).toMatchObject([ - { accounts: [], hiddenAccountCount: 0, error: "Hub unavailable" }, - ]); - }); - - it("keys sources per environment and names the environment only when several have some", () => { - const one = new Map([ - [ - "env-a", - { entry: { target: { label: "Laptop" } }, serverConfig: { usageLimitSources: [source] } }, - ], - [ - "env-b", - { entry: { target: { label: "Desktop" } }, serverConfig: { usageLimitSources: [] } }, - ], - ] as const); - expect(collectLimitSources(one as never).map((entry) => [entry.key, entry.label])).toEqual([ - ["env-a:cliproxy-hub", "hub"], - ]); - - const two = new Map([ - [ - "env-a", - { entry: { target: { label: "Laptop" } }, serverConfig: { usageLimitSources: [source] } }, - ], - [ - "env-b", - { entry: { target: { label: "Desktop" } }, serverConfig: { usageLimitSources: [source] } }, - ], - ] as const); - expect(collectLimitSources(two as never).map((entry) => entry.label)).toEqual([ - "Laptop · hub", - "Desktop · hub", - ]); - }); -}); - describe("pools", () => { const checkedAt = "2026-09-03T11:00:00.000Z"; const weekly = { diff --git a/packages/shared/src/usageLimits.ts b/packages/shared/src/usageLimits.ts index 5c32cc0343b7..17ba1feab5d6 100644 --- a/packages/shared/src/usageLimits.ts +++ b/packages/shared/src/usageLimits.ts @@ -15,7 +15,6 @@ import { type ServerProvider, type ServerProviderUsageLimits, type ServerProviderUsageWindow, - type UsageLimitSourceSnapshot, type UsageLimitSourceSnapshots, } from "@t3tools/contracts"; @@ -42,108 +41,16 @@ export function providersWithLimits( ); } -export interface LimitsGroup { - readonly environmentId: EnvironmentId; - /** Null while only one environment is connected; there is nothing to tell apart. */ - readonly environmentLabel: string | null; - readonly providers: readonly ServerProvider[]; -} - -/** - * One group per connected environment with a provider reporting limits. - * Provider snapshots come from the config stream every client already holds, - * so opening the view costs no extra request. - */ -export function collectLimitsGroups( - presentations: ReadonlyMap< - EnvironmentId, - { - readonly entry: { readonly target: { readonly label: string } }; - readonly serverConfig: { - readonly providers?: readonly ServerProvider[] | undefined; - } | null; - } - >, -): readonly LimitsGroup[] { - const groups: LimitsGroup[] = []; - for (const [environmentId, presentation] of presentations) { - const providers = providersWithLimits(presentation.serverConfig?.providers ?? []); - if (providers.length === 0) continue; - groups.push({ environmentId, environmentLabel: presentation.entry.target.label, providers }); - } - return groups.length > 1 ? groups : groups.map((group) => ({ ...group, environmentLabel: null })); -} - -/** - * Every usage-limit source across connected environments, keyed so two - * environments pointing at the same hub still get their own rows. The label - * carries the environment only when more than one environment has sources. - * A native provider with usable limits takes precedence over the same account - * in a source, even when it belongs to another connected environment. - */ -export function collectLimitSources( - presentations: ReadonlyMap< - EnvironmentId, - { - readonly entry: { readonly target: { readonly label: string } }; - readonly serverConfig: { - readonly providers?: readonly ServerProvider[] | undefined; - readonly usageLimitSources?: UsageLimitSourceSnapshots | undefined; - } | null; - } - >, -): ReadonlyArray< - UsageLimitSourceSnapshot & { - readonly key: string; - readonly environmentId: EnvironmentId; - readonly hiddenAccountCount: number; +export type LimitPresentations = ReadonlyMap< + EnvironmentId, + { + readonly entry: { readonly target: { readonly label: string } }; + readonly serverConfig: { + readonly providers?: readonly ServerProvider[] | undefined; + readonly usageLimitSources?: UsageLimitSourceSnapshots | undefined; + } | null; } -> { - const nativeAccounts = new Set(); - for (const presentation of presentations.values()) { - for (const provider of providersWithLimits(presentation.serverConfig?.providers ?? [])) { - const key = accountKey(provider.driver, provider.auth.email); - if ( - key !== null && - provider.usageLimits?.windows.length && - !provider.usageLimits.unavailable - ) { - nativeAccounts.add(key); - } - } - } - const perEnvironment: Array<{ - readonly environmentId: EnvironmentId; - readonly environmentLabel: string; - readonly sources: UsageLimitSourceSnapshots; - }> = []; - for (const [environmentId, presentation] of presentations) { - const sources = presentation.serverConfig?.usageLimitSources ?? []; - if (sources.length === 0) continue; - perEnvironment.push({ - environmentId, - environmentLabel: presentation.entry.target.label, - sources, - }); - } - const labelEnvironment = perEnvironment.length > 1; - return perEnvironment.flatMap(({ environmentId, environmentLabel, sources }) => - sources.map((source) => { - const accounts = source.accounts.filter((account) => { - const key = accountKey(account.driver, account.email); - return key === null || !nativeAccounts.has(key); - }); - return { - ...source, - accounts, - hiddenAccountCount: source.accounts.length - accounts.length, - environmentId, - key: `${environmentId}:${source.id}`, - label: labelEnvironment ? `${environmentLabel} · ${source.label}` : source.label, - }; - }), - ); -} +>; function accountKey(driver: ServerProvider["driver"], email: string | undefined): string | null { const normalizedEmail = email?.trim().toLowerCase(); @@ -184,9 +91,7 @@ export interface LimitAccount { * entry per distinct account. The freshest reads supply windows and credits; * native instances supply names and environment labels. */ -export function collectLimitAccounts( - presentations: Parameters[0], -): readonly LimitAccount[] { +export function collectLimitAccounts(presentations: LimitPresentations): readonly LimitAccount[] { const accounts = new Map(); const creditSources = new Map(); const hubRedeems = new Map(); @@ -315,9 +220,7 @@ export function collectLimitAccounts( * are left out; there is nothing for the user to act on. The environment * is named only when more than one is connected. */ -export function collectLimitNotices( - presentations: Parameters[0], -): readonly string[] { +export function collectLimitNotices(presentations: LimitPresentations): readonly string[] { const label = (environmentLabel: string, subject: string) => presentations.size > 1 ? `${environmentLabel} · ${subject}` : subject; const notices: string[] = [];