fix(web): keep signed-out updater in top-right cluster (#7482)

* fix(web): keep signed-out updater in top-right cluster

* fix(web): gate updater cluster on rendered control

Keep the updater host mounted for status updates while exposing the top-right cluster only when the updater renders a control. Preserve the signed-out and identity-loading project-route no-cluster contract.\n\nGenerated-By: looper 0.12.0 (runner=fixer, agent=codex)
This commit is contained in:
Ray Xi
2026-08-27 06:49:33 +00:00
committed by GitHub
parent 586469938e
commit 75e231ec7e
5 changed files with 111 additions and 131 deletions
+33 -83
View File
@@ -1,7 +1,9 @@
import { expect, test } from '@/playwright/suite';
import { applyStandardMocks } from '@/playwright/mock-factory';
import {
applyStandardMocks,
routeSignedOutVelaStatus,
} from '@/playwright/mock-factory';
import { mockAmrPersonalWorkspace } from '@/playwright/amr';
import { ensureRailOpen } from '@/playwright/rail';
import { T } from '@/timeouts';
const RECENT_PROJECTS = Array.from({ length: 6 }, (_, i) => ({
@@ -14,9 +16,10 @@ const RECENT_PROJECTS = Array.from({ length: 6 }, (_, i) => ({
}));
// Regression boundary: the desktop update-ready prompt and the home composer's
// model picker can be open at the same time. The updater now lives in the nav
// rail, but it must still paint above the raised composer card and its popover
// wherever those independently positioned surfaces overlap.
// model picker can be open at the same time. The updater lives in the shared
// top-right cluster for both signed-in and signed-out shells. Signed-in keeps
// the prompt within the viewport; signed-out stays clear of the raised composer
// card and its popover in a compact window.
test.beforeEach(async ({ page }) => {
await applyStandardMocks(page);
@@ -125,27 +128,21 @@ for (const direction of ['ltr', 'rtl'] as const) {
});
}
test('[P1] update ready prompt paints above the composer and its agent picker', async ({ page }) => {
test.fail(
true,
'The rail-hosted updater prompt currently paints behind the raised Home composer in compact windows.',
);
// In the current rail host the prompt grows upward from the footer. A compact
// desktop window puts it across the centered composer and model popover.
test('[P1] signed-out update prompt stays clear of the composer and its agent picker', async ({ page }) => {
await routeSignedOutVelaStatus(page);
await page.setViewportSize({ width: 700, height: 600 });
await page.goto('/', { waitUntil: 'domcontentloaded' });
await page.getByText('Loading OpenDesign…').waitFor({ state: 'hidden', timeout: T.long });
await expect(page.getByTestId('home-hero')).toBeVisible();
// The updater host moved into the nav rail footer with the entry topbar's
// removal (#5517); the collapsed rail is inert, so expand it first. Open the
// control with the keyboard because this stacking test intentionally models
// the signed-out shell, whose Cloud sign-in card overlaps the footer pointer
// target. Updater pointer actionability is covered by its component tests.
await ensureRailOpen(page);
const updaterButton = page.getByTestId('entry-nav-updater');
await updaterButton.focus();
await page.keyboard.press('Enter');
// Signed-out has no account capsule, but the updater keeps the same
// top-right cluster home and remains directly actionable.
await expect(page.getByTestId('entry-nav-account')).toHaveCount(0);
const updaterButton = page
.locator('.entry-top-right-cluster')
.getByTestId('entry-nav-updater');
await expect(updaterButton).toBeVisible();
await updaterButton.click();
const popup = page.getByTestId('updater-popup');
await expect(popup).toBeVisible();
@@ -159,81 +156,34 @@ test('[P1] update ready prompt paints above the composer and its agent picker',
await expect(page.getByTestId('inline-model-switcher-popover')).toBeVisible();
await expect(popup).toBeVisible();
// Require real overlap now that both surfaces are present so the stacking
// assertion cannot pass on separated geometry.
await expect
.poll(async () =>
page.evaluate(() => {
const popupEl = document.querySelector('[data-testid="updater-popup"]');
const card = document.querySelector('.home-hero__input-card');
const popover = document.querySelector('[data-testid="inline-model-switcher-popover"]');
if (popupEl == null || card == null || popover == null) return Number.NaN;
const popupRect = popupEl.getBoundingClientRect();
return Math.max(
...[card, popover].map((element) => {
const rect = element.getBoundingClientRect();
const width = Math.min(popupRect.right, rect.right) - Math.max(popupRect.left, rect.left);
const height = Math.min(popupRect.bottom, rect.bottom) - Math.max(popupRect.top, rect.top);
return width > 0 && height > 0 ? width * height : 0;
}),
);
}),
)
.toBeGreaterThan(0);
await expect(page.getByTestId('inline-model-switcher-popover')).toBeVisible();
await expect(popup).toBeVisible();
// The prompt is a dialog: it must be the topmost element wherever the
// raised composer card or its agent-picker popover overlaps it. With the
// stacking bug, `elementFromPoint` resolves to composer/picker content
// instead of the prompt.
const probe = await page.evaluate(() => {
// Moving the signed-out updater from the rail footer to the top-right cluster
// removes the old collision altogether. Keep the geometry assertion after
// both surfaces open so a future repositioning cannot silently put the
// prompt back across the composer or its popover.
const overlapAreas = await page.evaluate(() => {
const popupEl = document.querySelector('[data-testid="updater-popup"]');
const overlays = [
document.querySelector('.home-hero__input-card'),
document.querySelector('[data-testid="inline-model-switcher-popover"]'),
];
if (popupEl == null || overlays.some((el) => el == null)) {
return { ready: false, overlapArea: 0, samples: [] };
return null;
}
const p = popupEl.getBoundingClientRect();
let overlapArea = 0;
const samples: { x: number; y: number; insidePopup: boolean; hit: string }[] = [];
for (const overlay of overlays) {
return overlays.map((overlay) => {
const r = (overlay as Element).getBoundingClientRect();
const left = Math.max(p.left, r.left);
const right = Math.min(p.right, r.right);
const top = Math.max(p.top, r.top);
const bottom = Math.min(p.bottom, r.bottom);
if (right - left < 4 || bottom - top < 4) continue;
overlapArea += (right - left) * (bottom - top);
for (const fx of [0.25, 0.5, 0.75]) {
for (const fy of [0.25, 0.5, 0.75]) {
const x = Math.round(left + (right - left) * fx);
const y = Math.round(top + (bottom - top) * fy);
const hit = document.elementFromPoint(x, y);
samples.push({
x,
y,
insidePopup: hit?.closest('[data-testid="updater-popup"]') != null,
hit:
hit instanceof HTMLElement
? hit.className.toString().slice(0, 60) || hit.tagName
: (hit?.tagName ?? 'null'),
});
}
}
}
return { ready: true, overlapArea, samples };
const width = Math.min(p.right, r.right) - Math.max(p.left, r.left);
const height = Math.min(p.bottom, r.bottom) - Math.max(p.top, r.top);
return width > 0 && height > 0 ? width * height : 0;
});
});
expect(probe.ready, 'popup, composer card, and agent picker must all be present').toBe(true);
// Geometry precondition: the composer surfaces actually reach under the
// prompt — otherwise this test would pass without exercising the stack.
expect(probe.overlapArea, 'composer must overlap the prompt area').toBeGreaterThan(0);
const leaks = probe.samples.filter((sample) => !sample.insidePopup);
expect(
leaks,
`Composer content paints over the update prompt at: ${JSON.stringify(leaks)}`,
).toEqual([]);
overlapAreas,
'popup, composer card, and agent picker must all be measurable',
).not.toBeNull();
expect(overlapAreas, 'signed-out updater prompt must stay clear of composer surfaces').toEqual([0, 0]);
});