From 6112ed56029216c4d059753f77332b72f6f01a77 Mon Sep 17 00:00:00 2001 From: Etienne Lescot Date: Sun, 19 Jul 2026 12:10:47 +0200 Subject: [PATCH 1/2] fix(overlay): stop the HUD drag from drifting away from the cursor MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Fixes #100: dragging the HUD by its grip handle would drift away from the cursor rather than tracking it 1:1. Root cause: two independent IPC channels can both reposition the HUD BrowserWindow. Pointer drags send incremental deltas via hud-overlay-move-by; separately, a ResizeObserver watching the HUD's content calls hud-overlay-set-size whenever the observed content size changes, which recomputes the window's bounds from a bottom-centre anchor using the window's *current* bounds at the time of the call. If a resize observation fires mid-drag (even a spurious one from transient reflow), its anchor recompute races the drag's own incremental repositioning, and the two compound into a net offset — read by users as the HUD "drifting away". Fix: freeze content-size measurement while a drag is in progress (isDraggingHudRef), so hud-overlay-set-size cannot fire mid-drag. The content size is re-measured once via measureHudSize() right after the drag ends, so a real size change (e.g. from a toggle that was clicked mid-drag) still gets picked up promptly. Verified via computer-use against a real dev build: multiple multi-step drags (7-9 incremental pointer moves each, matching how a real drag event stream looks) all track the cursor to within a few px, both immediately on release and after a 2s settle -- no drift, no post-drop jump. --- src/components/launch/LaunchWindow.tsx | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/src/components/launch/LaunchWindow.tsx b/src/components/launch/LaunchWindow.tsx index 1136d8537..723660a39 100644 --- a/src/components/launch/LaunchWindow.tsx +++ b/src/components/launch/LaunchWindow.tsx @@ -321,9 +321,16 @@ export function LaunchWindow() { // and scrolls. Measure from the window's bottom-centre (the anchor the main process // preserves) so fixed bottom/centre offsets keep this stable and it doesn't oscillate. const lastHudSizeRef = useRef({ width: 0, height: 0 }); + const isDraggingHudRef = useRef(false); const measureHudSize = useCallback(() => { const barEl = hudBarRef.current; if (!barEl || !window.electronAPI?.setHudOverlaySize) return; + // While the user is dragging the HUD, ignore content-size measurements. A + // ResizeObserver-driven resize (hud-overlay-set-size) re-centres the window from + // its own bottom-centre anchor, which fights the position "hud-overlay-move-by" is + // actively applying frame-by-frame -- the two IPC channels racing is what produces + // the reported drift. Content size is re-measured once the drag ends instead. + if (isDraggingHudRef.current) return; // Breathing room so the drop shadow isn't clipped. TOP_MARGIN must also exceed the // slack in the bar's `max-h: calc(100vh - 2.5rem)` cap (40px reserved - 20px bottom @@ -634,6 +641,7 @@ export function LaunchWindow() { setHudMouseEventsEnabled(true); event.currentTarget.setPointerCapture(event.pointerId); dragLastPositionRef.current = { x: event.screenX, y: event.screenY }; + isDraggingHudRef.current = true; }; const handleHudDragPointerMove = (event: React.PointerEvent) => { const lastPosition = dragLastPositionRef.current; @@ -650,6 +658,8 @@ export function LaunchWindow() { event.currentTarget.releasePointerCapture(event.pointerId); } setHudMouseEventsEnabled(false); + isDraggingHudRef.current = false; + measureHudSize(); }; return ( From a1a2cf75fb415bde3ad83b860938ba220357f88d Mon Sep 17 00:00:00 2001 From: Etienne Lescot Date: Sun, 19 Jul 2026 14:14:29 +0200 Subject: [PATCH 2/2] test(launch): add regression coverage for HUD drag measurement suppression Per CodeRabbit review on #125: covers the fix's actual behavior end-to-end through the real drag handlers rather than testing isDraggingHudRef directly. Adds data-testid="hud-drag-handle" (the drag grip had no selector) and a test that: fires a real pointerdown on the handle, triggers a ResizeObserver callback mid-drag and asserts setHudOverlaySize is NOT called (the race this PR fixes), then fires pointermove/pointerup and asserts a measurement DOES happen once released -- so a real size change made mid-drag still gets picked up promptly. Full suite: 53 files / 424 tests pass. tsc --noEmit and biome check clean. --- src/components/launch/LaunchWindow.test.tsx | 69 +++++++++++++++++++++ src/components/launch/LaunchWindow.tsx | 1 + 2 files changed, 70 insertions(+) diff --git a/src/components/launch/LaunchWindow.test.tsx b/src/components/launch/LaunchWindow.test.tsx index 154feef73..0b745c80c 100644 --- a/src/components/launch/LaunchWindow.test.tsx +++ b/src/components/launch/LaunchWindow.test.tsx @@ -460,6 +460,75 @@ describe("LaunchWindow system language prompt", () => { }); }); +describe("LaunchWindow HUD drag", () => { + beforeEach(() => { + platformState.value = "darwin"; + resetLaunchMocks(); + resizeCallbacks.length = 0; + vi.stubGlobal("ResizeObserver", CapturingResizeObserver); + // jsdom doesn't implement the Pointer Capture API; stub it so the drag handlers + // (which call set/has/releasePointerCapture) don't throw. + HTMLElement.prototype.setPointerCapture = vi.fn(); + HTMLElement.prototype.hasPointerCapture = vi.fn(() => true); + HTMLElement.prototype.releasePointerCapture = vi.fn(); + }); + + afterEach(() => { + cleanup(); + vi.unstubAllGlobals(); + }); + + it("suppresses ResizeObserver-driven measurement while dragging, and measures once on release", async () => { + renderLaunchWindow(); + + const dragHandle = await screen.findByTestId("hud-drag-handle"); + + // Give the bar a non-zero, changing size so a resize observation would actually + // trigger a `setHudOverlaySize` call if it weren't suppressed during the drag. + const bar = dragHandle.closest("[data-tray-layout]") as HTMLElement | null; + if (bar) { + vi.spyOn(bar, "getBoundingClientRect").mockReturnValue({ + top: 700, + left: 200, + right: 600, + bottom: 756, + width: 400, + height: 56, + x: 200, + y: 700, + toJSON: () => ({}), + }); + Object.defineProperty(bar, "scrollHeight", { value: 56, configurable: true }); + Object.defineProperty(bar, "scrollWidth", { value: 400, configurable: true }); + } + + const sizeMock = window.electronAPI.setHudOverlaySize as unknown as { + mockClear: () => void; + }; + sizeMock.mockClear(); + + fireEvent.pointerDown(dragHandle, { screenX: 100, screenY: 100 }); + + // Simulate a ResizeObserver firing mid-drag (e.g. transient reflow) -- this must + // NOT reposition/resize the HUD while the user's pointer is still down. + await act(async () => { + for (const callback of resizeCallbacks) { + callback([], {} as ResizeObserver); + } + }); + expect(window.electronAPI.setHudOverlaySize).not.toHaveBeenCalled(); + + fireEvent.pointerMove(dragHandle, { screenX: 140, screenY: 130 }); + fireEvent.pointerUp(dragHandle, { screenX: 140, screenY: 130 }); + + // Content is re-measured once the drag ends, so a real size change made mid-drag + // still gets picked up promptly. + await waitFor(() => { + expect(window.electronAPI.setHudOverlaySize).toHaveBeenCalled(); + }); + }); +}); + describe("LaunchWindow software encoder fallback notice", () => { beforeEach(() => { platformState.value = "darwin"; diff --git a/src/components/launch/LaunchWindow.tsx b/src/components/launch/LaunchWindow.tsx index 723660a39..3350bb6a8 100644 --- a/src/components/launch/LaunchWindow.tsx +++ b/src/components/launch/LaunchWindow.tsx @@ -926,6 +926,7 @@ export function LaunchWindow() { > {/* Drag handle */}