From 27cd211bb3407777a6492ecc368eaeccd959bb40 Mon Sep 17 00:00:00 2001 From: Zzy Date: Sat, 22 Aug 2026 00:08:06 +0800 Subject: [PATCH] fix: cap drag height to visual viewport --- src/client/Sidebar.tsx | 16 +++++++++------- tests/sidebar-layout-push.spec.ts | 11 +++++++++-- 2 files changed, 18 insertions(+), 9 deletions(-) diff --git a/src/client/Sidebar.tsx b/src/client/Sidebar.tsx index ede7acd..cd58a9c 100644 --- a/src/client/Sidebar.tsx +++ b/src/client/Sidebar.tsx @@ -198,6 +198,7 @@ export function Sidebar(props: { ctx: Context; store: SidebarStore }) { // Guarded: browsers without visualViewport (older WebViews, jsdom) stay // at 0. rAF-throttled, same pattern as useNarrowViewport. const [keyboardInset, setKeyboardInset] = useState(0) + const [visualViewportHeight, setVisualViewportHeight] = useState(null) useEffect(() => { const vv = window.visualViewport if (vv === null || vv === undefined) return @@ -206,6 +207,7 @@ export function Sidebar(props: { ctx: Context; store: SidebarStore }) { frame = null const inset = Math.max(0, window.innerHeight - (vv.height + vv.offsetTop)) setKeyboardInset(inset > 1 ? Math.round(inset) : 0) + setVisualViewportHeight(Math.max(0, Math.round(vv.height))) } const onResize = (): void => { if (frame === null) frame = requestAnimationFrame(measure) } vv.addEventListener('resize', onResize) @@ -220,7 +222,7 @@ export function Sidebar(props: { ctx: Context; store: SidebarStore }) { // The bottom panel is offset above the on-screen keyboard. Cap its height // against that same visible area, not the taller layout viewport, so the // conversation keeps PANEL_MIN even on wide touch devices. - const layoutViewportHeight = Math.max(0, viewport.height - keyboardInset) + const layoutViewportHeight = visualViewportHeight ?? viewport.height // Current conversation (the sessions list feed). const sessionList = useSyncExternalStore( @@ -792,10 +794,10 @@ export function Sidebar(props: { ctx: Context; store: SidebarStore }) { height = pushedBottomHeight(state?.bottomOpen === true, state?.bottomHeight ?? 0) } else if (draggingBottom) { width = Math.min(state?.width ?? 0, window.innerWidth) - height = clampHeight(bottomDrag.current.startHeight + (bottomDrag.current.startY - event.clientY)) + height = pushedBottomHeight(true, clampHeight(bottomDrag.current.startHeight + (bottomDrag.current.startY - event.clientY))) } else if (draggingCorner) { width = clampWidth(cornerDrag.current.startWidth + (cornerDrag.current.startX - event.clientX)) - height = clampHeight(cornerDrag.current.startHeight + (cornerDrag.current.startY - event.clientY)) + height = pushedBottomHeight(true, clampHeight(cornerDrag.current.startHeight + (cornerDrag.current.startY - event.clientY))) } } if (width !== undefined && height !== undefined) { @@ -1187,7 +1189,7 @@ export function Sidebar(props: { ctx: Context; store: SidebarStore }) { if (!event.currentTarget.hasPointerCapture(event.pointerId)) return const { startX, startY, startWidth, startHeight } = cornerDrag.current const width = clampWidth(startWidth + (startX - event.clientX)) - const height = clampHeight(startHeight + (startY - event.clientY)) + const height = pushedBottomHeight(true, clampHeight(startHeight + (startY - event.clientY))) scheduleDrag(width, height) }} onPointerUp={(event) => { @@ -1199,7 +1201,7 @@ export function Sidebar(props: { ctx: Context; store: SidebarStore }) { // Up position wins over the rAF pending value (see the width // strip handler — issue #247). const width = clampWidth(startWidth + (startX - event.clientX)) - const height = clampHeight(startHeight + (startY - event.clientY)) + const height = pushedBottomHeight(true, clampHeight(startHeight + (startY - event.clientY))) commitDrag(width, height, s => setBottomHeight(setWidth(s, width), height)) setDraggingCorner(false) }} @@ -1266,7 +1268,7 @@ export function Sidebar(props: { ctx: Context; store: SidebarStore }) { onPointerMove={(event) => { if (!event.currentTarget.hasPointerCapture(event.pointerId)) return const { startY, startHeight } = bottomDrag.current - const height = clampHeight(startHeight + (startY - event.clientY)) + const height = pushedBottomHeight(true, clampHeight(startHeight + (startY - event.clientY))) scheduleDrag(Math.min(state.width, window.innerWidth), height) }} onPointerUp={(event) => { @@ -1277,7 +1279,7 @@ export function Sidebar(props: { ctx: Context; store: SidebarStore }) { const { startY, startHeight } = bottomDrag.current // Up position wins over the rAF pending value (see the width // strip handler — issue #247). - const height = clampHeight(startHeight + (startY - event.clientY)) + const height = pushedBottomHeight(true, clampHeight(startHeight + (startY - event.clientY))) commitDrag(Math.min(state.width, window.innerWidth), height, s => setBottomHeight(s, height)) setDraggingBottom(false) }} diff --git a/tests/sidebar-layout-push.spec.ts b/tests/sidebar-layout-push.spec.ts index 0574ba1..b94de12 100644 --- a/tests/sidebar-layout-push.spec.ts +++ b/tests/sidebar-layout-push.spec.ts @@ -6,12 +6,19 @@ const source = readFileSync('src/client/Sidebar.tsx', 'utf8') describe('Sidebar layout-push integration', () => { it('does not bypass the shared bottom-height cap during width drags', () => { expect(source).not.toContain('Math.min(state.bottomHeight, window.innerHeight)') - expect(source.match(/pushedBottomHeight\(/g)).toHaveLength(3) + expect(source.match(/pushedBottomHeight\(/g)).toHaveLength(9) }) it('caps panel geometry against the viewport visible above the keyboard', () => { - expect(source).toContain('viewport.height - keyboardInset') + expect(source).toContain('setVisualViewportHeight(Math.max(0, Math.round(vv.height)))') + expect(source).toContain('visualViewportHeight ?? viewport.height') expect(source.match(/viewportHeight: layoutViewportHeight/g)).toHaveLength(4) expect(source).toContain('viewport.width, layoutViewportHeight]') }) + + it('reapplies the visible-height cap to every vertical drag result', () => { + expect(source).not.toMatch(/const height = clampHeight\(/) + expect(source).not.toMatch(/height = clampHeight\(/) + expect(source.match(/pushedBottomHeight\(true, clampHeight\(/g)).toHaveLength(6) + }) })