Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 15 additions & 1 deletion apps/web/src/components/AppRouteModal.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -20,14 +20,24 @@ export function AppRouteModal({ label, children }: { label: string; children: Re
// Focus the surface, not its first control: no stray focus ring, no phone keyboard.
// On close, leave focus alone; returning it to the opener paints a focus ring.
const popupRef = useRef<HTMLDivElement>(null);
// dnd-kit marks the handle being dragged; that drag owns arrow keys and Escape, which its
// sensors read on the document.
const sortableDragInProgress = () =>
popupRef.current?.querySelector('[aria-roledescription="sortable"][aria-pressed="true"]') !=
null;

return (
<DialogPrimitive.Root
open={open}
// Portaled layers outside this tree (toasts, Clerk, confirm dialogs) are not backdrop presses.
disablePointerDismissal
onOpenChange={(nextOpen) => {
onOpenChange={(nextOpen, eventDetails) => {
if (nextOpen) return;
if (eventDetails.reason === "escape-key" && sortableDragInProgress()) {
eventDetails.cancel();
eventDetails.allowPropagation();
return;
}
// Closing unmounts a focused field without a blur, so commit-on-blur rows would
// drop what was typed. Blur first so they commit.
const focused = document.activeElement;
Expand Down Expand Up @@ -57,6 +67,10 @@ export function AppRouteModal({ label, children }: { label: string; children: Re
aria-label={label}
data-slot="dialog-popup"
data-app-modal=""
// The popup stops arrow-key propagation, which would strand a keyboard drag.
onKeyDown={(event) => {
if (sortableDragInProgress()) event.preventBaseUIHandler();
}}
className="relative flex size-full min-h-0 min-w-0 flex-col overflow-hidden bg-background max-sm:pt-safe max-sm:pb-safe text-foreground outline-none transition-[opacity,scale] duration-150 ease-out [-webkit-app-region:no-drag] data-ending-style:scale-[0.985] data-ending-style:opacity-0 data-starting-style:scale-[0.985] data-starting-style:opacity-0 sm:max-h-[60rem] sm:max-w-[80rem] sm:rounded-2xl sm:border sm:shadow-2xl/40"
>
{children}
Expand Down
15 changes: 14 additions & 1 deletion apps/web/src/components/usage/UsageLimits.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import {
type EnvironmentId,
type ProviderConsumeResetCreditOutcome,
ProviderConsumeResetCreditInput,
type ProviderDriverKind,
ServerProvider,
ServerProviderResetCredits,
ServerProviderUsageWindow,
Expand Down Expand Up @@ -364,11 +365,15 @@ export function ResetCredits({
export function UsageLimitsSection({
selectedEnvironmentIds,
hiddenProviders,
providerOrder,
onProviderOrderChange,
now,
cursorPrompt,
}: {
readonly selectedEnvironmentIds: ReadonlySet<EnvironmentId> | null;
readonly hiddenProviders: ReadonlySet<UsageProviderKind>;
readonly providerOrder: readonly ProviderDriverKind[];
readonly onProviderOrderChange: (order: readonly ProviderDriverKind[]) => void;
readonly now: number;
readonly cursorPrompt?: ReactNode;
}) {
Expand Down Expand Up @@ -402,5 +407,13 @@ export function UsageLimitsSection({
];
}),
);
return <UsageLimitsPooled presentations={selected} now={now} cursorPrompt={cursorPrompt} />;
return (
<UsageLimitsPooled
presentations={selected}
now={now}
cursorPrompt={cursorPrompt}
providerOrder={providerOrder}
onProviderOrderChange={onProviderOrderChange}
/>
);
}
143 changes: 143 additions & 0 deletions apps/web/src/components/usage/UsageLimitsOrder.browser.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,143 @@
import { EnvironmentId, ProviderDriverKind, UsageLimitSourceId } from "@t3tools/contracts";
import type { LimitPresentations } from "@t3tools/shared/usageLimits";
import { useState } from "react";
import { expect, it, vi } from "vite-plus/test";
import { page, userEvent } from "vite-plus/test/browser";

import { render } from "../../../test/render";
import { AppRouteModal } from "../AppRouteModal";
import { AppAtomRegistryProvider } from "../../rpc/atomRegistry";
import { UsageLimitsPooled } from "./UsageLimitsPooled";
import "../../index.css";

it("reorders visible providers without moving hidden ones and cancels inside the Usage modal", async () => {
const now = Date.parse("2026-10-28T01:00:00.000Z");
const checkedAt = new Date(now).toISOString();
const usageWindow = (id: string, kind: "session" | "weekly", label: string) => ({
id,
kind,
label,
usedPercent: 29,
resetsAt: "2026-10-28T03:55:00.000Z",
});
const presentations: LimitPresentations = new Map([
[
EnvironmentId.make("demo"),
{
entry: { target: { label: "Demo" } },
serverConfig: {
providers: [],
usageLimitSources: [
{
id: UsageLimitSourceId.make("demo"),
kind: "cliproxy",
label: "Demo",
checkedAt,
// Unequal section heights: Claude has two windows, Codex one.
accounts: [
{ driver: "codex", windows: [usageWindow("weekly", "weekly", "Weekly")] },
{
driver: "claudeAgent",
windows: [
usageWindow("primary", "session", "Session"),
usageWindow("weekly", "weekly", "Weekly"),
],
},
].map(({ driver, windows }) => ({
id: driver,
driver: ProviderDriverKind.make(driver),
usageLimits: { checkedAt, windows },
})),
},
],
},
},
],
]);
const onOrderChange = vi.fn();
function Demo() {
const [order, setOrder] = useState<readonly ProviderDriverKind[]>(
["codex", "cursor", "claudeAgent", "grok"].map((driver) => ProviderDriverKind.make(driver)),
);
return (
<AppAtomRegistryProvider>
<AppRouteModal label="Usage">
<div style={{ width: 1000 }}>
<UsageLimitsPooled
presentations={presentations}
now={now}
providerOrder={order}
onProviderOrderChange={(next) => {
onOrderChange(next);
setOrder(next);
}}
/>
</div>
</AppRouteModal>
</AppAtomRegistryProvider>
);
}
await using _screen = await render(<Demo />, { router: "/usage" });
const dialog = page.getByRole("dialog", { name: "Usage" });
const headings = () => [...document.querySelectorAll("h2")].map((node) => node.textContent);
await expect.element(dialog).toBeVisible();
expect(headings()).toEqual(["Codex", "Claude"]);
await page.getByRole("button", { name: "Reorder Claude" }).click();
await userEvent.keyboard(" ");
await userEvent.keyboard("{ArrowUp}");
await userEvent.keyboard(" ");
await vi.waitFor(() => expect(headings()).toEqual(["Claude", "Codex"]));
expect(onOrderChange).toHaveBeenCalledExactlyOnceWith(["claudeAgent", "cursor", "codex", "grok"]);

// Escape cancels the drag only; Usage stays open and the same grip picks up again.
await userEvent.keyboard(" ");
await userEvent.keyboard("{ArrowDown}");
await userEvent.keyboard("{Escape}");
await vi.waitFor(() => expect(headings()).toEqual(["Claude", "Codex"]));
await expect.element(dialog).toHaveAttribute("data-open");
expect(onOrderChange).toHaveBeenCalledTimes(1);
await expect.element(page.getByRole("button", { name: "Reorder Claude" })).toHaveFocus();
await userEvent.keyboard(" ");
await userEvent.keyboard("{ArrowDown}");
await userEvent.keyboard(" ");
await vi.waitFor(() => expect(headings()).toEqual(["Codex", "Claude"]));
expect(onOrderChange).toHaveBeenLastCalledWith(["codex", "cursor", "claudeAgent", "grok"]);

// The same holds for a pointer drag, and the grip can be grabbed again afterwards.
const grip = page.getByRole("button", { name: "Reorder Claude" });
const pointer = (type: string, clientY: number, buttons = 1) =>
new PointerEvent(type, {
bubbles: true,
cancelable: true,
isPrimary: true,
pointerId: 1,
button: 0,
buttons,
clientX: grip.element().getBoundingClientRect().left,
clientY,
});
const dragClaudeUp = async () => {
const top = grip.element().getBoundingClientRect().top;
grip.element().dispatchEvent(pointer("pointerdown", top));
// The first move only activates the drag; the second one moves it.
document.dispatchEvent(pointer("pointermove", top - 10));
await expect.element(grip).toHaveAttribute("aria-pressed", "true");
document.dispatchEvent(pointer("pointermove", top - 200));
await expect.element(page.getByText(/over droppable area codex\./)).toBeInTheDocument();
return top - 200;
};
const cancelledAt = await dragClaudeUp();
await userEvent.keyboard("{Escape}");
await expect.element(grip).not.toHaveAttribute("aria-pressed");
document.dispatchEvent(pointer("pointerup", cancelledAt, 0));
await expect.element(dialog).toHaveAttribute("data-open");
expect(headings()).toEqual(["Codex", "Claude"]);
expect(onOrderChange).toHaveBeenCalledTimes(2);
document.dispatchEvent(pointer("pointerup", await dragClaudeUp(), 0));
await vi.waitFor(() => expect(headings()).toEqual(["Claude", "Codex"]));
expect(onOrderChange).toHaveBeenCalledTimes(3);

// With no drag in progress, Escape closes Usage as before.
await userEvent.keyboard("{Escape}");
await expect.element(dialog).not.toBeInTheDocument();
});
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ import {
type ServerProvider,
} from "@t3tools/contracts";
import { CHATGPT_USAGE_URL } from "@t3tools/shared/usageLimits";
import { describe, expect, it } from "vite-plus/test";
import { describe, expect, it, vi } from "vite-plus/test";

import { render } from "../../../test/render";
import { UsageLimitsPooled } from "./UsageLimitsPooled";
Expand Down Expand Up @@ -52,7 +52,12 @@ describe("UsageLimitsPooled ChatGPT usage card", () => {
],
]);
await using screen = await render(
<UsageLimitsPooled presentations={presentations} now={Date.parse(checkedAt)} />,
<UsageLimitsPooled
presentations={presentations}
now={Date.parse(checkedAt)}
providerOrder={[]}
onProviderOrderChange={vi.fn()}
/>,
);

await expect
Expand Down
Loading
Loading