diff --git a/apps/web/src/components/usage/UsageLimitsPooled.browser.test.tsx b/apps/web/src/components/usage/UsageLimitsPooled.browser.test.tsx index 2454e5cc8dff..296588761ea8 100644 --- a/apps/web/src/components/usage/UsageLimitsPooled.browser.test.tsx +++ b/apps/web/src/components/usage/UsageLimitsPooled.browser.test.tsx @@ -2,6 +2,7 @@ import { EnvironmentId, ProviderDriverKind, ProviderInstanceId, + UsageLimitSourceId, type ServerProvider, } from "@t3tools/contracts"; import type { LimitPresentations } from "@t3tools/shared/usageLimits"; @@ -164,3 +165,122 @@ it("omits an empty reset plate but keeps countdowns and banked credits visible", await expect.element(plate()).toBeVisible(); expect(plate()?.textContent).toBe("1"); }); + +it("moves the one limit popover between windows on hover", async () => { + const [environmentId, presentation] = [...presentations("2026-09-03T14:00:00Z", 1)][0]!; + const serverConfig = presentation.serverConfig!; + const provider = serverConfig.providers[0]!; + const twoWindows: LimitPresentations = new Map([ + [ + environmentId, + { + ...presentation, + serverConfig: { + ...serverConfig, + providers: [ + { + ...provider, + usageLimits: { + ...provider.usageLimits!, + windows: [ + ...provider.usageLimits!.windows, + { id: "weekly", kind: "weekly", label: "Weekly", usedPercent: 32 }, + ], + }, + }, + ], + }, + }, + ], + ]); + // Room above the bars so the popover opens there instead of flipping over the next card. + container.style.paddingTop = "20rem"; + await act(() => root.render()); + const segment = (label: string) => + Array.from(document.querySelectorAll("button")).find((node) => + node.getAttribute("aria-label")?.startsWith(label), + )!; + // Popup contents, once the previous segment's details have animated away. + const popups = () => + document.querySelector("[data-previous]") + ? null + : Array.from( + document.querySelectorAll('[data-slot="popover-popup"]'), + (node) => node.textContent, + ); + const session = segment("Alpha: 60%"); + const weekly = segment("Alpha: 68%"); + + await act(() => userEvent.click(session)); + await expect.poll(popups).toEqual([expect.stringContaining("Left60%")]); + + await act(() => userEvent.hover(weekly)); + await expect.poll(popups).toEqual([expect.stringContaining("Left68%")]); + + // Weekly's popover now sits over the middle of the Session bar; come back in at its edge. + await act(() => userEvent.hover(session, { position: { x: 4, y: 4 } })); + await expect.poll(popups).toEqual([expect.stringContaining("Left60%")]); + + await act(() => root.render()); + await expect.poll(popups).toEqual([]); + await act(() => userEvent.hover(container, { position: { x: 4, y: 4 } })); + + await act(() => root.render()); + await expect.poll(popups).toEqual([]); + await act(() => userEvent.click(segment("Alpha: 60%"))); + await expect.poll(popups).toEqual([expect.stringContaining("Left60%")]); +}); + +it("closes an open source-account popover when reset availability replaces its trigger", async () => { + const [environmentId, presentation] = [...presentations("2026-09-03T14:00:00Z", 1)][0]!; + const provider = presentation.serverConfig!.providers[0]!; + const source = (nextCreditId?: string): LimitPresentations => + new Map([ + [ + environmentId, + { + ...presentation, + serverConfig: { + providers: [], + usageLimitSources: [ + { + id: UsageLimitSourceId.make("hub"), + kind: "cliproxy", + label: "Hub", + checkedAt: "2026-09-03T11:00:00Z", + accounts: [ + { + id: "Alpha", + driver: provider.driver, + usageLimits: { + ...provider.usageLimits!, + resetCredits: { + availableCount: 1, + ...(nextCreditId ? { nextCreditId } : {}), + }, + }, + }, + ], + }, + ], + }, + }, + ], + ]); + const popup = () => document.querySelector('[data-slot="popover-popup"]'); + await act(() => root.render()); + await click("Alpha: 60%"); + await expect.poll(() => popup()?.textContent).toContain("Use reset"); + + await act(() => root.render()); + await expect.poll(popup).toBeNull(); + await click("Alpha: 60%"); + await expect.poll(() => popup()?.textContent).toContain("Left60%"); + + await act(() => root.render()); + await expect.poll(popup).toBeNull(); + await click("Alpha: 60%"); + await expect.poll(() => popup()?.textContent).toContain("Use reset"); + await click("Use reset"); + await expect.poll(() => document.querySelector('[role="alertdialog"]')).not.toBeNull(); +}); diff --git a/apps/web/src/components/usage/UsageLimitsPooled.tsx b/apps/web/src/components/usage/UsageLimitsPooled.tsx index 379c6f9d1028..e5178c4e200b 100644 --- a/apps/web/src/components/usage/UsageLimitsPooled.tsx +++ b/apps/web/src/components/usage/UsageLimitsPooled.tsx @@ -14,7 +14,7 @@ import { remainingPercent, } from "@t3tools/shared/usageLimits"; import { AlertTriangleIcon, ExternalLinkIcon, TicketIcon } from "lucide-react"; -import { Fragment, type ReactNode, useState } from "react"; +import { createContext, Fragment, type ReactNode, use, useEffect, useState } from "react"; import { ensureLocalApi } from "../../localApi"; import { usePrimarySettings } from "../../hooks/useSettings"; @@ -26,7 +26,7 @@ import { RedactedSensitiveText } from "../settings/RedactedSensitiveText"; import { Button } from "../ui/button"; import { OpenAI } from "../Icons"; import { Alert, AlertTitle } from "../ui/alert"; -import { Popover, PopoverPopup, PopoverTrigger } from "../ui/popover"; +import { Popover, PopoverCreateHandle, PopoverPopup, PopoverTrigger } from "../ui/popover"; import { PaceIcon, ResetCreditDialog, @@ -201,36 +201,133 @@ function SegmentPopover({ ); } +/** + * Every segment on the page shares one popover, so hovering or clicking another + * segment moves it instead of opening a second one. The payload is the segment + * it currently describes. + */ +type SegmentPayload = Omit[0], "now" | "onRedeem">; +const SegmentPopoverContext = createContext +> | null>(null); + +function ActiveSegmentPopover({ + handle, + payload, + pools, + now, +}: { + readonly handle: ReturnType>; + readonly payload: SegmentPayload; + readonly pools: readonly LimitPool[]; + readonly now: number; +}) { + const available = pools.some((pool) => + displayLimitWindows(pool).some((window) => + window.members.some( + (member) => + member.account.key === payload.account.key && + member.window.id === payload.window.id && + member.window.kind === payload.window.kind && + Boolean(member.account.redeem) === Boolean(payload.redeem), + ), + ), + ); + useEffect(() => { + if (!available) handle.close(); + }, [available, handle]); + return ( + + { + handle.close(); + payload.redeem?.setConfirming(true); + }} + /> + + ); +} + /** * The confirm is a sibling of the popover, not a child: dialogs stack under * popovers, and the popover closes as the confirm opens. */ function PoolSegment({ + account, + ...props +}: { + readonly account: LimitAccount; + readonly window: LimitPoolMember["window"]; + readonly reset: LimitPoolWindow["resets"][number] | undefined; + readonly refillPercent: number; + readonly color: string; + readonly now: number; + readonly index: number; + readonly showAccountName: boolean; +}) { + return account.redeem ? ( + + ) : ( + + ); +} + +function RedeemableSegment({ + redeemAt, + ...props +}: Omit[0], "redeem"> & { + readonly redeemAt: NonNullable; +}) { + const redeem = useResetCredit(redeemAt.environmentId, redeemAt.input); + return ( + <> + + void redeem.redeem()} + disabled={!redeem.canManageProviders} + /> + {/* The popover closed before the confirm, so progress and the outcome need a home outside it. */} + {redeem.busy || redeem.status ? ( + + {" "} + {redeem.busy ? "Using reset credit…" : redeem.status} + + ) : null} + + ); +} + +function SegmentTriggers({ account, window, reset, + redeem, refillPercent, color, now, index, showAccountName, -}: { - readonly account: LimitAccount; - readonly window: LimitPoolMember["window"]; - readonly reset: LimitPoolWindow["resets"][number] | undefined; +}: SegmentPayload & { readonly refillPercent: number; readonly color: string; readonly now: number; readonly index: number; readonly showAccountName: boolean; }) { - const [open, setOpen] = useState(false); + const popover = use(SegmentPopoverContext) ?? undefined; + const payload: SegmentPayload = { account, window, reset, redeem }; const remaining = remainingPercent(window); const resetsIn = formatResetsIn(window, now); const credits = account.limits.resetCredits?.availableCount ?? 0; return ( - + <> - - {account.redeem ? ( - setOpen(false)} - /> - ) : ( - - {}} - /> - - )} - + + ); } function LegendRow({ - account, - window, + payload, color, now, index, }: { - readonly account: LimitAccount; - readonly window: LimitPoolMember["window"]; + readonly payload: SegmentPayload; readonly color: string; readonly now: number; readonly index: number; }) { + const popover = use(SegmentPopoverContext) ?? undefined; + const { account, window } = payload; const remaining = remainingPercent(window); const resetsIn = formatResetsIn(window, now); const credits = account.limits.resetCredits?.availableCount ?? 0; return ( } className="min-w-0 @2xl/pool:hidden" @@ -371,54 +449,6 @@ function LegendRow({ ); } -function RedeemableSegmentPopup({ - account, - window, - reset, - now, - redeemAt, - closePopover, -}: { - readonly account: LimitAccount; - readonly window: LimitPoolMember["window"]; - readonly reset: LimitPoolWindow["resets"][number] | undefined; - readonly now: number; - readonly redeemAt: NonNullable; - readonly closePopover: () => void; -}) { - const redeem = useResetCredit(redeemAt.environmentId, redeemAt.input); - return ( - <> - - { - closePopover(); - redeem.setConfirming(true); - }} - /> - - void redeem.redeem()} - disabled={!redeem.canManageProviders} - /> - {/* The popover closed before the confirm, so progress and the outcome need a home outside it. */} - {redeem.busy || redeem.status ? ( - - {" "} - {redeem.busy ? "Using reset credit…" : redeem.status} - - ) : null} - - ); -} - function PoolBar({ pool, color, @@ -538,6 +568,7 @@ export function UsageLimitsPooled({ readonly now: number; readonly cursorPrompt?: ReactNode; }) { + const [segmentPopover] = useState(() => PopoverCreateHandle()); const pools = collectLimitPools(collectLimitAccounts(presentations), now); const notices = collectLimitNotices(presentations); const externalLinks = collectExternalUsageLinks(presentations); @@ -553,12 +584,26 @@ export function UsageLimitsPooled({ No provider on the selected environments reports subscription limits.

) : null} - {pools.map((pool, index) => ( - - {index === cursorPromptAt ? cursorPrompt : null} - - - ))} + + {pools.map((pool, index) => ( + + {index === cursorPromptAt ? cursorPrompt : null} + + + ))} + + + {({ payload }) => + payload ? ( + + ) : null + } + {cursorPromptAt === pools.length ? cursorPrompt : null} {externalLinks.map((link) => (