fix(ui): prevent layout shift when usage limits load (#19463)

Reserve the usage-limit property row during the first runtime-state request and replace its skeleton in place.

Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
DottaandPaperclip authored and GitHub committed 2026-11-06 08:34:38 -06:00
1 parent e680fa6814
commit b8e69c6dd0
2 files changed
+99 -6

No files matched your search

@@ -23,6 +23,7 @@ import { ApiError } from "../api/client";
const mockAgentsApi = vi.hoisted(() => ({
list: vi.fn(),
adapterModels: vi.fn(),
runtimeState: vi.fn(),
}));
const mockProjectsApi = vi.hoisted(() => ({
@@ -639,6 +640,7 @@ describe("IssueProperties", () => {
document.body.appendChild(container);
mockAgentsApi.list.mockResolvedValue([]);
mockAgentsApi.adapterModels.mockResolvedValue([]);
mockAgentsApi.runtimeState.mockResolvedValue(null);
mockRoutinesApi.get.mockReset();
mockProjectsApi.list.mockResolvedValue([]);
mockExecutionWorkspacesApi.list.mockResolvedValue([]);
@@ -685,6 +687,92 @@ describe("IssueProperties", () => {
document.body.innerHTML = "";
});
it.each([true, false])("reserves the usage-limit row while runtime data loads (inline: %s)", async (inline) => {
let resolveRuntimeState!: (value: unknown) => void;
const runtimeState = new Promise<unknown>((resolve) => { resolveRuntimeState = resolve; });
mockAgentsApi.runtimeState.mockReturnValueOnce(runtimeState);
const root = renderProperties(container, {
issue: createIssue({ assigneeAgentId: "agent-1" }),
childIssues: [],
onUpdate: vi.fn(),
inline,
});
try {
const row = container.querySelector('[data-property-label="Usage limit"]')
?.closest('[data-property-row="true"]');
expect(row).toBeTruthy();
expect(row?.querySelector('[role="status"]')?.getAttribute("aria-label"))
.toBe("Loading usage limit");
const nextRow = row?.nextElementSibling;
await act(async () => {
resolveRuntimeState({
stateJson: {
providerRateLimits: {
schema: "paperclip.provider.rate_limits.v1",
provider: "openai",
observedAt: new Date().toISOString(),
windows: [{
id: "primary",
label: "5-hour window",
shortLabel: "5h",
windowMinutes: 300,
usedPercent: 42,
resetsAt: null,
status: "allowed",
}],
credits: null,
limitReached: null,
},
},
});
});
await waitForAssertion(() => {
expect(row?.querySelector('button[aria-label="Provider usage limit: 5h 42%"]')).toBeTruthy();
});
expect(container.querySelector('[data-property-label="Usage limit"]')
?.closest('[data-property-row="true"]')).toBe(row);
expect(row?.nextElementSibling).toBe(nextRow);
expect(row?.querySelector('[role="status"]')).toBeNull();
} finally {
act(() => root.unmount());
}
});
it.each([null, "user-1"])("does not reserve a usage-limit row without an agent (user: %s)", async (assigneeUserId) => {
const root = renderProperties(container, {
issue: createIssue({ assigneeUserId }),
childIssues: [],
onUpdate: vi.fn(),
});
try {
await flush();
expect(container.querySelector('[data-property-label="Usage limit"]')).toBeNull();
} finally {
act(() => root.unmount());
}
});
it.each(["missing", "forbidden"])("hides the usage-limit placeholder when runtime data is %s", async (outcome) => {
if (outcome === "forbidden") {
mockAgentsApi.runtimeState.mockRejectedValueOnce(new ApiError("Forbidden", 403, null));
}
const root = renderProperties(container, {
issue: createIssue({ assigneeAgentId: "agent-1" }),
childIssues: [],
onUpdate: vi.fn(),
});
try {
await waitForAssertion(() => {
expect(container.querySelector('[data-property-label="Usage limit"]')).toBeNull();
});
} finally {
act(() => root.unmount());
}
});
it("marks the task-detail property typography and section rhythm", () => {
const root = renderProperties(container, {
issue: createIssue(),
@@ -79,6 +79,7 @@ import { invalidateInboxIssueQueries } from "../../lib/inboxArchiveCache";
import { Button } from "@/components/ui/button";
import { ToggleSwitch } from "@/components/ui/toggle-switch";
import { Separator } from "@/components/ui/separator";
import { Skeleton } from "@/components/ui/skeleton";
import { Textarea } from "@/components/ui/textarea";
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip";
@@ -436,7 +437,7 @@ export function IssueProperties({
enabled: !!companyId,
});
const assigneeRuntimeAgentId = issue.assigneeAgentId ?? null;
const { data: assigneeRuntimeState } = useQuery({
const { data: assigneeRuntimeState, isLoading: assigneeRuntimeStateLoading } = useQuery({
queryKey: queryKeys.agents.runtimeState(assigneeRuntimeAgentId ?? ""),
queryFn: () => agentsApi.runtimeState(assigneeRuntimeAgentId!, companyId ?? undefined),
// Optional detail: a 403 for board users without agent management access
@@ -2420,12 +2421,16 @@ export function IssueProperties({
</PropertyPicker>
) : null}
{assigneeRateLimits && summarizeProviderRateLimits(assigneeRateLimits, Date.now()) ? (
{assigneeRuntimeStateLoading || (assigneeRateLimits && summarizeProviderRateLimits(assigneeRateLimits, Date.now())) ? (
<PropertyRow label="Usage limit">
<AgentRateLimitPropertyValue
snapshot={assigneeRateLimits}
detailsHref={assigneeRateLimitAgent ? agentUrl(assigneeRateLimitAgent) : undefined}
/>
{assigneeRuntimeStateLoading ? (
<Skeleton className="h-4 w-32" role="status" aria-label="Loading usage limit" />
) : (
<AgentRateLimitPropertyValue
snapshot={assigneeRateLimits}
detailsHref={assigneeRateLimitAgent ? agentUrl(assigneeRateLimitAgent) : undefined}
/>
)}
</PropertyRow>
) : null}