diff --git a/apps/web/tests/plugin-manager.e2e.ts b/apps/web/tests/plugin-manager.e2e.ts index 1f7ee7ee70..ff6871e4af 100644 --- a/apps/web/tests/plugin-manager.e2e.ts +++ b/apps/web/tests/plugin-manager.e2e.ts @@ -293,6 +293,38 @@ describe('web e2e: plugin manager', () => { } }) + it('keeps the collapsed bar steady when collapsing lets a barely scrolling list fit', async () => { + await page.setViewportSize({ width: 1000, height: 480 }) + const panel = await openPluginsPanel() + const body = panel.locator('[data-plugin-body]') + const compact = () => panel.locator('[data-plugin-bar]').evaluate(element => element.hasAttribute('data-compact')) + try { + await panel.locator('[data-plugin-package]').first().waitFor() + // Size the window so the resting bar leaves the list 12px of scroll range, less than collapsing frees. + const range = await body.evaluate(element => element.scrollHeight - element.clientHeight) + await page.setViewportSize({ width: 1000, height: 480 + range - 12 }) + await expect.poll(() => body.evaluate(element => element.scrollHeight - element.clientHeight)).toBe(12) + await page.mouse.move(500, 400) + await page.mouse.wheel(0, 200) + await expect.poll(compact).toBe(true) + expect(await body.evaluate(element => element.scrollHeight - element.clientHeight)).toBeLessThanOrEqual(0) + // The clamp back to the top must not reopen the bar and loop the layout. + const flips = await panel.locator('[data-plugin-bar]').evaluate(element => new Promise((resolve) => { + let count = 0 + const observer = new MutationObserver(() => { count++ }) + observer.observe(element, { attributes: true, attributeFilter: ['data-compact'] }) + setTimeout(() => { observer.disconnect(); resolve(count) }, 500) + })) + expect(flips).toBe(0) + expect(await compact()).toBe(true) + await page.mouse.wheel(0, -200) + await expect.poll(compact).toBe(false) + } finally { + await body.evaluate((element) => { element.scrollTop = 0 }) + await page.setViewportSize({ width: 1680, height: 1000 }) + } + }) + it('keeps the plugin page title and actions pinned while the cards scroll', async () => { await page.setViewportSize({ width: 1000, height: 480 }) const panel = await openPluginsPanel() diff --git a/packages/client/ui-plugin-manager/src/client/PluginManagerPage.tsx b/packages/client/ui-plugin-manager/src/client/PluginManagerPage.tsx index da8dde9af4..54d6508255 100644 --- a/packages/client/ui-plugin-manager/src/client/PluginManagerPage.tsx +++ b/packages/client/ui-plugin-manager/src/client/PluginManagerPage.tsx @@ -1728,7 +1728,18 @@ export function PluginManagerPage(props: PluginManagerPageProps): ReactNode { ) : null} {/* The page bar and the More list's head stay put; everything below scrolls. */}
{ setCompactBar(event.currentTarget.scrollTop > COMPACT_BAR_SCROLL_TOP) }}> + onScroll={(event) => { + const body = event.currentTarget + if (body.scrollTop > COMPACT_BAR_SCROLL_TOP) setCompactBar(true) + // Collapsing grows the body, so a list that only just scrolled can fit and clamp to 0 without + // the person scrolling up; the bar reopens at the top only while the list still scrolls. + else if (body.scrollTop === 0 && body.scrollHeight > body.clientHeight) setCompactBar(false) + }} + onWheel={(event) => { + // A list that fits only under the collapsed bar has no scroll range; scrolling up reopens the bar. + const body = event.currentTarget + if (event.deltaY < 0 && body.scrollHeight <= body.clientHeight) setCompactBar(false) + }}> {!onInstalled ? (
diff --git a/packages/client/ui-plugin-manager/tests/components.client.spec.tsx b/packages/client/ui-plugin-manager/tests/components.client.spec.tsx index b28e6c4e44..5ee9c63ad7 100644 --- a/packages/client/ui-plugin-manager/tests/components.client.spec.tsx +++ b/packages/client/ui-plugin-manager/tests/components.client.spec.tsx @@ -292,12 +292,42 @@ describe('PluginManagerPage', () => { expect(bar?.hasAttribute('data-compact')).toBe(true) act(() => { if (body === null) throw new Error('the page body is missing') + Object.defineProperties(body, { scrollHeight: { configurable: true, value: 900 }, clientHeight: { configurable: true, value: 400 } }) body.scrollTop = 0 fireEvent.scroll(body) }) expect(bar?.hasAttribute('data-compact')).toBe(false) }) + it('keeps the collapsed bar when collapsing makes the list fit, and reopens it on an upward scroll', () => { + renderTab({ packages: [pkg()] }) + const bar = document.querySelector('[data-plugin-bar]') + const body = document.querySelector('[data-plugin-body]') + if (body === null) throw new Error('the page body is missing') + const range = (scrollHeight: number): void => { + Object.defineProperties(body, { + scrollHeight: { configurable: true, value: scrollHeight }, + clientHeight: { configurable: true, value: 400 }, + }) + } + range(440) + act(() => { body.scrollTop = 20; fireEvent.scroll(body) }) + expect(bar?.hasAttribute('data-compact')).toBe(true) + // The taller body now holds the whole list, and the browser clamps the scroll position to 0. + range(400) + act(() => { body.scrollTop = 0; fireEvent.scroll(body) }) + expect(bar?.hasAttribute('data-compact')).toBe(true) + act(() => { fireEvent.wheel(body, { deltaY: 40 }) }) + expect(bar?.hasAttribute('data-compact')).toBe(true) + act(() => { fireEvent.wheel(body, { deltaY: -40 }) }) + expect(bar?.hasAttribute('data-compact')).toBe(false) + // A wheel over a list that still scrolls leaves the bar to the scroll position. + range(900) + act(() => { body.scrollTop = 20; fireEvent.scroll(body) }) + act(() => { fireEvent.wheel(body, { deltaY: -40 }) }) + expect(bar?.hasAttribute('data-compact')).toBe(true) + }) + it('keeps the Installed tab panel through loading and the More list out of the tabs', () => { const worktree = '@deepseek-ai/dsh-experimental-tool-worktree' const { navigation, set } = renderTab({ status: 'loading', packages: [] }, {}, {