fix(web): let the pointer reach hover cards and row menus

The workspace browser's two hover-raised popups both died on the way to
them. HoverCard closed on the first pointerleave and rendered its card
pointer-events:none, but the card sits 8px off the anchor, so every path
to it crossed ground belonging to neither. The row action menus put
closeOnPointerLeave's handler on the portaled list, so aiming back at the
... trigger that opened it, or overshooting a list edge, closed it with no
window to come back.

usePointerGrace owns one cancelable delayed close (200ms) shared by both
atoms: leaving arms it, returning cancels it. The hover card becomes
hit-testable so resting on it holds it open, and Menu moves pointer-leave
dismissal to the wrapper span, where React's enter/leave traversal makes
trigger and portaled list one region.

Both gestures are pinned in the real browser lane; each fails without the
corresponding fix.
This commit is contained in:
creatixchu
2026-07-30 20:25:02 +08:00
parent 2e8c82634e
commit 2cac565383
10 changed files with 341 additions and 38 deletions

View File

@@ -1,7 +1,8 @@
// Web e2e scenarios: workspace management — the create-by-name dialog, the
// rename round trip over the real wire (workspace.rename RPC + durable
// registry), duplicate-name pre-check, the flat "In one list" view with its
// persisted group-by preference, and the session hover card. Zero model
// persisted group-by preference, and the pointer-reachability of the session
// hover card and the row action menu. Zero model
// calls: workspace.create/rename are host RPCs with no model involvement,
// and the one session row the flat/hover scenarios need comes from a seeded
// fixture (the seeded-history seed reused verbatim — no new recording).
@@ -26,7 +27,7 @@ const MODE = webSnapshotMode()
const BROWSER_EXPECTED = join(SNAPSHOT_DIR, 'directory-browser.expected.md')
const SEED_ID = 'workspace-management-web-e2e'
describe('web e2e: workspace management (create / rename / flat view / hover card)', () => {
describe('web e2e: workspace management (create / rename / flat view / hover affordances)', () => {
let scaffold: WebScaffold
let browser: Browser
let page: Page
@@ -384,14 +385,17 @@ describe('web e2e: workspace management (create / rename / flat view / hover car
expect(tripwire.pageErrors).toEqual([])
}, 60_000)
it('shows the session hover card after a dwell on the row', async () => {
onTestFailed(() => saveFailureShot(page, 'web-e2e-ws-hover'))
// Expand Ungrouped to reveal the seeded session row, then dwell on it
// (the card opens after a 500ms hover delay, portaled to body).
/**
* Expand Ungrouped and return its seeded session row. The only visible child
* is the non-blank persisted Session; the blank Session created while
* adopting the Workspace stays hidden.
* @returns the session row locator, already present.
*/
async function seededSessionRow() {
const ungroupedRow = page.getByText('Ungrouped', { exact: true }).locator('..').locator('..')
const ungroupedSection = ungroupedRow.locator('..')
// Initial-current auto-expansion can race this following test's gesture;
// converge on expanded rather than assuming which update wins first.
// Initial-current auto-expansion can race this gesture; converge on
// expanded rather than assuming which update wins first.
await expect.poll(async () => {
if (await ungroupedRow.getAttribute('aria-expanded') !== 'true') {
await page.getByText('Ungrouped', { exact: true }).click()
@@ -399,20 +403,62 @@ describe('web e2e: workspace management (create / rename / flat view / hover car
}
return await ungroupedRow.getAttribute('aria-expanded')
}, { timeout: 5_000 }).toBe('true')
// The only visible child is the non-blank persisted Session; the blank
// Session created while adopting the Workspace remains hidden.
const sessionRow = ungroupedSection.locator('[role="treeitem"]').nth(1)
await sessionRow.waitFor({ timeout: 10_000 })
const row = ungroupedSection.locator('[role="treeitem"]').nth(1)
await row.waitFor({ timeout: 10_000 })
return row
}
it('shows the session hover card after a dwell on the row', async () => {
onTestFailed(() => saveFailureShot(page, 'web-e2e-ws-hover'))
// Dwell on the seeded row; the card opens after a 500ms hover delay,
// portaled to body.
const sessionRow = await seededSessionRow()
await sessionRow.hover()
// Card content: the full title plus the Idle status line (display-only
// card; no aria role — text anchors are the stable selector).
// Card content: the full title plus the Idle status line (no aria role —
// text anchors are the stable selector).
await expect.poll(() => page.getByText('Idle', { exact: true }).count(), { timeout: 5_000 }).toBeGreaterThanOrEqual(1)
// Leaving the anchor closes it with no delay.
// The card is REACHABLE: it sits 8px off the row, so getting to it means
// crossing ground that belongs to neither. Hovering it must not dismiss
// it — the regression this scenario guards.
const card = page.getByText('Idle', { exact: true }).locator('../../..')
await card.hover()
await page.waitForTimeout(600)
expect(await page.getByText('Idle', { exact: true }).count()).toBeGreaterThanOrEqual(1)
// Leaving anchor and card together closes it after the grace.
await page.getByRole('button', { name: 'Settings' }).hover()
await expect.poll(() => page.getByText('Idle', { exact: true }).count(), { timeout: 5_000 }).toBe(0)
expect(tripwire.pageErrors).toEqual([])
}, 60_000)
it('keeps an open row menu up while the pointer moves between trigger and list', async () => {
onTestFailed(() => saveFailureShot(page, 'web-e2e-ws-row-menu'))
const sessionRow = await seededSessionRow()
// The trigger is display:none until its row hovers.
await sessionRow.hover()
const trigger = sessionRow.locator('button[aria-label^="Session actions for "]')
await trigger.click()
const item = page.getByRole('menuitem', { name: 'Rename' })
await item.waitFor({ timeout: 5_000 })
// Into the list, then back up to the trigger across the 4px gap below it:
// that return trip used to fire the list's pointerleave and close the
// menu, so a hesitating pointer lost it. Order matters — clicking leaves
// the pointer ON the trigger, so entering the list has to come first for
// the return to be a real departure.
await item.hover()
await page.waitForTimeout(300)
await trigger.hover()
await page.waitForTimeout(600)
expect(await page.getByRole('menuitem', { name: 'Rename' }).count()).toBe(1)
// ...and back down into the list, which must still be there to enter.
await item.hover()
await page.waitForTimeout(600)
expect(await page.getByRole('menuitem', { name: 'Rename' }).count()).toBe(1)
// Pointer-leave dismissal still applies once the pointer genuinely leaves.
await page.getByRole('button', { name: 'Settings' }).hover()
await expect.poll(() => page.getByRole('menuitem', { name: 'Rename' }).count(), { timeout: 5_000 }).toBe(0)
expect(tripwire.pageErrors).toEqual([])
}, 60_000)
it.skipIf(MODE === 'record')('issued zero model calls and stayed clean', async () => {
expect(tripwire.warnings).toEqual([])
// The directory-browser aria golden is this spec's one owned artifact;