diff --git a/apps/mobile/src/features/threads/ThreadDetailScreen.tsx b/apps/mobile/src/features/threads/ThreadDetailScreen.tsx index 27cce9c5795ba..b6cc9774aed7c 100644 --- a/apps/mobile/src/features/threads/ThreadDetailScreen.tsx +++ b/apps/mobile/src/features/threads/ThreadDetailScreen.tsx @@ -128,6 +128,7 @@ import { } from "../../native/native-layout-metrics"; import { useAppearancePreferences } from "../settings/appearance/AppearancePreferencesProvider"; import { collectProviderUsageLimits } from "@t3tools/shared/usageLimits"; +import { refreshUsageLimits } from "@t3tools/client-runtime/state/usage"; import type { ComposerEditorHandle } from "../../components/ComposerEditor"; import type { StatusTone } from "../../components/StatusPill"; import { getComposerDraftSnapshot, setFreshSessionPending } from "../../state/use-composer-drafts"; @@ -161,6 +162,7 @@ import { } from "../../state/threads"; import { appAtomRegistry } from "../../state/atom-registry"; import { threadStreamHaptics } from "./threadStreamHaptics"; +import { serverEnvironment } from "../../state/server"; import { useAtomCommand } from "../../state/use-atom-command"; import { orchestrationEnvironment } from "../../state/orchestration"; import { useDelayedStatus } from "../../lib/useDelayedStatus"; @@ -993,6 +995,41 @@ function ThreadDetailBody( now: Date.parse(report.createdAt), }, ); + const canCheckUsageLimits = useAtomValue( + serverEnvironment.refreshProviders.permissionAtom(props.environmentId), + ); + const refreshProviders = useAtomCommand(serverEnvironment.refreshProviders, { + reportFailure: false, + }); + const checkUsageLimits = async () => { + const result = await refreshUsageLimits(props.environmentId, () => + refreshProviders({ environmentId: props.environmentId, input: {} }), + ); + if (result?._tag !== "Success") return "Could not check usage limits."; + // Read the response and the registry: the config stream may not have rendered yet. + const report = collectProviderUsageLimits( + props.selectedThread.modelSelection.instanceId, + result.value.providers, + appAtomRegistry.get(serverEnvironment.configValueAtom(props.environmentId)) + ?.usageLimitSources ?? + props.serverConfig?.usageLimitSources ?? + [], + Date.now(), + ); + // Never replace a panel opened for a newer context while this check was in flight. + setUsageLimitsPanel((current) => + current !== null && current.key !== usageLimitsKey + ? current + : report === null + ? null + : { + key: usageLimitsKey, + threadKey: selectedThreadKey, + now: Date.parse(report.createdAt), + }, + ); + return report === null ? "This provider does not currently report limits." : null; + }; const dismissUsageLimits = () => setUsageLimitsPanel(null); // Late sends clear only their originating thread's limits panel. const clearUsageLimitsFor = (threadKey: string) => @@ -1679,6 +1716,7 @@ function ThreadDetailBody( key={props.selectedThread.latestRun?.runId} thread={props.selectedThread} environmentId={props.environmentId} + onCheckLimits={canCheckUsageLimits ? checkUsageLimits : null} /> { , ), ); @@ -86,6 +87,26 @@ describe("UsageLimitRecoveryCard", () => { ); }); + it("checks limits when the provider reported no reset, and shows why a check failed", async () => { + const onCheckLimits = vi.fn(async () => "Could not check usage limits."); + const container = document.createElement("div"); + const root = createRoot(container); + using _root = { [Symbol.dispose]: () => act(() => root.unmount()) }; + act(() => + root.render( + , + ), + ); + await act(async () => container.querySelector("button")!.click()); + expect(onCheckLimits).toHaveBeenCalledTimes(1); + expect(container.textContent).toContain("Could not check usage limits."); + expect(container.querySelector("button")!.textContent).toBe("Check limits"); + }); + it("drops a failed copy's error once a retry copies the request", async () => { vi.mocked(Clipboard.setStringAsync) .mockRejectedValueOnce(new Error("denied")) @@ -98,6 +119,7 @@ describe("UsageLimitRecoveryCard", () => { , ), ); @@ -120,6 +142,7 @@ describe("UsageLimitRecoveryCard", () => { , ), ); @@ -142,6 +165,7 @@ describe("UsageLimitRecoveryCard", () => { , ), ); diff --git a/apps/mobile/src/features/threads/UsageLimitRecoveryCard.tsx b/apps/mobile/src/features/threads/UsageLimitRecoveryCard.tsx index 201fc78234f5a..8e1d7e3fcd2ff 100644 --- a/apps/mobile/src/features/threads/UsageLimitRecoveryCard.tsx +++ b/apps/mobile/src/features/threads/UsageLimitRecoveryCard.tsx @@ -21,9 +21,12 @@ import { useAtomCommand } from "../../state/use-atom-command"; export function UsageLimitRecoveryCard({ thread, environmentId, + onCheckLimits, }: { thread: EnvironmentThreadShell; environmentId: EnvironmentId; + /** Resolves to an error message, or null once the limits panel is open. */ + onCheckLimits: (() => Promise) | null; }) { const updateMetadata = useAtomCommand(threadEnvironment.updateMetadata); const [pending, setPending] = useState(false); @@ -48,10 +51,12 @@ export function UsageLimitRecoveryCard({ : resetAt; const [copied, setCopied] = useState(false); const [actionPending, setActionPending] = useState(false); + const [checking, setChecking] = useState(false); const code = thread.runtime?.lastErrorCode; // OpenAI's partner guidelines specify the ChatGPT notice and its action. const workspaceLimit = code === CHATGPT_WORKSPACE_USAGE_LIMIT_CODE; const chatGptLimit = workspaceLimit || code === CHATGPT_USAGE_LIMIT_CODE; + const canCheck = !chatGptLimit && resetAt === null && onCheckLimits !== null; if ( thread.runtime?.status !== "failed" || // Older servers report ChatGPT sharing limits as provider errors. @@ -97,6 +102,13 @@ export function UsageLimitRecoveryCard({ setError(cause instanceof Error ? cause.message : "Could not change limit recovery."); } } + async function checkLimits() { + if (!onCheckLimits) return; + setChecking(true); + setError(null); + setError(await onCheckLimits().catch(() => "Could not check usage limits.")); + setChecking(false); + } const action = workspaceLimit ? { label: "Copy request for admin", @@ -155,7 +167,7 @@ export function UsageLimitRecoveryCard({ ? `Usage limit reached. ${resetText}` : "Usage limit reached. The provider did not report a reset time. Retry manually when your limit is available."} - {chatGptLimit || canSchedule ? ( + {chatGptLimit || canSchedule || canCheck ? ( // The margins cancel the 44pt touch height so the links sit 8pt under the text. {chatGptLimit ? ( @@ -166,6 +178,13 @@ export function UsageLimitRecoveryCard({ onPress={action.run} /> ) : null} + {canCheck ? ( + void checkLimits()} + /> + ) : null} {canSchedule ? ( <> { const result = await updateThreadMetadata({ environmentId, diff --git a/apps/web/src/components/chat/UsageLimitRecoveryBanner.test.tsx b/apps/web/src/components/chat/UsageLimitRecoveryBanner.test.tsx index 84b71be9b62d9..e38aab251feed 100644 --- a/apps/web/src/components/chat/UsageLimitRecoveryBanner.test.tsx +++ b/apps/web/src/components/chat/UsageLimitRecoveryBanner.test.tsx @@ -1,24 +1,34 @@ import { RunId } from "@t3tools/contracts"; import type { TimestampFormat } from "@t3tools/contracts/settings"; +import { renderToStaticMarkup } from "react-dom/server"; import { describe, expect, it, vi } from "vite-plus/test"; import { usageLimitRecoveryBannerItem } from "./UsageLimitRecoveryBanner"; const stoppedAt = new Date(2026, 9, 20, 12, 0); -function bannerDescription(resetAt: Date, timestampFormat: TimestampFormat) { +function bannerItem( + resetAt: Date | null, + timestampFormat: TimestampFormat, + errorCode: string | null = null, +) { return usageLimitRecoveryBannerItem({ runId: RunId.make("run-1"), - errorCode: null, - resetAt: resetAt.toISOString(), + errorCode, + resetAt: resetAt?.toISOString() ?? null, stoppedAt: stoppedAt.toISOString(), settled: false, requestPending: false, snoozedUntil: null, recovery: null, timestampFormat, + checkLimits: { pending: false, run: () => {} }, onChange: async () => {}, - }).description; + }); +} + +function bannerDescription(resetAt: Date, timestampFormat: TimestampFormat) { + return bannerItem(resetAt, timestampFormat).description; } describe("usageLimitRecoveryBannerItem", () => { @@ -36,4 +46,11 @@ describe("usageLimitRecoveryBannerItem", () => { "Resets tomorrow at 17:30", ); }); + + it("offers a limits check only when the reset time is unknown", () => { + const actions = (resetAt: Date | null) => + renderToStaticMarkup(<>{bannerItem(resetAt, "24-hour").actions}); + expect(actions(null)).toContain("Check limits"); + expect(actions(new Date(2026, 9, 20, 17, 30))).not.toContain("Check limits"); + }); }); diff --git a/apps/web/src/components/chat/UsageLimitRecoveryBanner.tsx b/apps/web/src/components/chat/UsageLimitRecoveryBanner.tsx index 744db5ea3a1e9..08630e3d8d8ec 100644 --- a/apps/web/src/components/chat/UsageLimitRecoveryBanner.tsx +++ b/apps/web/src/components/chat/UsageLimitRecoveryBanner.tsx @@ -31,6 +31,7 @@ type RecoveryProps = { snoozedUntil: string | null; recovery: OrchestrationV2LimitRecovery | null; timestampFormat: TimestampFormat; + checkLimits: { readonly pending: boolean; readonly run: () => void } | null; onChange: (recovery: OrchestrationV2LimitRecoveryUpdate) => Promise; }; @@ -45,6 +46,7 @@ export function usageLimitRecoveryBannerItem(props: RecoveryProps): ComposerBann recovery, snoozedUntil, timestampFormat, + checkLimits, } = props; // The server refuses recovery changes while a request is pending. const canSchedule = @@ -71,7 +73,19 @@ export function usageLimitRecoveryBannerItem(props: RecoveryProps): ComposerBann icon: , title: "Usage limit reached", description: resetDescription, - actions: recoveryActions, + actions: + resetAt === null && checkLimits !== null ? ( + + ) : ( + recoveryActions + ), }; } // OpenAI's partner guidelines specify this ChatGPT notice and its actions. diff --git a/apps/web/src/components/chat/useUsageLimitsPanel.ts b/apps/web/src/components/chat/useUsageLimitsPanel.ts index c0862a65bca28..c70b34bf97929 100644 --- a/apps/web/src/components/chat/useUsageLimitsPanel.ts +++ b/apps/web/src/components/chat/useUsageLimitsPanel.ts @@ -1,3 +1,5 @@ +import { useAtomValue } from "@effect/atom-react"; +import { refreshUsageLimits } from "@t3tools/client-runtime/state/usage"; import type { EnvironmentId, ProviderInstanceId, @@ -7,6 +9,9 @@ import type { import { collectProviderUsageLimits, hasProviderUsageLimits } from "@t3tools/shared/usageLimits"; import { useState } from "react"; import { useStableCallback } from "../../hooks/useStableCallback"; +import { appAtomRegistry } from "../../rpc/atomRegistry"; +import { serverEnvironment } from "../../state/server"; +import { useAtomCommand } from "../../state/use-atom-command"; import { toastManager } from "../ui/toast"; import { usageLimitsBannerItem } from "./ComposerUsageLimits"; @@ -37,6 +42,11 @@ export function useUsageLimitsPanel(input: { if (panel !== null && panelKey !== null && panel.key !== panelKey) { setPanel(null); } + const canCheck = useAtomValue(serverEnvironment.refreshProviders.permissionAtom(environmentId)); + const refreshProviders = useAtomCommand(serverEnvironment.refreshProviders, { + reportFailure: false, + }); + const [checkingKey, setCheckingKey] = useState(null); const report = panel !== null && panelKey !== null && panel.key === panelKey && providerInstanceId !== null ? collectProviderUsageLimits(providerInstanceId, providers, sources, panel.now) @@ -66,6 +76,35 @@ export function useUsageLimitsPanel(input: { toastManager.add({ type: "info", title: "Usage limits are unavailable for this provider" }); return false; }); + // Runs after the refresh with this render's route and sources; the config stream lands first. + const finishCheck = useStableCallback( + (key: string, refreshed: readonly ServerProvider[] | null) => { + if (key !== panelKey || providerInstanceId === null) return; + if (refreshed === null) { + toastManager.add({ type: "error", title: "Could not check usage limits" }); + return; + } + const latestSources = + appAtomRegistry.get(serverEnvironment.configValueAtom(environmentId))?.usageLimitSources ?? + sources; + const now = Date.now(); + if (collectProviderUsageLimits(providerInstanceId, refreshed, latestSources, now)) { + setPanel({ key, threadKey, now }); + return; + } + toastManager.add({ type: "info", title: "Usage limits are unavailable for this provider" }); + }, + ); + const check = useStableCallback(async () => { + if (providerInstanceId === null || panelKey === null) return; + const key = panelKey; + setCheckingKey(key); + const result = await refreshUsageLimits(environmentId, () => + refreshProviders({ environmentId, input: {} }), + ); + setCheckingKey((current) => (current === key ? null : current)); + finishCheck(key, result?._tag === "Success" ? result.value.providers : null); + }); // Async responses may clear only their originating thread's panel. const clearFor = useStableCallback((originThreadKey: string) => setPanel((current) => @@ -73,5 +112,9 @@ export function useUsageLimitsPanel(input: { ), ); - return { banner, offered, open, clearFor }; + const checkLimits = canCheck + ? { pending: checkingKey !== null && checkingKey === panelKey, run: check } + : null; + + return { banner, offered, open, clearFor, checkLimits }; } diff --git a/docs/user/usage.md b/docs/user/usage.md index d25bdba81f00a..0739a7072f418 100644 --- a/docs/user/usage.md +++ b/docs/user/usage.md @@ -147,6 +147,8 @@ current model's limits without leaving the conversation. The result opens above closes when you dismiss it or send your next message. It uses the same snapshot as **Usage → Limits**, so it does not run the agent or refresh anything. The command is offered only for providers that appear under **Usage → Limits**. +When a thread stops at a usage limit and the provider gave no reset time, choose **Check limits** on the notice above the composer. ChatGPT limit notices keep their own actions instead. It refreshes your providers and opens the same limits view, so you can see when a window resets. + To keep them in view on web and desktop, turn on **Usage limits** in **Settings → Appearance → Thread details**, or right-click the thread details card. The bottom of the thread details card then shows how much is left in each limit window of the current provider account. Some windows, such as a weekly Opus limit, count only one model. Hover a