Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion desktop/line-budgets.json
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
38 changes: 37 additions & 1 deletion desktop/src/renderer/components/SessionStrip.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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
Expand Down
23 changes: 23 additions & 0 deletions desktop/tests/SessionStrip.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
Loading