fix(sidebar): unstick interrupted panel drags so the bottom panel keeps tracking layout (#183)

* 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 <menghuan2003@outlook.com>
This commit is contained in:
Solitude
2026-08-18 23:08:54 +08:00
committed by GitHub
co-authored by Menghuan1918
parent 76aacddbb9
commit bcd60234ac
+32
View File
@@ -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)) }}
/>
)}
<div className={css.panelBody}>
@@ -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)) }}
/>
)}
</div>
@@ -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