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) => (