From 2a59ac254617e35a74c0b466792883a30bf391de Mon Sep 17 00:00:00 2001 From: shivam <91240327+shivamhwp@users.noreply.github.com> Date: Thu, 5 Nov 2026 03:17:15 +0000 Subject: [PATCH 1/7] fix(mobile): show one notice for ChatGPT sharing limits Since #25341 classifies ChatGPT Subscription Sharing limits as usage limits, mobile rendered both the generic usage-limit card and the ChatGPT notice for the same failure. The generic card now leaves those codes to the ChatGPT notice, matching web's single banner. Co-Authored-By: Claude Opus 5.5 --- .../threads/UsageLimitRecoveryCard.test.tsx | 31 +++++++++++++++++-- .../threads/UsageLimitRecoveryCard.tsx | 10 +++++- 2 files changed, 38 insertions(+), 3 deletions(-) diff --git a/apps/mobile/src/features/threads/UsageLimitRecoveryCard.test.tsx b/apps/mobile/src/features/threads/UsageLimitRecoveryCard.test.tsx index 59210f74273f7..335328d4f6ebb 100644 --- a/apps/mobile/src/features/threads/UsageLimitRecoveryCard.test.tsx +++ b/apps/mobile/src/features/threads/UsageLimitRecoveryCard.test.tsx @@ -1,5 +1,9 @@ import type { EnvironmentThreadShell } from "@t3tools/client-runtime/state/shell"; import type { EnvironmentId } from "@t3tools/contracts"; +import { + CHATGPT_USAGE_LIMIT_CODE, + CHATGPT_WORKSPACE_USAGE_LIMIT_CODE, +} from "@t3tools/shared/usageLimits"; import type { ReactNode } from "react"; import { act } from "react"; import { createRoot } from "react-dom/client"; @@ -27,18 +31,41 @@ import { UsageLimitRecoveryCard } from "./UsageLimitRecoveryCard"; const DAY_MS = 24 * 60 * 60 * 1000; -function failedThread(resetAt: string): EnvironmentThreadShell { +function failedThread(resetAt: string | null, lastErrorCode?: string): EnvironmentThreadShell { return { id: "thread-1", updatedAt: new Date(0).toISOString(), snoozedUntil: null, latestRun: { runId: "run-1", completedAt: new Date(0).toISOString() }, limitRecovery: null, - runtime: { status: "failed", lastErrorClass: "usage_limit", usageLimitResetAt: resetAt }, + runtime: { + status: "failed", + lastErrorClass: "usage_limit", + lastErrorCode, + usageLimitResetAt: resetAt, + }, } as unknown as EnvironmentThreadShell; } describe("UsageLimitRecoveryCard", () => { + it.each([CHATGPT_USAGE_LIMIT_CODE, CHATGPT_WORKSPACE_USAGE_LIMIT_CODE])( + "leaves the %s limit to the ChatGPT notice", + (code) => { + const container = document.createElement("div"); + const root = createRoot(container); + using _root = { [Symbol.dispose]: () => act(() => root.unmount()) }; + act(() => + root.render( + , + ), + ); + expect(container.textContent).toBe(""); + }, + ); + it("disables Snooze once a reset more than 24.8 days away passes", () => { using _timers = { [Symbol.dispose]: () => vi.useRealTimers() }; vi.useFakeTimers({ now: 0 }); diff --git a/apps/mobile/src/features/threads/UsageLimitRecoveryCard.tsx b/apps/mobile/src/features/threads/UsageLimitRecoveryCard.tsx index 791dc2ab5d847..1c61f94029fe7 100644 --- a/apps/mobile/src/features/threads/UsageLimitRecoveryCard.tsx +++ b/apps/mobile/src/features/threads/UsageLimitRecoveryCard.tsx @@ -3,6 +3,10 @@ import { squashAtomCommandFailure } from "@t3tools/client-runtime/state/runtime" import { AsyncResult } from "effect/reactivity"; import type { EnvironmentThreadShell } from "@t3tools/client-runtime/state/shell"; import type { EnvironmentId } from "@t3tools/contracts"; +import { + CHATGPT_USAGE_LIMIT_CODE, + CHATGPT_WORKSPACE_USAGE_LIMIT_CODE, +} from "@t3tools/shared/usageLimits"; import * as DateTime from "effect/DateTime"; import { useState } from "react"; import { View } from "react-native"; @@ -30,10 +34,14 @@ export function UsageLimitRecoveryCard({ const recovery = thread.limitRecovery; const scheduled = recovery?.runId === runId && recovery?.resetAt === resetAt && recovery?.autoResume; + const code = thread.runtime?.lastErrorCode; if ( thread.runtime?.status !== "failed" || thread.runtime.lastErrorClass !== "usage_limit" || - !runId + !runId || + // ChatGptUsageLimitNotice owns these; sharing limits never report a reset time. + code === CHATGPT_USAGE_LIMIT_CODE || + code === CHATGPT_WORKSPACE_USAGE_LIMIT_CODE ) return null; const snoozed = From 283d19fcdc9886d26d1c273c4d4aa4f3fae87526 Mon Sep 17 00:00:00 2001 From: shivam <91240327+shivamhwp@users.noreply.github.com> Date: Thu, 5 Nov 2026 03:25:38 +0000 Subject: [PATCH 2/7] fix(mobile): keep reset controls when a ChatGPT limit has a reset time Co-Authored-By: Claude Opus 5.5 --- .../threads/UsageLimitRecoveryCard.test.tsx | 15 +++++++++++++++ .../features/threads/UsageLimitRecoveryCard.tsx | 6 +++--- 2 files changed, 18 insertions(+), 3 deletions(-) diff --git a/apps/mobile/src/features/threads/UsageLimitRecoveryCard.test.tsx b/apps/mobile/src/features/threads/UsageLimitRecoveryCard.test.tsx index 335328d4f6ebb..04d4dd99246de 100644 --- a/apps/mobile/src/features/threads/UsageLimitRecoveryCard.test.tsx +++ b/apps/mobile/src/features/threads/UsageLimitRecoveryCard.test.tsx @@ -66,6 +66,21 @@ describe("UsageLimitRecoveryCard", () => { }, ); + it("keeps reset controls for a ChatGPT limit stored with a reset time", () => { + const container = document.createElement("div"); + const root = createRoot(container); + using _root = { [Symbol.dispose]: () => act(() => root.unmount()) }; + act(() => + root.render( + , + ), + ); + expect(container.textContent).toContain("Resume at reset"); + }); + it("disables Snooze once a reset more than 24.8 days away passes", () => { using _timers = { [Symbol.dispose]: () => vi.useRealTimers() }; vi.useFakeTimers({ now: 0 }); diff --git a/apps/mobile/src/features/threads/UsageLimitRecoveryCard.tsx b/apps/mobile/src/features/threads/UsageLimitRecoveryCard.tsx index 1c61f94029fe7..e19b2ea0e8a75 100644 --- a/apps/mobile/src/features/threads/UsageLimitRecoveryCard.tsx +++ b/apps/mobile/src/features/threads/UsageLimitRecoveryCard.tsx @@ -39,9 +39,9 @@ export function UsageLimitRecoveryCard({ thread.runtime?.status !== "failed" || thread.runtime.lastErrorClass !== "usage_limit" || !runId || - // ChatGptUsageLimitNotice owns these; sharing limits never report a reset time. - code === CHATGPT_USAGE_LIMIT_CODE || - code === CHATGPT_WORKSPACE_USAGE_LIMIT_CODE + // Without a reset there is nothing to schedule, and ChatGptUsageLimitNotice covers it. + (resetAt === null && + (code === CHATGPT_USAGE_LIMIT_CODE || code === CHATGPT_WORKSPACE_USAGE_LIMIT_CODE)) ) return null; const snoozed = From 315d227a83dc14b0ad89b09764f528a6e5d2c0ca Mon Sep 17 00:00:00 2001 From: shivam <91240327+shivamhwp@users.noreply.github.com> Date: Thu, 5 Nov 2026 04:28:40 +0000 Subject: [PATCH 3/7] fix(mobile): fold the ChatGPT limit notice into the one usage-limit card The separate ChatGPT card with its full-width blue button is gone. The usage-limit card now carries the ChatGPT identity, the guideline copy and a compact Manage usage / Copy request for admin action, matching web's compact banner. It also covers older servers that report sharing limits as provider errors. Co-Authored-By: Claude Opus 5.5 --- .../threads/ChatGptUsageLimitNotice.tsx | 69 ------------ .../features/threads/ThreadDetailScreen.tsx | 5 - .../threads/UsageLimitRecoveryCard.test.tsx | 56 +++++----- .../threads/UsageLimitRecoveryCard.tsx | 102 +++++++++++++----- 4 files changed, 110 insertions(+), 122 deletions(-) delete mode 100644 apps/mobile/src/features/threads/ChatGptUsageLimitNotice.tsx diff --git a/apps/mobile/src/features/threads/ChatGptUsageLimitNotice.tsx b/apps/mobile/src/features/threads/ChatGptUsageLimitNotice.tsx deleted file mode 100644 index faebfa5ddf927..0000000000000 --- a/apps/mobile/src/features/threads/ChatGptUsageLimitNotice.tsx +++ /dev/null @@ -1,69 +0,0 @@ -import type { EnvironmentThreadShell } from "@t3tools/client-runtime/state/shell"; -import { - CHATGPT_USAGE_LIMIT_CODE, - CHATGPT_USAGE_URL, - CHATGPT_WORKSPACE_ADMIN_REQUEST, - CHATGPT_WORKSPACE_USAGE_LIMIT_CODE, -} from "@t3tools/shared/usageLimits"; -import * as Clipboard from "expo-clipboard"; -import { useState } from "react"; -import { Linking, View } from "react-native"; -import { ProviderIcon } from "../../components/ProviderIcon"; -import { AppText as Text } from "../../components/ui/AppText"; -import { Button } from "../../components/ui/Button"; - -export function ChatGptUsageLimitNotice({ thread }: { thread: EnvironmentThreadShell }) { - const [status, setStatus] = useState<"copied" | "copy-failed" | "open-failed" | null>(null); - const code = thread.runtime?.lastErrorCode; - const workspaceLimit = code === CHATGPT_WORKSPACE_USAGE_LIMIT_CODE; - if (thread.runtime?.status !== "failed" || (!workspaceLimit && code !== CHATGPT_USAGE_LIMIT_CODE)) - return null; - return ( - - - - ChatGPT usage limit reached - - - {workspaceLimit - ? status === "copied" - ? "Request copied. Send it to your workspace admin." - : "You've reached your workspace's limit for using your ChatGPT plan in other apps. Copy a request to ask your workspace admin for increased usage limits." - : "Review your usage settings in ChatGPT."} - - {workspaceLimit ? ( - + ), View: ({ children }: { children: ReactNode }) =>
{children}
, })); vi.mock("expo-clipboard", () => ({ setStringAsync: vi.fn() })); @@ -50,7 +55,7 @@ function failedThread( } describe("UsageLimitRecoveryCard", () => { - // Servers before #25341 report sharing limits as provider errors. + // Older servers report ChatGPT sharing limits as provider errors. it.each([ [CHATGPT_USAGE_LIMIT_CODE, "usage_limit", "Manage usage"], [CHATGPT_WORKSPACE_USAGE_LIMIT_CODE, "usage_limit", "Copy request for admin"], diff --git a/apps/mobile/src/features/threads/UsageLimitRecoveryCard.tsx b/apps/mobile/src/features/threads/UsageLimitRecoveryCard.tsx index 9a4d42c934e20..33f1a0384dace 100644 --- a/apps/mobile/src/features/threads/UsageLimitRecoveryCard.tsx +++ b/apps/mobile/src/features/threads/UsageLimitRecoveryCard.tsx @@ -12,7 +12,7 @@ import { import * as DateTime from "effect/DateTime"; import * as Clipboard from "expo-clipboard"; import { useState } from "react"; -import { Linking, View } from "react-native"; +import { Linking, Pressable, View } from "react-native"; import { ProviderIcon } from "../../components/ProviderIcon"; import { AppText as Text } from "../../components/ui/AppText"; import { useDeadlinePassed } from "../../lib/clock"; @@ -40,11 +40,12 @@ export function UsageLimitRecoveryCard({ recovery?.runId === runId && recovery?.resetAt === resetAt && recovery?.autoResume; const [copied, setCopied] = 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; if ( thread.runtime?.status !== "failed" || - // Servers before #25341 report sharing limits as provider errors. + // Older servers report ChatGPT sharing limits as provider errors. (thread.runtime.lastErrorClass !== "usage_limit" && !chatGptLimit) || !runId ) @@ -84,80 +85,81 @@ export function UsageLimitRecoveryCard({ setError(cause instanceof Error ? cause.message : "Could not change limit recovery."); } } + const action = workspaceLimit + ? { + label: "Copy request for admin", + role: "button" as const, + run: () => + void Clipboard.setStringAsync(CHATGPT_WORKSPACE_ADMIN_REQUEST).then( + () => setCopied(true), + () => setError("Could not copy the request. Try again."), + ), + } + : { + label: "Manage usage", + role: "link" as const, + run: () => + void Linking.openURL(CHATGPT_USAGE_URL).catch(() => + setError("Could not open ChatGPT. Go to chatgpt.com → Settings → Usage."), + ), + }; return ( - + {chatGptLimit ? ( - // OpenAI's partner guidelines specify this ChatGPT notice and its action. - <> - - - + + + + ) : null} + + {chatGptLimit ? ( + + ChatGPT usage limit reached + + {workspaceLimit + ? copied + ? "Request copied. Send it to your workspace admin." + : "You've reached your workspace's limit for using your ChatGPT plan in other apps. Copy a request to ask your workspace admin for increased usage limits." + : (resetText ?? "Review your usage settings in ChatGPT.")} + + + {action.label} + + ) : ( - {workspaceLimit - ? copied - ? "Request copied. Send it to your workspace admin." - : "You've reached your workspace's limit for using your ChatGPT plan in other apps. Copy a request to ask your workspace admin for increased usage limits." - : (resetText ?? "Review your usage settings in ChatGPT.")} + {resetText ?? + "The provider did not report a reset time. Retry manually when your limit is available."} - - ) : ( - - {resetText ?? - "The provider did not report a reset time. Retry manually when your limit is available."} - - )} - {canSchedule || chatGptLimit ? ( - - {canSchedule ? ( - <> - ), @@ -109,6 +109,28 @@ describe("UsageLimitRecoveryCard", () => { expect(container.textContent).not.toContain("Could not copy the request"); }); + it("ignores a second copy while the first is still in flight", async () => { + const first = Promise.withResolvers(); + vi.mocked(Clipboard.setStringAsync).mockReset().mockReturnValueOnce(first.promise); + const container = document.createElement("div"); + const root = createRoot(container); + using _root = { [Symbol.dispose]: () => act(() => root.unmount()) }; + act(() => + root.render( + , + ), + ); + const copy = () => container.querySelector("button")!.click(); + act(() => copy()); + act(() => copy()); + expect(Clipboard.setStringAsync).toHaveBeenCalledTimes(1); + await act(async () => first.resolve(true)); + expect(container.textContent).toContain("Request copied"); + }); + it("disables Snooze once a reset more than 24.8 days away passes", () => { using _timers = { [Symbol.dispose]: () => vi.useRealTimers() }; vi.useFakeTimers({ now: 0 }); diff --git a/apps/mobile/src/features/threads/UsageLimitRecoveryCard.tsx b/apps/mobile/src/features/threads/UsageLimitRecoveryCard.tsx index 67c15bc25faa4..8e8a1d3d7c446 100644 --- a/apps/mobile/src/features/threads/UsageLimitRecoveryCard.tsx +++ b/apps/mobile/src/features/threads/UsageLimitRecoveryCard.tsx @@ -39,6 +39,7 @@ export function UsageLimitRecoveryCard({ const scheduled = recovery?.runId === runId && recovery?.resetAt === resetAt && recovery?.autoResume; const [copied, setCopied] = useState(false); + const [actionPending, setActionPending] = 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; @@ -92,10 +93,13 @@ export function UsageLimitRecoveryCard({ run: () => { setError(null); setCopied(false); - void Clipboard.setStringAsync(CHATGPT_WORKSPACE_ADMIN_REQUEST).then( - () => setCopied(true), - () => setError("Could not copy the request. Try again."), - ); + setActionPending(true); + void Clipboard.setStringAsync(CHATGPT_WORKSPACE_ADMIN_REQUEST) + .then( + () => setCopied(true), + () => setError("Could not copy the request. Try again."), + ) + .finally(() => setActionPending(false)); }, } : { @@ -103,9 +107,10 @@ export function UsageLimitRecoveryCard({ role: "link" as const, run: () => { setError(null); - void Linking.openURL(CHATGPT_USAGE_URL).catch(() => - setError("Could not open ChatGPT. Go to chatgpt.com → Settings → Usage."), - ); + setActionPending(true); + void Linking.openURL(CHATGPT_USAGE_URL) + .catch(() => setError("Could not open ChatGPT. Go to chatgpt.com → Settings → Usage.")) + .finally(() => setActionPending(false)); }, }; return ( @@ -130,7 +135,9 @@ export function UsageLimitRecoveryCard({ From 16b0985cadb2723ac6825023ae3c5a37e891321c Mon Sep 17 00:00:00 2001 From: maria-rcks Date: Thu, 5 Nov 2026 05:08:12 +0000 Subject: [PATCH 7/7] fix(mobile): announce the ChatGPT limit notice to screen readers --- apps/mobile/src/features/threads/UsageLimitRecoveryCard.tsx | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/apps/mobile/src/features/threads/UsageLimitRecoveryCard.tsx b/apps/mobile/src/features/threads/UsageLimitRecoveryCard.tsx index 8e8a1d3d7c446..27fece0272be6 100644 --- a/apps/mobile/src/features/threads/UsageLimitRecoveryCard.tsx +++ b/apps/mobile/src/features/threads/UsageLimitRecoveryCard.tsx @@ -114,7 +114,10 @@ export function UsageLimitRecoveryCard({ }, }; return ( - + {chatGptLimit ? (