diff --git a/apps/web/src/components/AppRouteModal.tsx b/apps/web/src/components/AppRouteModal.tsx index 55d7c4112f968..98f22b9bc5b82 100644 --- a/apps/web/src/components/AppRouteModal.tsx +++ b/apps/web/src/components/AppRouteModal.tsx @@ -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(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 ( { + 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; @@ -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} diff --git a/apps/web/src/components/usage/UsageLimits.tsx b/apps/web/src/components/usage/UsageLimits.tsx index 1153b9cc74bc0..b5e8e47d30672 100644 --- a/apps/web/src/components/usage/UsageLimits.tsx +++ b/apps/web/src/components/usage/UsageLimits.tsx @@ -3,6 +3,7 @@ import { type EnvironmentId, type ProviderConsumeResetCreditOutcome, ProviderConsumeResetCreditInput, + type ProviderDriverKind, ServerProvider, ServerProviderResetCredits, ServerProviderUsageWindow, @@ -364,11 +365,15 @@ export function ResetCredits({ export function UsageLimitsSection({ selectedEnvironmentIds, hiddenProviders, + providerOrder, + onProviderOrderChange, now, cursorPrompt, }: { readonly selectedEnvironmentIds: ReadonlySet | null; readonly hiddenProviders: ReadonlySet; + readonly providerOrder: readonly ProviderDriverKind[]; + readonly onProviderOrderChange: (order: readonly ProviderDriverKind[]) => void; readonly now: number; readonly cursorPrompt?: ReactNode; }) { @@ -402,5 +407,13 @@ export function UsageLimitsSection({ ]; }), ); - return ; + return ( + + ); } diff --git a/apps/web/src/components/usage/UsageLimitsOrder.browser.test.tsx b/apps/web/src/components/usage/UsageLimitsOrder.browser.test.tsx new file mode 100644 index 0000000000000..cbf8c47b2ec99 --- /dev/null +++ b/apps/web/src/components/usage/UsageLimitsOrder.browser.test.tsx @@ -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( + ["codex", "cursor", "claudeAgent", "grok"].map((driver) => ProviderDriverKind.make(driver)), + ); + return ( + + +
+ { + onOrderChange(next); + setOrder(next); + }} + /> +
+
+
+ ); + } + await using _screen = await render(, { 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(); +}); diff --git a/apps/web/src/components/usage/UsageLimitsPooled.browser.test.tsx b/apps/web/src/components/usage/UsageLimitsPooled.browser.test.tsx index ab114fbbd6f21..dd969a7a1c460 100644 --- a/apps/web/src/components/usage/UsageLimitsPooled.browser.test.tsx +++ b/apps/web/src/components/usage/UsageLimitsPooled.browser.test.tsx @@ -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"; @@ -52,7 +52,12 @@ describe("UsageLimitsPooled ChatGPT usage card", () => { ], ]); await using screen = await render( - , + , ); await expect diff --git a/apps/web/src/components/usage/UsageLimitsPooled.tsx b/apps/web/src/components/usage/UsageLimitsPooled.tsx index bc69ca4e3403a..4023f10f0a6c1 100644 --- a/apps/web/src/components/usage/UsageLimitsPooled.tsx +++ b/apps/web/src/components/usage/UsageLimitsPooled.tsx @@ -15,11 +15,28 @@ import { remainingPercent, } from "@t3tools/shared/usageLimits"; import type { Popover as PopoverPrimitive } from "@base-ui/react/popover"; +import { + closestCenter, + DndContext, + KeyboardSensor, + PointerSensor, + useSensor, + useSensors, +} from "@dnd-kit/core"; +import { restrictToVerticalAxis } from "@dnd-kit/modifiers"; +import { + arrayMove, + SortableContext, + sortableKeyboardCoordinates, + useSortable, + verticalListSortingStrategy, +} from "@dnd-kit/sortable"; +import { CSS } from "@dnd-kit/utilities"; +import type { ProviderDriverKind } from "@t3tools/contracts"; +import { AlertTriangleIcon, ExternalLinkIcon, GripVerticalIcon, TicketIcon } from "lucide-react"; import { normalizeProviderAccentColor } from "@t3tools/client-runtime/state/provider-instance-display"; -import { AlertTriangleIcon, ExternalLinkIcon, TicketIcon } from "lucide-react"; import { createContext, - Fragment, type ReactNode, type RefObject, use, @@ -30,15 +47,18 @@ import { import { ensureLocalApi } from "../../localApi"; import { usePrimarySettings } from "../../hooks/useSettings"; +import { useMediaQuery } from "../../hooks/useMediaQuery"; import { cn } from "../../lib/utils"; import { formatUpcomingTimestamp } from "../../timestampFormat"; import { ProviderInstanceIcon } from "../chat/ProviderInstanceIcon"; import { providerClients } from "../settings/providerDriverMeta"; +import { usageLimitOrder } from "./usageLimitOrder"; import { RedactedSensitiveText } from "../settings/RedactedSensitiveText"; import { Button } from "../ui/button"; import { OpenAI } from "../Icons"; import { Alert, AlertTitle } from "../ui/alert"; import { Popover, PopoverCreateHandle, PopoverPopup, PopoverTrigger } from "../ui/popover"; +import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip"; import { PaceIcon, ResetCreditDialog, @@ -569,13 +589,55 @@ function PoolWindowCard({ ); } -function PoolSection({ pool, now }: { readonly pool: LimitPool; readonly now: number }) { +function PoolSection({ + pool, + now, + animate, +}: { + readonly pool: LimitPool; + readonly now: number; + readonly animate: boolean; +}) { + const { + active, + attributes, + listeners, + setNodeRef, + setActivatorNodeRef, + transform, + transition, + isDragging, + } = useSortable({ id: pool.driver, ...(animate ? {} : { transition: null }) }); const color = barColor(pool.driver); const label = providerClients.get(pool.driver)?.label ?? String(pool.driver); const windows = displayLimitWindows(pool); return ( -
+

+ + + } + > + + + Drag to reorder, or press Space and use the arrow keys + [0]; readonly now: number; readonly cursorPrompt?: ReactNode; + readonly providerOrder: readonly ProviderDriverKind[]; + readonly onProviderOrderChange: (order: readonly ProviderDriverKind[]) => void; }) { const [segmentPopover] = useState(() => PopoverCreateHandle()); + const [keyboardDrag, setKeyboardDrag] = useState(false); + const reducedMotion = useMediaQuery("(prefers-reduced-motion: reduce)"); const segmentPopoverActions = useRef(null); - const pools = collectLimitPools(collectLimitAccounts(presentations), now); + const collectedPools = collectLimitPools(collectLimitAccounts(presentations), now); + const order = usageLimitOrder( + providerOrder, + collectedPools.map((pool) => pool.driver), + ); + const pools = collectedPools.toSorted( + (left, right) => order.indexOf(left.driver) - order.indexOf(right.driver), + ); + const sensors = useSensors( + useSensor(PointerSensor, { activationConstraint: { distance: 4 } }), + useSensor(KeyboardSensor, { coordinateGetter: sortableKeyboardCoordinates }), + ); const notices = collectLimitNotices(presentations); const externalLinks = collectExternalUsageLinks(presentations); - const cursorPromptAt = - Math.max( - pools.findIndex((pool) => pool.driver === "codex"), - pools.findIndex((pool) => pool.driver === "claudeAgent"), - ) + 1; return (
{pools.length === 0 && notices.length === 0 && !cursorPrompt && externalLinks.length === 0 ? ( @@ -629,12 +703,39 @@ export function UsageLimitsPooled({

) : null} - {pools.map((pool, index) => ( - - {index === cursorPromptAt ? cursorPrompt : null} - - - ))} + setKeyboardDrag(activatorEvent.type === "keydown")} + onDragEnd={({ active, over }) => { + if (!over || active.id === over.id) return; + const visibleOrder = pools.map((pool) => pool.driver); + const from = visibleOrder.findIndex((driver) => driver === active.id); + const to = visibleOrder.findIndex((driver) => driver === over.id); + if (from < 0 || to < 0) return; + const reordered = arrayMove(visibleOrder, from, to); + onProviderOrderChange( + order.map((driver) => + visibleOrder.includes(driver) ? (reordered.shift() ?? driver) : driver, + ), + ); + }} + > + pool.driver)} + strategy={verticalListSortingStrategy} + > + {pools.map((pool) => ( + + ))} + + {({ payload }) => @@ -649,7 +750,7 @@ export function UsageLimitsPooled({ ) : null } - {cursorPromptAt === pools.length ? cursorPrompt : null} + {cursorPrompt} {externalLinks.map((link) => (
+ updatePreferences({ limitProviderOrder }) + } now={limitsNow} cursorPrompt={ cursorAccessEnvironments.length > 0 ? ( diff --git a/apps/web/src/components/usage/usageLimitOrder.test.ts b/apps/web/src/components/usage/usageLimitOrder.test.ts new file mode 100644 index 0000000000000..167f04ed74bcc --- /dev/null +++ b/apps/web/src/components/usage/usageLimitOrder.test.ts @@ -0,0 +1,29 @@ +import { ProviderDriverKind } from "@t3tools/contracts"; +import { describe, expect, it } from "vite-plus/test"; + +import { usageLimitOrder } from "./usageLimitOrder"; + +const drivers = (...values: string[]) => values.map((value) => ProviderDriverKind.make(value)); + +describe("usageLimitOrder", () => { + it("keeps the existing order until a preference is saved", () => { + const available = drivers("codex", "cursor", "claudeAgent"); + expect(usageLimitOrder([], available)).toEqual(available); + }); + + it("keeps hidden and disconnected providers in the saved order and appends new ones", () => { + const saved = drivers("claudeAgent", "codex", "cursor"); + const available = drivers("codex", "grok"); + const order = usageLimitOrder(saved, available); + expect(order).toEqual(drivers("claudeAgent", "codex", "cursor", "grok")); + expect(usageLimitOrder(order, drivers("cursor", "claudeAgent", "codex", "grok"))).toEqual( + order, + ); + }); + + it("deduplicates saved providers", () => { + expect(usageLimitOrder(drivers("claudeAgent", "claudeAgent"), drivers("codex"))).toEqual( + drivers("claudeAgent", "codex"), + ); + }); +}); diff --git a/apps/web/src/components/usage/usageLimitOrder.ts b/apps/web/src/components/usage/usageLimitOrder.ts new file mode 100644 index 0000000000000..cab95dc4931c4 --- /dev/null +++ b/apps/web/src/components/usage/usageLimitOrder.ts @@ -0,0 +1,8 @@ +import type { ProviderDriverKind } from "@t3tools/contracts"; + +export function usageLimitOrder( + saved: readonly ProviderDriverKind[], + available: readonly ProviderDriverKind[], +): ProviderDriverKind[] { + return [...new Set([...saved, ...available])]; +} diff --git a/apps/web/src/components/usage/usagePagePreferences.test.ts b/apps/web/src/components/usage/usagePagePreferences.test.ts index 075076a9b3c52..733634eacfa39 100644 --- a/apps/web/src/components/usage/usagePagePreferences.test.ts +++ b/apps/web/src/components/usage/usagePagePreferences.test.ts @@ -1,3 +1,4 @@ +import { ProviderDriverKind } from "@t3tools/contracts"; import { afterEach, beforeEach, describe, expect, it, vi } from "vite-plus/test"; import { readUsagePagePreferences, saveUsagePagePreferences } from "./usagePagePreferences"; @@ -24,6 +25,19 @@ afterEach(() => { }); describe("Usage page preferences", () => { + it("remembers provider order without losing filters or the selected metric", () => { + const preferences = { + metric: "limits" as const, + windowDays: 7 as const, + hiddenProviders: ["cursor" as const], + limitProviderOrder: ["claudeAgent", "codex", "cursor"].map((driver) => + ProviderDriverKind.make(driver), + ), + }; + saveUsagePagePreferences(preferences); + expect(readUsagePagePreferences()).toEqual(preferences); + }); + it("round-trips hidden providers and reads older saves without them", () => { saveUsagePagePreferences({ metric: "cost", windowDays: 7, hiddenProviders: ["codex"] }); expect(readUsagePagePreferences().hiddenProviders).toEqual(["codex"]); diff --git a/apps/web/src/components/usage/usagePagePreferences.ts b/apps/web/src/components/usage/usagePagePreferences.ts index 1aeaefc43da12..e790cf26dcaa6 100644 --- a/apps/web/src/components/usage/usagePagePreferences.ts +++ b/apps/web/src/components/usage/usagePagePreferences.ts @@ -1,4 +1,4 @@ -import { UsageProviderKind } from "@t3tools/contracts"; +import { ProviderDriverKind, UsageProviderKind } from "@t3tools/contracts"; import * as Schema from "effect/Schema"; import { getLocalStorageItem, setLocalStorageItem } from "../../hooks/useLocalStorage"; @@ -8,6 +8,7 @@ const UsagePagePreferencesSchema = Schema.Struct({ metric: Schema.Literals(["cost", "tokens", "limits"]), windowDays: Schema.Literals([1, 7, 30, 90]), hiddenProviders: Schema.optional(Schema.Array(UsageProviderKind)), + limitProviderOrder: Schema.optional(Schema.Array(ProviderDriverKind)), }); export type UsagePagePreferences = typeof UsagePagePreferencesSchema.Type; diff --git a/docs/user/usage.md b/docs/user/usage.md index 447924a072eff..5023209d0af9a 100644 --- a/docs/user/usage.md +++ b/docs/user/usage.md @@ -87,6 +87,11 @@ the dialog. ## Track subscription limits +On web and desktop, drag the grip beside a provider heading to put your preferred providers +first. You can also focus the grip, press Space, use the arrow keys, and press Space again to +drop it. Escape cancels. The order is remembered on this device, including providers you +temporarily hide or disconnect. + **Usage → Limits** pools every subscription account it can see per provider, so with several Codex or Claude accounts across your enabled environments and hubs you read one number per window rather than a list. Each window card shows how much of the pool is left and a bar with one segment per account,