1import { squashAtomCommandFailure } from "@t3tools/client-runtime/state/runtime";
2import { AsyncResult } from "effect/reactivity";
3import type { EnvironmentThreadShell } from "@t3tools/client-runtime/state/shell";
4import { limitRecoveryOwnsSnooze } from "@t3tools/client-runtime/state/thread-execution";
5import type { EnvironmentId } from "@t3tools/contracts";
6import {
7 CHATGPT_USAGE_LIMIT_CODE,
8 CHATGPT_USAGE_URL,
9 CHATGPT_WORKSPACE_ADMIN_REQUEST,
10 CHATGPT_WORKSPACE_USAGE_LIMIT_CODE,
11} from "@t3tools/shared/usageLimits";
12import * as Clipboard from "expo-clipboard";
13import { useState } from "react";
14import { Linking, Pressable, View } from "react-native";
15import { ProviderIcon } from "../../components/ProviderIcon";
16import { AppText as Text } from "../../components/ui/AppText";
17import { useDeadlinePassed } from "../../lib/clock";
18import { threadEnvironment } from "../../state/threads";
19import { useAtomCommand } from "../../state/use-atom-command";
20
21export function UsageLimitRecoveryCard({
22 thread,
23 environmentId,
24}: {
25 thread: EnvironmentThreadShell;
26 environmentId: EnvironmentId;
27}) {
28 const updateMetadata = useAtomCommand(threadEnvironment.updateMetadata);
29 const [pending, setPending] = useState(false);
30 const [error, setError] = useState<string | null>(null);
31 const resetAt = thread.runtime?.usageLimitResetAt ?? null;
32 const canSchedule =
33 thread.settledOverride !== "settled" &&
34 resetAt !== null &&
35 Date.parse(resetAt) > Date.parse(thread.latestRun?.completedAt ?? thread.updatedAt);
36 const resetPassed = useDeadlinePassed(resetAt === null ? null : Date.parse(resetAt));
37 const runId = thread.latestRun?.runId;
38 const recovery = thread.limitRecovery;
39 const scheduled =
40 recovery?.runId === runId && recovery?.resetAt === resetAt && recovery?.autoResume;
41 const displayAt =
42 canSchedule &&
43 scheduled &&
44 resetAt &&
45 thread.snoozedUntil &&
46 Date.parse(thread.snoozedUntil) > Date.parse(resetAt)
47 ? thread.snoozedUntil
48 : resetAt;
49 const [copied, setCopied] = useState(false);
50 const [actionPending, setActionPending] = useState(false);
51 const code = thread.runtime?.lastErrorCode;
52 // OpenAI's partner guidelines specify the ChatGPT notice and its action.
53 const workspaceLimit = code === CHATGPT_WORKSPACE_USAGE_LIMIT_CODE;
54 const chatGptLimit = workspaceLimit || code === CHATGPT_USAGE_LIMIT_CODE;
55 if (
56 thread.runtime?.status !== "failed" ||
57 // Older servers report ChatGPT sharing limits as provider errors.
58 (thread.runtime.lastErrorClass !== "usage_limit" && !chatGptLimit) ||
59 !runId
60 )
61 return null;
62 const resetText = displayAt
63 ? `${canSchedule && scheduled ? "Resumes" : "Resets"} ${new Date(displayAt).toLocaleString(
64 undefined,
65 { month: "short", day: "numeric", hour: "numeric", minute: "2-digit" },
66 )}.`
67 : null;
68 const snoozed =
69 limitRecoveryOwnsSnooze({
70 runId,
71 resetAt,
72 recovery,
73 snoozedUntil: thread.snoozedUntil,
74 }) && !resetPassed;
75 async function toggle(action: "resume" | "snooze") {
76 if (!resetAt || !runId || !canSchedule) return;
77 if (action === "snooze" && !snoozed && Date.parse(resetAt) <= Date.now()) {
78 setError("The reset time has passed. Retry the thread manually.");
79 return;
80 }
81 setPending(true);
82 setError(null);
83 const result = await updateMetadata({
84 environmentId,
85 input: {
86 threadId: thread.id,
87 limitRecovery: {
88 runId,
89 resetAt,
90 ...(action === "resume" ? { autoResume: !scheduled } : { snooze: !snoozed }),
91 },
92 },
93 });
94 setPending(false);
95 if (AsyncResult.isFailure(result)) {
96 const cause = squashAtomCommandFailure(result);
97 setError(cause instanceof Error ? cause.message : "Could not change limit recovery.");
98 }
99 }
100 const action = workspaceLimit
101 ? {
102 label: "Copy request for admin",
103 role: "button" as const,
104 run: () => {
105 setError(null);
106 setCopied(false);
107 setActionPending(true);
108 void Clipboard.setStringAsync(CHATGPT_WORKSPACE_ADMIN_REQUEST)
109 .then(
110 () => setCopied(true),
111 () => setError("Could not copy the request. Try again."),
112 )
113 .finally(() => setActionPending(false));
114 },
115 }
116 : {
117 label: "Manage usage",
118 role: "link" as const,
119 run: () => {
120 setError(null);
121 setActionPending(true);
122 void Linking.openURL(CHATGPT_USAGE_URL)
123 .catch(() => setError("Could not open ChatGPT. Go to chatgpt.com → Settings → Usage."))
124 .finally(() => setActionPending(false));
125 },
126 };
127 return (
128 <View
129 accessibilityRole={chatGptLimit ? "alert" : undefined}
130 className="mx-3 mb-2 flex-row gap-2 rounded-xl border border-warning-foreground/25 bg-screen p-3"
131 >
132 {chatGptLimit ? (
133 <View className="pt-0.5">
134 <ProviderIcon provider="codex" size={16} />
135 </View>
136 ) : null}
137 <View className="min-w-0 flex-1 gap-1">
138 {chatGptLimit ? (
139 <Text className="text-sm font-t3-medium text-foreground">
140 ChatGPT usage limit reached
141 </Text>
142 ) : null}
143 <Text
144 className={
145 chatGptLimit ? "text-sm text-foreground-muted" : "text-sm text-warning-foreground"
146 }
147 >
148 {workspaceLimit
149 ? copied
150 ? "Request copied. Send it to your workspace admin."
151 : "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."
152 : chatGptLimit
153 ? (resetText ?? "Review your usage settings in ChatGPT.")
154 : resetText
155 ? `Usage limit reached. ${resetText}`
156 : "Usage limit reached. The provider did not report a reset time. Retry manually when your limit is available."}
157 </Text>
158 {chatGptLimit || canSchedule ? (
159 // The margins cancel the 44pt touch height so the links sit 8pt under the text.
160 <View className="-mb-3 -mt-2 flex-row flex-wrap gap-x-4">
161 {chatGptLimit ? (
162 <RecoveryAction
163 label={action.label}
164 role={action.role}
165 disabled={actionPending}
166 onPress={action.run}
167 />
168 ) : null}
169 {canSchedule ? (
170 <>
171 <RecoveryAction
172 label={scheduled ? "Cancel auto-resume" : "Resume at reset"}
173 disabled={pending}
174 onPress={() => void toggle("resume")}
175 />
176 <RecoveryAction
177 label={snoozed ? "Wake now" : "Snooze until reset"}
178 disabled={pending || (!snoozed && resetPassed)}
179 onPress={() => void toggle("snooze")}
180 />
181 </>
182 ) : null}
183 </View>
184 ) : null}
185 {error ? (
186 <Text accessibilityRole="alert" className="text-sm text-destructive">
187 {error}
188 </Text>
189 ) : null}
190 </View>
191 </View>
192 );
193}
194
195function RecoveryAction(props: {
196 readonly label: string;
197 readonly role?: "button" | "link";
198 readonly disabled: boolean;
199 readonly onPress: () => void;
200}) {
201 return (
202 <Pressable
203 accessibilityRole={props.role ?? "button"}
204 accessibilityState={{ disabled: props.disabled }}
205 disabled={props.disabled}
206 hitSlop={{ left: 8, right: 8 }}
207 onPress={props.onPress}
208 className="min-h-11 justify-center active:opacity-70"
209 >
210 <Text
211 className={
212 props.disabled
213 ? "text-sm font-t3-medium text-foreground-muted"
214 : "text-sm font-t3-medium text-primary"
215 }
216 >
217 {props.label}
218 </Text>
219 </Pressable>
220 );
221}