From 88b6a7a49caccf6b9dd3ce39037a6b183fd7e8b4 Mon Sep 17 00:00:00 2001 From: Brooklyn Nicholson Date: Tue, 28 Jul 2026 02:50:06 -0500 Subject: [PATCH] fix(desktop): measure dock proximity against the chat surface, not the window The dock target is the docked composer at the bottom-center of its own surface. In a split, the viewport's bottom-center is somewhere else, so dragging onto the real dock never registered. --- .../chat/composer/hooks/use-popout-drag.ts | 41 ++++++++++++++----- 1 file changed, 30 insertions(+), 11 deletions(-) diff --git a/apps/desktop/src/app/chat/composer/hooks/use-popout-drag.ts b/apps/desktop/src/app/chat/composer/hooks/use-popout-drag.ts index 79e1cc067da..e4a53889e5b 100644 --- a/apps/desktop/src/app/chat/composer/hooks/use-popout-drag.ts +++ b/apps/desktop/src/app/chat/composer/hooks/use-popout-drag.ts @@ -3,6 +3,7 @@ import { type PointerEvent as ReactPointerEvent, type RefObject, useCallback, us import { POPOUT_ESTIMATED_HEIGHT, POPOUT_WIDTH_REM, + type PopoutBounds, type PopoutPosition, type PopoutSize, readPopoutBounds, @@ -35,6 +36,8 @@ interface PressState { interface ComposerPopoutGesturesOptions { composerRef: RefObject + /** Layout zone this composer belongs to — the scope its float is stored under. */ + groupId: string onDock: () => void onPopOut: () => void poppedOut: boolean @@ -67,10 +70,17 @@ function isFloatDragPlatform(target: EventTarget | null) { } /** 0 (far) → 1 (inside the dock zone). Drives both the dock glow and the - * release-to-dock test (which fires at proximity 1). */ -function dockProximityOf(rect: DOMRect) { - const horizontalDist = Math.abs(rect.left + rect.width / 2 - window.innerWidth / 2) - const verticalGap = window.innerHeight - DOCK_ZONE_BOTTOM_PX - rect.bottom + * release-to-dock test (which fires at proximity 1). + * + * Measured against THIS surface's area, not the window: the dock target is the + * docked composer, which sits at the bottom-center of its own chat surface. In + * a split (or any layout where the chat isn't the full window) the viewport's + * bottom-center is somewhere else entirely, so dragging onto the real dock + * never registered. */ +function dockProximityOf(rect: DOMRect, area?: PopoutBounds) { + const a = area ?? { bottom: window.innerHeight, left: 0, right: window.innerWidth, top: 0 } + const horizontalDist = Math.abs(rect.left + rect.width / 2 - (a.left + a.right) / 2) + const verticalGap = a.bottom - DOCK_ZONE_BOTTOM_PX - rect.bottom const v = verticalGap <= 0 ? 1 : Math.max(0, 1 - verticalGap / DOCK_VERTICAL_FALLOFF_PX) @@ -109,6 +119,7 @@ function popoutPositionUnderPointer( */ export function useComposerPopoutGestures({ composerRef, + groupId, onDock, onPopOut, poppedOut, @@ -142,7 +153,12 @@ export function useComposerPopoutGestures({ const beginFloatDrag = useCallback( (state: PressState, clientX: number, clientY: number, next: PopoutPosition, size?: PopoutSize) => { clearTimer() - const clamped = setComposerPopoutPosition(next, { area: readPopoutBounds(composerRef.current), size }) + + const clamped = setComposerPopoutPosition(groupId, next, { + area: readPopoutBounds(composerRef.current), + size + }) + liveRef.current = clamped state.mode = 'float' @@ -154,7 +170,7 @@ export function useComposerPopoutGestures({ setDragging(true) }, - [clearTimer, composerRef] + [clearTimer, composerRef, groupId] ) const peelOffFromDock = useCallback( @@ -255,17 +271,19 @@ export function useComposerPopoutGestures({ const composer = composerRef.current const size = composer ? { height: composer.offsetHeight, width: composer.offsetWidth } : undefined + const area = readPopoutBounds(composer) liveRef.current = setComposerPopoutPosition( + groupId, { bottom: state.startBottom - (pending.y - state.startY), right: state.startRight - (pending.x - state.startX) }, - { area: readPopoutBounds(composer), size } + { area, size } ) if (composer) { - setDockProximity(dockProximityOf(composer.getBoundingClientRect())) + setDockProximity(dockProximityOf(composer.getBoundingClientRect(), area)) } } @@ -317,13 +335,14 @@ export function useComposerPopoutGestures({ if (state.armed && state.mode === 'float') { const composer = composerRef.current const rect = composer?.getBoundingClientRect() + const area = readPopoutBounds(composer) - if (rect && dockProximityOf(rect) >= 1) { + if (rect && dockProximityOf(rect, area) >= 1) { onDock() } else { // Persist the resting position once, on release — never per move. const size = composer ? { height: composer.offsetHeight, width: composer.offsetWidth } : undefined - setComposerPopoutPosition(liveRef.current, { area: readPopoutBounds(composer), persist: true, size }) + setComposerPopoutPosition(groupId, liveRef.current, { area, persist: true, size }) } } @@ -340,7 +359,7 @@ export function useComposerPopoutGestures({ window.removeEventListener('pointerup', handleUp) window.removeEventListener('pointercancel', handleUp) } - }, [composerRef, onDock, peelOffFromDock, resetGesture]) + }, [composerRef, groupId, onDock, peelOffFromDock, resetGesture]) useEffect(() => clearTimer, [clearTimer])