From ff787499b5ffe6c94dacc813190e3ffb902eb956 Mon Sep 17 00:00:00 2001 From: Destin Date: Mon, 28 Sep 2026 16:01:19 -0700 Subject: [PATCH] fix(strip): a pill no longer sticks to the cursor after a missed release On Linux/Wayland a mouse press on a pill (or a row in the All Sessions menu) takes no pointer capture, so the browser can start its own drag. A release anywhere else never reached handlePointerUp: the press stayed armed, and the next time the cursor crossed the strip the pill was picked up and followed the mouse. Two guards: a window-level pointerup while a press is armed ends it, and a pointer move with no button held abandons a stale press outright. SessionStrip.tsx line budget 2784 -> 2820: the two guards and their WHY comments; nothing in the file is a natural split point for them. Co-Authored-By: Claude Opus 5.5 (1M context) --- desktop/line-budgets.json | 2 +- .../src/renderer/components/SessionStrip.tsx | 38 ++++++++++++++++++- desktop/tests/SessionStrip.test.tsx | 23 +++++++++++ 3 files changed, 61 insertions(+), 2 deletions(-) diff --git a/desktop/line-budgets.json b/desktop/line-budgets.json index 024dbcd6a..f36908bff 100644 --- a/desktop/line-budgets.json +++ b/desktop/line-budgets.json @@ -16,7 +16,7 @@ "renderer/remote-shim.ts": 3176, "renderer/state/chat-reducer.ts": 3397, "renderer/styles/globals.css": 2960, - "renderer/components/SessionStrip.tsx": 2784, + "renderer/components/SessionStrip.tsx": 2820, "main/main.ts": 2604, "shared/types.ts": 2391, "renderer/components/SyncPanel.tsx": 2060, diff --git a/desktop/src/renderer/components/SessionStrip.tsx b/desktop/src/renderer/components/SessionStrip.tsx index 197f4ad80..febc9f153 100644 --- a/desktop/src/renderer/components/SessionStrip.tsx +++ b/desktop/src/renderer/components/SessionStrip.tsx @@ -970,8 +970,28 @@ export default function SessionStrip({ pointerCaptureId.current = e.pointerId; }, [sessions, sessionStatuses, activeSessionId, onSelectSession, metrics, measurementsOf, tearOffModel]); + // A press whose release never reached handlePointerUp. WHY (Destin, + // 2026-09-28: pills "stuck to my mouse ... even when i've clicked away"): + // 'html-drag' takes no pointer capture, so a release off the strip was lost, + // the press stayed armed, and merely crossing the strip later dragged a pill. + const abandonPress = useCallback(() => { + if (dragOrigin.current === null) return; + const wasDragging = isDragging.current; + dragOrigin.current = null; isDragging.current = false; overIdRef.current = null; + setDragId(null); setOverId(null); setDragLeft(null); setDragActive(false); + setTimeout(() => { suppressClick.current = false; }, 0); + if (wasDragging && tearOffModel !== 'html-drag') (window as any).claude?.detach?.dragEnded?.(); + }, [tearOffModel]); + const handlePointerMove = useCallback((e: React.PointerEvent) => { if (e.pointerType) lastPointerType.current = e.pointerType; + // No button held = the release was missed (abandonPress). The dragover + // feed passes no `buttons`; a live tear-off ends on its captured pointerup. + if (dragOrigin.current !== null && liveDetachedWindowId.current === null + && typeof e.buttons === 'number' && (e.buttons & 1) === 0) { + abandonPress(); + return; + } // Live tear-off continuation — runs even after we've cleared dragId so the // detached window keeps following the cursor. Must be checked BEFORE the // dragId null-guard below. @@ -1152,7 +1172,23 @@ export default function SessionStrip({ : leadDrawn; const next = nextSlotId(rects, dragId, overIdRef.current, centre, tv.dir); if (next !== overIdRef.current) { overIdRef.current = next; setOverId(next); } - }, [dragId, tearOffModel]); + }, [dragId, tearOffModel, abandonPress]); + + // A release anywhere while a press is armed (handlePointerUp runs first and + // disarms, so this catches only the missed ones). pointercancel for a finger + // only: a mouse's is the browser drag 'html-drag' wants, ended by dragend. + const pressArmed = dragId !== null; + useEffect(() => { + if (!pressArmed) return; + const onUp = () => abandonPress(); + const onCancel = (e: PointerEvent) => { if (e.pointerType === 'touch') abandonPress(); }; + window.addEventListener('pointerup', onUp); + window.addEventListener('pointercancel', onCancel); + return () => { + window.removeEventListener('pointerup', onUp); + window.removeEventListener('pointercancel', onCancel); + }; + }, [pressArmed, abandonPress]); const handlePointerUp = useCallback((e: React.PointerEvent) => { if (isDragging.current) hoverLock.current = { x: e.clientX }; // see hoverLock diff --git a/desktop/tests/SessionStrip.test.tsx b/desktop/tests/SessionStrip.test.tsx index 9b2c14529..0e6cea689 100644 --- a/desktop/tests/SessionStrip.test.tsx +++ b/desktop/tests/SessionStrip.test.tsx @@ -421,6 +421,29 @@ describe('html-drag tear-off', () => { expect(dt.dropEffect).toBe('none'); // never claimed the drag }); + it('a press released away from the strip does not leave the pill stuck to the cursor', () => { + // No pointer capture on this model, so the release can land anywhere. + // A later press elsewhere that sweeps across the strip (selecting chat + // text, say) must not pick the old pill up. + const { bar, pills } = mount(); + const twin = () => bar.querySelector(':scope > div[aria-hidden]:not([data-ghost])'); + fireEvent.pointerDown(pills[1], { button: 0, buttons: 1, clientX: 100, clientY: 10, pointerId: 1, pointerType: 'mouse' }); + fireEvent.pointerUp(document.body, { button: 0, buttons: 0, clientX: 100, clientY: 300, pointerId: 1, pointerType: 'mouse' }); + fireEvent.pointerMove(bar, { buttons: 1, clientX: 160, clientY: 10, pointerId: 1, pointerType: 'mouse' }); + fireEvent.pointerMove(bar, { buttons: 1, clientX: 200, clientY: 10, pointerId: 1, pointerType: 'mouse' }); + expect(twin()).toBeNull(); + }); + + it('hovering with no button held never drags, even if the release was missed entirely', () => { + // The release can be swallowed outright (a window switch, a native menu). + const { bar, pills } = mount(); + const twin = () => bar.querySelector(':scope > div[aria-hidden]:not([data-ghost])'); + fireEvent.pointerDown(pills[1], { button: 0, buttons: 1, clientX: 100, clientY: 10, pointerId: 1, pointerType: 'mouse' }); + fireEvent.pointerMove(bar, { buttons: 0, clientX: 160, clientY: 10, pointerId: 1, pointerType: 'mouse' }); + fireEvent.pointerMove(bar, { buttons: 0, clientX: 200, clientY: 10, pointerId: 1, pointerType: 'mouse' }); + expect(twin()).toBeNull(); + }); + it('a drag that nothing accepted opens a new window — the desktop drop, as on Windows', () => { // Escape ends a drag identically (dropEffect 'none'); Destin chose the // desktop drop over Escape — cancelling is dragging back into the strip.