| 1 | import { squashAtomCommandFailure } from "@t3tools/client-runtime/state/runtime"; |
| 2 | import type { EnvironmentThreadShell } from "@t3tools/client-runtime/state/shell"; |
| 3 | import type { EnvironmentId } from "@t3tools/contracts"; |
| 4 | import * as DateTime from "effect/DateTime"; |
| 5 | import { useState } from "react"; |
| 6 | import { Pressable, View } from "react-native"; |
| 7 | import { AppText as Text } from "../../components/AppText"; |
| 8 | import { threadEnvironment } from "../../state/threads"; |
| 9 | import { useAtomCommand } from "../../state/use-atom-command"; |
| 10 | |
| 11 | export function UsageLimitRecoveryCard({ |
| 12 | thread, |
| 13 | environmentId, |
| 14 | }: { |
| 15 | thread: EnvironmentThreadShell; |
| 16 | environmentId: EnvironmentId; |
| 17 | }) { |
| 18 | const updateMetadata = useAtomCommand(threadEnvironment.updateMetadata); |
| 19 | const [pending, setPending] = useState(false); |
| 20 | const [error, setError] = useState<string | null>(null); |
| 21 | const resetAt = thread.runtime?.usageLimitResetAt ?? null; |
| 22 | const canSchedule = |
| 23 | resetAt !== null && |
| 24 | Date.parse(resetAt) > Date.parse(thread.latestRun?.completedAt ?? thread.updatedAt); |
| 25 | const runId = thread.latestRun?.runId; |
| 26 | const recovery = thread.limitRecovery; |
| 27 | const scheduled = |
| 28 | recovery?.runId === runId && recovery?.resetAt === resetAt && recovery?.autoResume; |
| 29 | if ( |
| 30 | thread.runtime?.status !== "failed" || |
| 31 | thread.runtime.lastErrorClass !== "usage_limit" || |
| 32 | !runId |
| 33 | ) |
| 34 | return null; |
| 35 | const snoozed = |
| 36 | recovery?.snooze === true && |
| 37 | recovery.runId === runId && |
| 38 | recovery.resetAt === resetAt && |
| 39 | resetAt !== null && |
| 40 | thread.snoozedUntil !== null && |
| 41 | Date.parse(thread.snoozedUntil) === Date.parse(resetAt); |
| 42 | async function toggle(action: "resume" | "snooze") { |
| 43 | if (!resetAt || !runId || !canSchedule) return; |
| 44 | if (action === "snooze" && !snoozed && Date.parse(resetAt) <= Date.now()) { |
| 45 | setError("The reset time has passed. Retry the thread manually."); |
| 46 | return; |
| 47 | } |
| 48 | setPending(true); |
| 49 | setError(null); |
| 50 | try { |
| 51 | const result = await updateMetadata({ |
| 52 | environmentId, |
| 53 | input: { |
| 54 | threadId: thread.id, |
| 55 | limitRecovery: { |
| 56 | runId, |
| 57 | resetAt, |
| 58 | ...(action === "resume" ? { autoResume: !scheduled } : { snooze: !snoozed }), |
| 59 | }, |
| 60 | }, |
| 61 | }); |
| 62 | if (result._tag === "Failure") throw squashAtomCommandFailure(result); |
| 63 | } catch (cause) { |
| 64 | setError(cause instanceof Error ? cause.message : "Could not change limit recovery."); |
| 65 | } finally { |
| 66 | setPending(false); |
| 67 | } |
| 68 | } |
| 69 | return ( |
| 70 | <View className="mx-3 mb-2 gap-2 rounded-xl border border-warning-foreground/25 bg-screen p-3"> |
| 71 | <Text className="text-sm text-warning-foreground"> |
| 72 | {resetAt |
| 73 | ? `Usage limit resets ${DateTime.toDateUtc(DateTime.makeUnsafe(resetAt)).toLocaleString()}.` |
| 74 | : "The provider did not report a reset time. Retry manually when your limit is available."} |
| 75 | </Text> |
| 76 | {canSchedule ? ( |
| 77 | <View className="flex-row flex-wrap gap-2"> |
| 78 | <Pressable |
| 79 | accessibilityRole="button" |
| 80 | disabled={pending} |
| 81 | onPress={() => void toggle("resume")} |
| 82 | className="self-start rounded-lg bg-subtle px-3 py-2 active:opacity-70" |
| 83 | > |
| 84 | <Text className="text-sm text-foreground"> |
| 85 | {scheduled ? "Cancel auto-resume" : "Resume at reset"} |
| 86 | </Text> |
| 87 | </Pressable> |
| 88 | <Pressable |
| 89 | accessibilityRole="button" |
| 90 | disabled={pending || (!snoozed && Date.parse(resetAt!) <= Date.now())} |
| 91 | onPress={() => void toggle("snooze")} |
| 92 | className="self-start rounded-lg bg-subtle px-3 py-2 active:opacity-70" |
| 93 | > |
| 94 | <Text className="text-sm text-foreground"> |
| 95 | {snoozed ? "Wake now" : "Snooze until reset"} |
| 96 | </Text> |
| 97 | </Pressable> |
| 98 | </View> |
| 99 | ) : null} |
| 100 | {error ? ( |
| 101 | <Text accessibilityRole="alert" className="text-sm text-destructive"> |
| 102 | {error} |
| 103 | </Text> |
| 104 | ) : null} |
| 105 | </View> |
| 106 | ); |
| 107 | } |