1import { squashAtomCommandFailure } from "@t3tools/client-runtime/state/runtime";
2import type { EnvironmentThreadShell } from "@t3tools/client-runtime/state/shell";
3import type { EnvironmentId } from "@t3tools/contracts";
4import * as DateTime from "effect/DateTime";
5import { useState } from "react";
6import { Pressable, View } from "react-native";
7import { AppText as Text } from "../../components/AppText";
8import { threadEnvironment } from "../../state/threads";
9import { useAtomCommand } from "../../state/use-atom-command";
10
11export 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}