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 1136d8537..3350bb6a8 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 ( @@ -916,6 +926,7 @@ export function LaunchWindow() { > {/* Drag handle */}