fix(plugin-manager): keep the collapsed bar when collapsing lets the list fit

Collapsing the page bar grows the list's scroll container. A list that
scrolled only a few pixels then fit, the browser clamped it back to the
top, and the bar reopened, so every scroll flipped the layout. The bar
now reopens at the top only while the list still scrolls, and an upward
wheel reopens it over a list that fits only under the collapsed bar.

Co-authored-by: Yifffan <[email protected]>
This commit is contained in:
TurtleandYifffan committed 2026-10-19 22:19:24 +08:00
1 parent dbccedbc13
commit d04a96377a
3 files changed
+74 -1

No files matched your search

+32
View File
@@ -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<number>((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()
@@ -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. */}
<div className={css.pageBody} data-plugin-body
onScroll={(event) => { 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
? (
<div role="tabpanel" id={`${tabsId}-${tab}-panel`} aria-labelledby={`${tabsId}-${tab}`} className={css.tabPanel}>
@@ -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<HTMLElement>('[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: [] }, {}, {