| 1 | import { |
| 2 | type OrchestrationV2LimitRecovery, |
| 3 | type OrchestrationV2LimitRecoveryUpdate, |
| 4 | type RunId, |
| 5 | } from "@t3tools/contracts"; |
| 6 | import { GaugeIcon } from "lucide-react"; |
| 7 | import { useEffect, useState } from "react"; |
| 8 | import { Button } from "../ui/button"; |
| 9 | import type { ComposerBannerStackItem } from "./ComposerBannerStack"; |
| 10 | |
| 11 | type 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 | |
| 20 | export 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 | |
| 36 | function 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 | } |