diff --git a/apps/mobile/src/components/ComposerEditor.ios.test.tsx b/apps/mobile/src/components/ComposerEditor.ios.test.tsx index d5e99928ac780..d3b7fa11a43bf 100644 --- a/apps/mobile/src/components/ComposerEditor.ios.test.tsx +++ b/apps/mobile/src/components/ComposerEditor.ios.test.tsx @@ -20,6 +20,7 @@ const native = vi.hoisted(() => ({ text: "", eventCount: 0, resetGeneration: 0, + mounts: 0, props: null as NativeProps | null, deliver: null as ((event: NativeEvent) => void) | null, })); @@ -28,6 +29,7 @@ function NativeView(props: NativeProps) { const propsRef = useRef(props); useLayoutEffect(() => { Object.assign(native, { text: "", eventCount: 0, resetGeneration: 0 }); + native.mounts += 1; }, []); useLayoutEffect(() => { propsRef.current = props; @@ -173,13 +175,16 @@ it("keeps a pending event from the previous native editor out of the selected dr ); await act(async () => renderDraft(first)); const deliverPendingEvent = native.deliver!; + const pending = { eventCount: native.eventCount + 1, resetGeneration: native.resetGeneration }; + const mounts = native.mounts; await act(async () => renderDraft(second)); + // Remounting the native view would drop the keyboard mid-edit. + expect(native.mounts).toBe(mounts); await act(async () => deliverPendingEvent({ value: "Draft A corrected", selection: { start: 17, end: 17 }, - eventCount: 1, - resetGeneration: 0, + ...pending, }), ); expect(getComposerDraftSnapshot(second).text).toBe("Draft B"); diff --git a/apps/mobile/src/components/ComposerEditor.tsx b/apps/mobile/src/components/ComposerEditor.tsx index 9a146487625be..c925113901a33 100644 --- a/apps/mobile/src/components/ComposerEditor.tsx +++ b/apps/mobile/src/components/ComposerEditor.tsx @@ -177,7 +177,7 @@ export function ComposerEditor({ <> {/* Reset revision history with the recreated iOS view without aborting context imports. */} (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() })); +vi.mock("../../components/ProviderIcon", () => ({ ProviderIcon: () => null })); vi.mock("../../components/ui/AppText", () => ({ AppText: ({ children }: { children: ReactNode }) => {children}, })); @@ -27,18 +40,97 @@ import { UsageLimitRecoveryCard } from "./UsageLimitRecoveryCard"; const DAY_MS = 24 * 60 * 60 * 1000; -function failedThread(resetAt: string): EnvironmentThreadShell { +function failedThread( + resetAt: string | null, + lastErrorCode?: string, + lastErrorClass = "usage_limit", +): 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, lastErrorCode, usageLimitResetAt: resetAt }, } as unknown as EnvironmentThreadShell; } describe("UsageLimitRecoveryCard", () => { + // Older servers report ChatGPT sharing limits as provider errors and may store a reset. + it.each([ + [CHATGPT_USAGE_LIMIT_CODE, "usage_limit", null, ["Manage usage"]], + [CHATGPT_WORKSPACE_USAGE_LIMIT_CODE, "usage_limit", null, ["Copy request for admin"]], + [CHATGPT_USAGE_LIMIT_CODE, "provider_error", null, ["Manage usage"]], + [ + CHATGPT_USAGE_LIMIT_CODE, + "usage_limit", + new Date(DAY_MS).toISOString(), + ["Manage usage", "Resume at reset", "Snooze until reset"], + ], + ])("shows one ChatGPT notice for %s (%s, reset %s)", (code, errorClass, resetAt, actions) => { + const container = document.createElement("div"); + const root = createRoot(container); + using _root = { [Symbol.dispose]: () => act(() => root.unmount()) }; + act(() => + root.render( + , + ), + ); + expect(container.textContent).toContain("ChatGPT usage limit reached"); + expect(container.textContent).not.toContain("did not report a reset time"); + expect([...container.querySelectorAll("button")].map((button) => button.textContent)).toEqual( + actions, + ); + }); + + it("drops a failed copy's error once a retry copies the request", async () => { + vi.mocked(Clipboard.setStringAsync) + .mockRejectedValueOnce(new Error("denied")) + .mockResolvedValueOnce(true); + 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(); + await act(async () => copy()); + expect(container.textContent).toContain("Could not copy the request"); + await act(async () => copy()); + expect(container.textContent).toContain("Request copied"); + 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 791dc2ab5d847..27fece0272be6 100644 --- a/apps/mobile/src/features/threads/UsageLimitRecoveryCard.tsx +++ b/apps/mobile/src/features/threads/UsageLimitRecoveryCard.tsx @@ -3,9 +3,17 @@ 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_USAGE_URL, + CHATGPT_WORKSPACE_ADMIN_REQUEST, + CHATGPT_WORKSPACE_USAGE_LIMIT_CODE, +} from "@t3tools/shared/usageLimits"; import * as DateTime from "effect/DateTime"; +import * as Clipboard from "expo-clipboard"; import { useState } from "react"; -import { 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"; import { threadEnvironment } from "../../state/threads"; @@ -30,12 +38,22 @@ export function UsageLimitRecoveryCard({ const recovery = thread.limitRecovery; 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; + const chatGptLimit = workspaceLimit || code === CHATGPT_USAGE_LIMIT_CODE; if ( thread.runtime?.status !== "failed" || - thread.runtime.lastErrorClass !== "usage_limit" || + // Older servers report ChatGPT sharing limits as provider errors. + (thread.runtime.lastErrorClass !== "usage_limit" && !chatGptLimit) || !runId ) return null; + const resetText = resetAt + ? `Usage limit resets ${DateTime.toDateUtc(DateTime.makeUnsafe(resetAt)).toLocaleString()}.` + : null; const snoozed = recovery?.snooze === true && recovery.runId === runId && @@ -68,34 +86,95 @@ 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: () => { + setError(null); + setCopied(false); + setActionPending(true); + void Clipboard.setStringAsync(CHATGPT_WORKSPACE_ADMIN_REQUEST) + .then( + () => setCopied(true), + () => setError("Could not copy the request. Try again."), + ) + .finally(() => setActionPending(false)); + }, + } + : { + label: "Manage usage", + role: "link" as const, + run: () => { + setError(null); + setActionPending(true); + void Linking.openURL(CHATGPT_USAGE_URL) + .catch(() => setError("Could not open ChatGPT. Go to chatgpt.com → Settings → Usage.")) + .finally(() => setActionPending(false)); + }, + }; return ( - - - {resetAt - ? `Usage limit resets ${DateTime.toDateUtc(DateTime.makeUnsafe(resetAt)).toLocaleString()}.` - : "The provider did not report a reset time. Retry manually when your limit is available."} - - {canSchedule ? ( - -