From b8e69c6dd085943e7391b046547de05749aba3f9 Mon Sep 17 00:00:00 2001 From: Dotta <34892728+cryppadotta@users.noreply.github.com> Date: Fri, 6 Nov 2026 08:34:38 -0600 Subject: [PATCH] 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 --- ui/src/components/IssueProperties.test.tsx | 88 +++++++++++++++++++ .../issue-properties/IssueProperties.tsx | 17 ++-- 2 files changed, 99 insertions(+), 6 deletions(-) diff --git a/ui/src/components/IssueProperties.test.tsx b/ui/src/components/IssueProperties.test.tsx index b56d693a024..dcba674ba3c 100644 --- a/ui/src/components/IssueProperties.test.tsx +++ b/ui/src/components/IssueProperties.test.tsx @@ -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((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(), diff --git a/ui/src/components/issue-properties/IssueProperties.tsx b/ui/src/components/issue-properties/IssueProperties.tsx index b15987f2de2..2ca2e7b03de 100644 --- a/ui/src/components/issue-properties/IssueProperties.tsx +++ b/ui/src/components/issue-properties/IssueProperties.tsx @@ -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({ ) : null} - {assigneeRateLimits && summarizeProviderRateLimits(assigneeRateLimits, Date.now()) ? ( + {assigneeRuntimeStateLoading || (assigneeRateLimits && summarizeProviderRateLimits(assigneeRateLimits, Date.now())) ? ( - + {assigneeRuntimeStateLoading ? ( + + ) : ( + + )} ) : null}