mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-11-09 07:10:34 +01:00
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:
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}
|
||||
|
||||
|
||||
Reference in new issue
Block a user