From 208e323035ab40bbf3b3476fb6175330bc79821c Mon Sep 17 00:00:00 2001 From: "paseo-bot[bot]" <266920839+paseo-bot[bot]@users.noreply.github.com> Date: Thu, 15 Oct 2026 21:16:52 +0200 Subject: [PATCH] Keep usage providers in place after refresh (#6947) * fix(app): preserve usage provider order across refreshes * test(usage): keep refresh and scrolling journeys stable Usage reports can follow the long fixture card, so a fixed wheel distance can scroll past its final window. Target the named windows with wheel input while preserving the visibility checks. Assert refreshed provider order through visible names and keep viewport setup in journey helpers. Co-authored-by: Sleroq --------- Co-authored-by: Sleroq Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com> --- packages/app/e2e/browser/usage-modal.spec.ts | 49 +++++++++++++++++++ .../e2e/browser/usage-sidebar-item.spec.ts | 10 ++-- .../e2e/support/helpers/usage-sidebar-item.ts | 8 +++ packages/app/src/usage/model.test.ts | 16 ++++++ packages/app/src/usage/model.ts | 19 ++++--- 5 files changed, 92 insertions(+), 10 deletions(-) diff --git a/packages/app/e2e/browser/usage-modal.spec.ts b/packages/app/e2e/browser/usage-modal.spec.ts index 6f546078bf..a923971d09 100644 --- a/packages/app/e2e/browser/usage-modal.spec.ts +++ b/packages/app/e2e/browser/usage-modal.spec.ts @@ -100,6 +100,40 @@ function weeklyReport(sourceId: string, usedPct: number): UsageReportEntry { }; } +function installReversedUsageRefresh(page: Page) { + const initial = [weeklyReport("opencode", 31), weeklyReport("openai", 12)]; + const refreshed = [weeklyReport("openai", 22), weeklyReport("opencode", 41)]; + return installUsageReportsFixture(page, { + lists: [(request) => (request.forceRefresh ? refreshed : initial)], + }); +} + +async function openUsageSummaryAtWidth(page: Page, width: number) { + await page.setViewportSize({ width, height: 900 }); + await gotoAppShell(page); + if (width === 390) await openCompactSidebar(page); + await expectPinnedUsage(page, ["31% Weekly", "12% Weekly"]); + await openUsageFromItem(page); +} + +async function expectUsageProviderOrder(page: Page, summary: string[]) { + const group = page.getByTestId(`usage-host-${getServerId()}`); + await expect(group.getByText(/^(opencode|openai) plan$/)).toHaveText([ + "opencode plan", + "openai plan", + ]); + await expectPinnedUsage(page, summary); +} + +async function refreshUsageAndWaitForReports(page: Page, usage: UsageReportsFixture) { + const requestCount = usage.listRequests().length; + await refreshAllUsage(page); + await usage.waitForListRequests(requestCount + 1); + const group = page.getByTestId(`usage-host-${getServerId()}`); + await expect(group.getByText("41%", { exact: true })).toBeVisible(); + await expect(page.getByRole("button", { name: "Refresh all", exact: true })).toBeEnabled(); +} + test.describe("usage modal", () => { test("opens from the sidebar on the host's reports, with no host filter for one host", async ({ page, @@ -152,6 +186,21 @@ test.describe("usage modal", () => { await expect(group.getByTestId("usage-freshness")).toHaveCount(0); }); + for (const width of [1440, 390]) { + test(`keeps sidebar and modal provider order across a reversed refresh at width ${width}`, async ({ + page, + }) => { + const usage = await installReversedUsageRefresh(page); + await openUsageSummaryAtWidth(page, width); + await expectUsageProviderOrder(page, ["31% Weekly", "12% Weekly"]); + await qaScreenshot(page, `usage-order-${width}-before-refresh`); + + await refreshUsageAndWaitForReports(page, usage); + await expectUsageProviderOrder(page, ["41% Weekly", "22% Weekly"]); + await qaScreenshot(page, `usage-order-${width}-after-refresh`); + }); + } + test("refreshes one report from its card", async ({ page }) => { test.setTimeout(120_000); const serverId = getServerId(); diff --git a/packages/app/e2e/browser/usage-sidebar-item.spec.ts b/packages/app/e2e/browser/usage-sidebar-item.spec.ts index fb738b3cb5..27fac6691c 100644 --- a/packages/app/e2e/browser/usage-sidebar-item.spec.ts +++ b/packages/app/e2e/browser/usage-sidebar-item.spec.ts @@ -30,7 +30,7 @@ import { openUsageFromIcon, openUsageFromItem, pinRow, - scrollUsage, + scrollUsageToWindow, setSummaryInSidebar, openUsageOptions, showUsageAs, @@ -146,7 +146,9 @@ test.describe("Usage item", () => { compact: { viewport: COMPACT, showSidebar: openCompactSidebar }, }; for (const [size, { viewport, showSidebar }] of Object.entries(layouts)) { - test(`long usage reports scroll to the final window on ${size}`, async ({ page }) => { + test(`long usage reports scroll between their first and last windows on ${size}`, async ({ + page, + }) => { test.setTimeout(120_000); const source = await installTallUsageSource(); try { @@ -161,10 +163,10 @@ test.describe("Usage item", () => { await waitForSettledPosition(content); await expect(content.getByText("Window 20", { exact: true })).not.toBeInViewport(); await qaScreenshot(page, `${size}-usage-scroll-top`); - await scrollUsage(page, 5_000); + await scrollUsageToWindow(page, "Window 20"); await expect(content.getByText("Window 20", { exact: true })).toBeInViewport(); await qaScreenshot(page, `${size}-usage-scroll-bottom`); - await scrollUsage(page, -5_000); + await scrollUsageToWindow(page, "Window 1"); await expect(content.getByText("Window 20", { exact: true })).not.toBeInViewport(); } finally { await source.cleanup(); diff --git a/packages/app/e2e/support/helpers/usage-sidebar-item.ts b/packages/app/e2e/support/helpers/usage-sidebar-item.ts index 8cc3c07a00..95c6a4d689 100644 --- a/packages/app/e2e/support/helpers/usage-sidebar-item.ts +++ b/packages/app/e2e/support/helpers/usage-sidebar-item.ts @@ -212,6 +212,14 @@ export async function scrollUsage(page: Page, deltaY: number): Promise { await page.mouse.wheel(0, deltaY); } +/** Reach a window with the wheel even when other reports follow its card. */ +export async function scrollUsageToWindow(page: Page, label: string): Promise { + const target = await usageModal(page).getByText(label, { exact: true }).boundingBox(); + const viewport = page.viewportSize(); + if (!target || !viewport) throw new Error("Usage window must exist before scrolling to it."); + await scrollUsage(page, target.y + target.height / 2 - viewport.height / 2); +} + /** The modal's own Close button. */ export async function closeUsage(page: Page): Promise { await visible(page, "usage-modal").getByRole("button", { name: "Close", exact: true }).click(); diff --git a/packages/app/src/usage/model.test.ts b/packages/app/src/usage/model.test.ts index ad293bb23e..a2c493619d 100644 --- a/packages/app/src/usage/model.test.ts +++ b/packages/app/src/usage/model.test.ts @@ -257,6 +257,22 @@ describe("settleReports", () => { expect(settleReports([refreshed, beta], [alpha, beta])).toEqual([refreshed, beta]); }); + it("keeps the displayed order when providers finish a refresh in reverse order", () => { + const refreshed = entry({ sourceId: "alpha", planLabel: "New" }); + const streamed = upsertReport(upsertReport([alpha, beta], beta), refreshed); + expect(settleReports(streamed, [beta, refreshed])).toEqual([refreshed, beta]); + }); + + it("drops missing reports and appends new ones without moving surviving reports", () => { + const gamma = entry({ sourceId: "gamma" }); + const delta = entry({ sourceId: "delta" }); + expect(settleReports([alpha, beta, gamma], [delta, gamma, alpha])).toEqual([ + alpha, + gamma, + delta, + ]); + }); + it("takes the finished list as is when nothing was shown yet", () => { expect(settleReports(undefined, [alpha, beta])).toEqual([alpha, beta]); }); diff --git a/packages/app/src/usage/model.ts b/packages/app/src/usage/model.ts index a32da7ea4d..ed8a39163a 100644 --- a/packages/app/src/usage/model.ts +++ b/packages/app/src/usage/model.ts @@ -82,17 +82,24 @@ export function upsertReport( } /** - * The list a finished request leaves: its reports, dropping any the host no longer has, except - * that a copy on screen fetched after the request's copy stays. + * Keep displayed reports in place: the finished list is in fetch-completion order, which can + * change on every refresh. Drop missing reports, append new ones, and keep newer fetched copies. */ export function settleReports( shown: readonly UsageReportEntry[] | undefined, finished: readonly UsageReportEntry[], ): UsageReportEntry[] { - return finished.map((report) => { - const copy = shown?.find((entry) => entry.id === report.id); - return copy ? laterFetched(copy, report) : report; - }); + const remaining = new Map(finished.map((report) => [report.id, report])); + const settled: UsageReportEntry[] = []; + for (const report of shown ?? []) { + const arriving = remaining.get(report.id); + if (!arriving) { + continue; + } + settled.push(laterFetched(report, arriving)); + remaining.delete(report.id); + } + return [...settled, ...remaining.values()]; } export interface UsageQueryState {