1import {
2 type OrchestrationV2LimitRecovery,
3 type OrchestrationV2LimitRecoveryUpdate,
4 type RunId,
5} from "@t3tools/contracts";
6import { GaugeIcon } from "lucide-react";
7import { useEffect, useState } from "react";
8import { Button } from "../ui/button";
9import type { ComposerBannerStackItem } from "./ComposerBannerStack";
10
11type RecoveryProps = {
12 runId: RunId;
13 resetAt: string | null;
14 stoppedAt: string;
15 snoozedUntil: string | null;
16 recovery: OrchestrationV2LimitRecovery | null;
17 onChange: (recovery: OrchestrationV2LimitRecoveryUpdate) => Promise<void>;
18};
19
20export function usageLimitRecoveryBannerItem(props: RecoveryProps): ComposerBannerStackItem {
21 const { runId, resetAt, stoppedAt } = props;
22 const canSchedule = resetAt !== null && Date.parse(resetAt) > Date.parse(stoppedAt);
23 return {
24 id: `usage-limit-recovery:${runId}`,
25 variant: "warning",
26 priority: "urgent",
27 icon: <GaugeIcon />,
28 title: "Usage limit reached",
29 description: resetAt
30 ? `Resets ${new Date(resetAt).toLocaleString()}`
31 : "Reset time unavailable; retry manually",
32 actions: canSchedule ? <RecoveryActions key={`${runId}:${resetAt}`} {...props} /> : null,
33 };
34}
35
36function RecoveryActions({ runId, resetAt, recovery, snoozedUntil, onChange }: RecoveryProps) {
37 const [pending, setPending] = useState(false);
38 const [error, setError] = useState<string | null>(null);
39 const [nowMs, setNowMs] = useState(() => Date.now());
40 useEffect(() => {
41 const delay = Date.parse(resetAt ?? "") - Math.max(nowMs, Date.now());
42 if (!Number.isFinite(delay) || delay <= 0) return;
43 const timer = window.setTimeout(() => setNowMs(Date.now()), Math.min(delay + 1, 2_147_483_647));
44 return () => window.clearTimeout(timer);
45 }, [resetAt, nowMs]);
46
47 const scheduled =
48 recovery?.runId === runId && recovery.resetAt === resetAt && recovery.autoResume;
49 const snoozed =
50 recovery?.snooze === true &&
51 recovery.runId === runId &&
52 recovery.resetAt === resetAt &&
53 resetAt !== null &&
54 snoozedUntil !== null &&
55 Date.parse(snoozedUntil) === Date.parse(resetAt);
56 async function toggle(action: "resume" | "snooze") {
57 if (resetAt === null) return;
58 if (action === "snooze" && !snoozed && Date.parse(resetAt) <= Date.now()) {
59 setError("The reset time has passed. Retry the thread manually.");
60 setNowMs(Date.now());
61 return;
62 }
63 setPending(true);
64 setError(null);
65 try {
66 await onChange({
67 runId,
68 resetAt,
69 ...(action === "resume" ? { autoResume: !scheduled } : { snooze: !snoozed }),
70 });
71 } catch (cause) {
72 setError(cause instanceof Error ? cause.message : "Could not change limit recovery.");
73 }
74 setPending(false);
75 }
76 return (
77 <div className="flex flex-wrap items-center gap-2">
78 <Button size="xs" variant="ghost" disabled={pending} onClick={() => void toggle("resume")}>
79 {pending ? "Saving..." : scheduled ? "Cancel auto-resume" : "Resume at reset"}
80 </Button>
81 {!snoozed ? (
82 <Button
83 size="xs"
84 variant="ghost"
85 disabled={pending || Date.parse(resetAt!) <= nowMs}
86 onClick={() => void toggle("snooze")}
87 >
88 {pending ? "Saving..." : "Snooze until reset"}
89 </Button>
90 ) : null}
91 {error ? (
92 <p role="alert" className="basis-full text-xs text-destructive">
93 {error}
94 </p>
95 ) : null}
96 </div>
97 );
98}