From bcd60234acc7af74e552086f5c16560452269883 Mon Sep 17 00:00:00 2001 From: Solitude <46916148+zhu1090093659@users.noreply.github.com> Date: Tue, 18 Aug 2026 23:08:54 +0800 Subject: [PATCH] fix(sidebar): unstick interrupted panel drags so the bottom panel keeps tracking layout (#183) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(sidebar): unstick interrupted panel drags so the bottom panel keeps tracking layout A width/height/corner drag whose pointer stream ends without pointerup (pointercancel, or pointer capture lost prematurely — e.g. a trackpad gesture interrupting the drag) left draggingWidth/draggingBottom/ draggingCorner stuck true: onPointerUp guards on hasPointerCapture and never ran, and no onPointerCancel/onLostPointerCapture handler existed. The stuck drag state pauses center-column measurement (draggingRef) permanently, so centerRect goes stale and the bottom panel keeps frozen inline left/right edges — it no longer expands when the right sidebar collapses or the app rail toggles until a full reload. Fix: mark each drag uncommitted on pointerdown and committed in pointerup before releasing capture; the new pointercancel / lostpointercapture handlers abort an uncommitted drag by reverting the DOM and layout variables to the store's committed sizes and clearing the dragging state, which resumes measurement. * fix(sidebar): clamp abort-drag rollback to collapsed/narrow geometry (P2 review) abortDrag wrote the committed width even when the right panel is collapsed or the viewport is narrow, where the layout-push effect had already written 0 to --dsh-sidebar-width — an interrupted drag then left the app shell squeezed by an invisible sidebar. Mirror the layout effect's clamping: write 0 when narrow or the panel is closed. --------- Co-authored-by: Menghuan1918 --- src/client/Sidebar.tsx | 32 ++++++++++++++++++++++++++++++++ 1 file changed, 32 insertions(+) diff --git a/src/client/Sidebar.tsx b/src/client/Sidebar.tsx index 07035d0..5773559 100644 --- a/src/client/Sidebar.tsx +++ b/src/client/Sidebar.tsx @@ -535,6 +535,26 @@ export function Sidebar(props: { ctx: Context; store: SidebarStore }) { pendingDrag.current = null } + /** Set once a drag's pointerup handler commits — premature capture loss + * (pointercancel / lostpointercapture without pointerup) must then be told + * apart from a normal release. */ + const dragCommitted = useRef(false) + /** Abort a drag whose pointer stream was interrupted (pointercancel, or + * capture lost before pointerup): no pointerup will arrive, so without + * this the dragging state would stick true and center-column measurement + * would stay paused forever — the bottom panel freezes at stale edges and + * stops tracking sidebar/app-rail layout changes. Reverts the DOM and the + * layout variables to the store's committed sizes. */ + const abortDrag = (reset: () => void): void => { + if (dragCommitted.current) return + stopDragScheduling() + applyDrag( + !narrow && state?.panelOpen === true ? Math.min(state?.width ?? 0, window.innerWidth) : 0, + !narrow && state?.bottomOpen === true ? Math.min(state.bottomHeight, window.innerHeight) : 0, + ) + reset() + } + // Layout push: the app shell gives up the panel's width/height while the // panels are open (0 while collapsed), so the conversation and input bar // are squeezed instead of covered. The margins are capped at the viewport @@ -784,6 +804,7 @@ export function Sidebar(props: { ctx: Context; store: SidebarStore }) { onPointerDown={(event) => { event.preventDefault() event.currentTarget.setPointerCapture(event.pointerId) + dragCommitted.current = false widthDrag.current = { startX: event.clientX, startWidth: state.width } setDraggingWidth(true) }} @@ -796,12 +817,15 @@ export function Sidebar(props: { ctx: Context; store: SidebarStore }) { }} onPointerUp={(event) => { if (!event.currentTarget.hasPointerCapture(event.pointerId)) return + dragCommitted.current = true event.currentTarget.releasePointerCapture(event.pointerId) const { startX, startWidth } = widthDrag.current stopDragScheduling() store.reduce(s => setWidth(s, startWidth + (startX - event.clientX))) setDraggingWidth(false) }} + onPointerCancel={() => { abortDrag(() => setDraggingWidth(false)) }} + onLostPointerCapture={() => { abortDrag(() => setDraggingWidth(false)) }} /> )}
@@ -833,6 +857,7 @@ export function Sidebar(props: { ctx: Context; store: SidebarStore }) { onPointerDown={(event) => { event.preventDefault() event.currentTarget.setPointerCapture(event.pointerId) + dragCommitted.current = false cornerDrag.current = { startX: event.clientX, startY: event.clientY, @@ -850,12 +875,15 @@ export function Sidebar(props: { ctx: Context; store: SidebarStore }) { }} onPointerUp={(event) => { if (!event.currentTarget.hasPointerCapture(event.pointerId)) return + dragCommitted.current = true event.currentTarget.releasePointerCapture(event.pointerId) const { startX, startY, startWidth, startHeight } = cornerDrag.current stopDragScheduling() store.reduce(s => setBottomHeight(setWidth(s, startWidth + (startX - event.clientX)), startHeight + (startY - event.clientY))) setDraggingCorner(false) }} + onPointerCancel={() => { abortDrag(() => setDraggingCorner(false)) }} + onLostPointerCapture={() => { abortDrag(() => setDraggingCorner(false)) }} /> )}
@@ -895,6 +923,7 @@ export function Sidebar(props: { ctx: Context; store: SidebarStore }) { onPointerDown={(event) => { event.preventDefault() event.currentTarget.setPointerCapture(event.pointerId) + dragCommitted.current = false bottomDrag.current = { startY: event.clientY, startHeight: state.bottomHeight } setDraggingBottom(true) }} @@ -906,12 +935,15 @@ export function Sidebar(props: { ctx: Context; store: SidebarStore }) { }} onPointerUp={(event) => { if (!event.currentTarget.hasPointerCapture(event.pointerId)) return + dragCommitted.current = true event.currentTarget.releasePointerCapture(event.pointerId) const { startY, startHeight } = bottomDrag.current stopDragScheduling() store.reduce(s => setBottomHeight(s, startHeight + (startY - event.clientY))) setDraggingBottom(false) }} + onPointerCancel={() => { abortDrag(() => setDraggingBottom(false)) }} + onLostPointerCapture={() => { abortDrag(() => setDraggingBottom(false)) }} /> {/* The bottom panel's own close control at its tab strip's right end