From 62946ea6b0193b6d14fa694c6d04bde576617efb Mon Sep 17 00:00:00 2001 From: wSedlacek Date: Thu, 5 Nov 2026 13:27:22 -0800 Subject: [PATCH 1/4] feat(usage): check limits when a limit stop has no reset time - Show a Check limits action on the usage-limit notice when the provider reports no reset time - Refresh providers and open the limits panel on web and mobile, and surface why a check failed - Document the action in the usage guide --- .../features/threads/ThreadDetailScreen.tsx | 24 +++++++++++++ .../threads/UsageLimitRecoveryCard.test.tsx | 24 +++++++++++++ .../threads/UsageLimitRecoveryCard.tsx | 21 ++++++++++++ apps/web/src/components/ChatView.tsx | 2 ++ .../chat/UsageLimitRecoveryBanner.test.tsx | 25 +++++++++++--- .../chat/UsageLimitRecoveryBanner.tsx | 17 ++++++++-- .../components/chat/useUsageLimitsPanel.ts | 34 ++++++++++++++++++- docs/user/usage.md | 2 ++ 8 files changed, 142 insertions(+), 7 deletions(-) diff --git a/apps/mobile/src/features/threads/ThreadDetailScreen.tsx b/apps/mobile/src/features/threads/ThreadDetailScreen.tsx index 0d3258dd3bee7..fd52d8c4dd945 100644 --- a/apps/mobile/src/features/threads/ThreadDetailScreen.tsx +++ b/apps/mobile/src/features/threads/ThreadDetailScreen.tsx @@ -127,6 +127,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"; @@ -160,6 +161,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"; @@ -961,6 +963,27 @@ 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: the config stream may not have rendered yet. A stale key closes itself. + const report = collectProviderUsageLimits( + props.selectedThread.modelSelection.instanceId, + result.value.providers, + props.serverConfig?.usageLimitSources ?? [], + Date.now(), + ); + showUsageLimits(report); + 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) => @@ -1626,6 +1649,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 27fece0272be6..ab016da6b6bfd 100644 --- a/apps/mobile/src/features/threads/UsageLimitRecoveryCard.tsx +++ b/apps/mobile/src/features/threads/UsageLimitRecoveryCard.tsx @@ -22,9 +22,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); @@ -40,6 +43,7 @@ export function UsageLimitRecoveryCard({ recovery?.runId === runId && recovery?.resetAt === resetAt && recovery?.autoResume; 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; @@ -86,6 +90,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()); + setChecking(false); + } const action = workspaceLimit ? { label: "Copy request for admin", @@ -153,6 +164,16 @@ export function UsageLimitRecoveryCard({ "The provider did not report a reset time. Retry manually when your limit is available."} )} + {!chatGptLimit && resetAt === null && onCheckLimits ? ( + + + ) : ( + 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..f12b5c1f594f2 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,8 @@ import type { import { collectProviderUsageLimits, hasProviderUsageLimits } from "@t3tools/shared/usageLimits"; import { useState } from "react"; import { useStableCallback } from "../../hooks/useStableCallback"; +import { serverEnvironment } from "../../state/server"; +import { useAtomCommand } from "../../state/use-atom-command"; import { toastManager } from "../ui/toast"; import { usageLimitsBannerItem } from "./ComposerUsageLimits"; @@ -37,6 +41,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 +75,25 @@ export function useUsageLimitsPanel(input: { toastManager.add({ type: "info", title: "Usage limits are unavailable for this provider" }); return false; }); + const check = useStableCallback(async () => { + if (providerInstanceId === null || panelKey === null) return; + setCheckingKey(panelKey); + const result = await refreshUsageLimits(environmentId, () => + refreshProviders({ environmentId, input: {} }), + ); + setCheckingKey(null); + if (result?._tag !== "Success") { + toastManager.add({ type: "error", title: "Could not check usage limits" }); + return; + } + // Read the response: the config stream may not have rendered yet. A stale key closes itself. + const now = Date.now(); + if (collectProviderUsageLimits(providerInstanceId, result.value.providers, sources, now)) { + setPanel({ key: panelKey, threadKey, now }); + return; + } + toastManager.add({ type: "info", title: "Usage limits are unavailable for this provider" }); + }); // Async responses may clear only their originating thread's panel. const clearFor = useStableCallback((originThreadKey: string) => setPanel((current) => @@ -73,5 +101,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..e5f7d729935fb 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. 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 From 60692c0e59ee7e8535b25e71e38b4be7aaa99c2e Mon Sep 17 00:00:00 2001 From: maria-rcks Date: Fri, 6 Nov 2026 11:26:19 +0000 Subject: [PATCH 2/4] fix(usage): check limits reads fresh sources and ignores stale routes --- .../features/threads/ThreadDetailScreen.tsx | 7 +++- .../components/chat/useUsageLimitsPanel.ts | 37 ++++++++++++------- docs/user/usage.md | 2 +- 3 files changed, 30 insertions(+), 16 deletions(-) diff --git a/apps/mobile/src/features/threads/ThreadDetailScreen.tsx b/apps/mobile/src/features/threads/ThreadDetailScreen.tsx index 573e1b6623623..4f399ae967796 100644 --- a/apps/mobile/src/features/threads/ThreadDetailScreen.tsx +++ b/apps/mobile/src/features/threads/ThreadDetailScreen.tsx @@ -1006,11 +1006,14 @@ function ThreadDetailBody( refreshProviders({ environmentId: props.environmentId, input: {} }), ); if (result?._tag !== "Success") return "Could not check usage limits."; - // Read the response: the config stream may not have rendered yet. A stale key closes itself. + // Read the response and the registry: the config stream may not have rendered yet. const report = collectProviderUsageLimits( props.selectedThread.modelSelection.instanceId, result.value.providers, - props.serverConfig?.usageLimitSources ?? [], + appAtomRegistry.get(serverEnvironment.configValueAtom(props.environmentId)) + ?.usageLimitSources ?? + props.serverConfig?.usageLimitSources ?? + [], Date.now(), ); showUsageLimits(report); diff --git a/apps/web/src/components/chat/useUsageLimitsPanel.ts b/apps/web/src/components/chat/useUsageLimitsPanel.ts index f12b5c1f594f2..c70b34bf97929 100644 --- a/apps/web/src/components/chat/useUsageLimitsPanel.ts +++ b/apps/web/src/components/chat/useUsageLimitsPanel.ts @@ -9,6 +9,7 @@ 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"; @@ -75,24 +76,34 @@ 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; - setCheckingKey(panelKey); + const key = panelKey; + setCheckingKey(key); const result = await refreshUsageLimits(environmentId, () => refreshProviders({ environmentId, input: {} }), ); - setCheckingKey(null); - if (result?._tag !== "Success") { - toastManager.add({ type: "error", title: "Could not check usage limits" }); - return; - } - // Read the response: the config stream may not have rendered yet. A stale key closes itself. - const now = Date.now(); - if (collectProviderUsageLimits(providerInstanceId, result.value.providers, sources, now)) { - setPanel({ key: panelKey, threadKey, now }); - return; - } - toastManager.add({ type: "info", title: "Usage limits are unavailable for this provider" }); + 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) => diff --git a/docs/user/usage.md b/docs/user/usage.md index e5f7d729935fb..0739a7072f418 100644 --- a/docs/user/usage.md +++ b/docs/user/usage.md @@ -147,7 +147,7 @@ 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. It refreshes your providers and opens the same limits view, so you can see when a window resets. +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 From 192dd2137ec1998cac0ac09f69915c77eb3b0588 Mon Sep 17 00:00:00 2001 From: maria-rcks Date: Fri, 6 Nov 2026 11:38:07 +0000 Subject: [PATCH 3/4] fix(mobile): limit checks keep newer panels and always clear pending --- .../src/features/threads/ThreadDetailScreen.tsx | 13 ++++++++++++- .../src/features/threads/UsageLimitRecoveryCard.tsx | 9 +++++++-- 2 files changed, 19 insertions(+), 3 deletions(-) diff --git a/apps/mobile/src/features/threads/ThreadDetailScreen.tsx b/apps/mobile/src/features/threads/ThreadDetailScreen.tsx index 4f399ae967796..b6cc9774aed7c 100644 --- a/apps/mobile/src/features/threads/ThreadDetailScreen.tsx +++ b/apps/mobile/src/features/threads/ThreadDetailScreen.tsx @@ -1016,7 +1016,18 @@ function ThreadDetailBody( [], Date.now(), ); - showUsageLimits(report); + // 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); diff --git a/apps/mobile/src/features/threads/UsageLimitRecoveryCard.tsx b/apps/mobile/src/features/threads/UsageLimitRecoveryCard.tsx index 9fcf3b2b22784..8f83adabf6f37 100644 --- a/apps/mobile/src/features/threads/UsageLimitRecoveryCard.tsx +++ b/apps/mobile/src/features/threads/UsageLimitRecoveryCard.tsx @@ -106,8 +106,13 @@ export function UsageLimitRecoveryCard({ if (!onCheckLimits) return; setChecking(true); setError(null); - setError(await onCheckLimits()); - setChecking(false); + try { + setError(await onCheckLimits()); + } catch { + setError("Could not check usage limits."); + } finally { + setChecking(false); + } } const action = workspaceLimit ? { From 44f2ea950924f1e0315b00b3677e81a182118081 Mon Sep 17 00:00:00 2001 From: maria-rcks Date: Fri, 6 Nov 2026 11:45:08 +0000 Subject: [PATCH 4/4] fix(mobile): keep the limit card compiled by React Compiler --- .../src/features/threads/UsageLimitRecoveryCard.tsx | 9 ++------- 1 file changed, 2 insertions(+), 7 deletions(-) diff --git a/apps/mobile/src/features/threads/UsageLimitRecoveryCard.tsx b/apps/mobile/src/features/threads/UsageLimitRecoveryCard.tsx index 8f83adabf6f37..8e1d7e3fcd2ff 100644 --- a/apps/mobile/src/features/threads/UsageLimitRecoveryCard.tsx +++ b/apps/mobile/src/features/threads/UsageLimitRecoveryCard.tsx @@ -106,13 +106,8 @@ export function UsageLimitRecoveryCard({ if (!onCheckLimits) return; setChecking(true); setError(null); - try { - setError(await onCheckLimits()); - } catch { - setError("Could not check usage limits."); - } finally { - setChecking(false); - } + setError(await onCheckLimits().catch(() => "Could not check usage limits.")); + setChecking(false); } const action = workspaceLimit ? {