From 03b5a6f168cd5095974693eeb4270c12684f5414 Mon Sep 17 00:00:00 2001 From: BLRINK317 Date: Fri, 18 Sep 2026 17:57:16 +0800 Subject: [PATCH] fix(windows): mount the drag strip ahead of the app root so no-drag opt-outs win The 36px strip was appended to , after the app root. Electron resolves overlapping app-region rectangles in tree order (drag adds, no-drag subtracts), so the drag rectangle was applied after every no-drag opt-out that came before it -- including this repo's own rule for button/a/input/[role=button]/ [role=tab]/[role=menuitem]/[data-dsh-no-drag]. Controls drawn inside the strip (the conversation header's row 1: breadcrumb, agent-preset chip and every plugin entry registered in conversation.session.header.actions) therefore looked right and never received a click: the point resolved as caption and the renderer never saw a mouse event. Mount the strip as the first child of so the opt-outs that follow it subtract, and keep the marker behind the app content (z-index: -1) so an exempted point reaches the control underneath. The drag rectangle itself is unchanged, so the strip still drags the window wherever nothing opted out. Adds a regression test to test/windows-titlebar.test.ts. --- src/preload/windows-titlebar.ts | 23 +++++++++++++++++++++-- test/windows-titlebar.test.ts | 21 +++++++++++++++++++++ 2 files changed, 42 insertions(+), 2 deletions(-) diff --git a/src/preload/windows-titlebar.ts b/src/preload/windows-titlebar.ts index f707a843d..536f3c99c 100644 --- a/src/preload/windows-titlebar.ts +++ b/src/preload/windows-titlebar.ts @@ -126,7 +126,12 @@ function installLayout(document: Document): void { } #${DRAG_REGION_ID} { position: fixed; - z-index: 10; + /* An app-region marker, not a target: keep it behind the app content, or + it wins the hit test for the very points its own "no-drag" opt-outs + exempt and the clicks never reach the controls underneath. Window + dragging is unaffected -- that comes from the app-region rectangle, + not from hit testing. */ + z-index: -1; top: 0; left: 0; right: calc(var(${CAPTION_WIDTH_PROPERTY}, 140px) + 44px); @@ -144,7 +149,21 @@ function installDragRegion(document: Document): void { const dragRegion = document.createElement('div') dragRegion.id = DRAG_REGION_ID dragRegion.setAttribute('aria-hidden', 'true') - document.body.appendChild(dragRegion) + // Mount the strip AHEAD of the app root, never appended after it. + // + // Electron resolves overlapping app-region rectangles in tree order: every + // `drag` rectangle is added and every `no-drag` rectangle is subtracted as + // the tree is walked. A strip appended after the app root is therefore + // applied after every `no-drag` opt-out that came before it -- including this + // shell's own rule for `button / a / input / [role="button"] / [role="tab"] / + // [role="menuitem"] / [data-dsh-no-drag]` -- and swallows them. A point + // inside the strip then resolves as a caption point, the renderer never sees + // a mouse event, and a control that lives there (the conversation header's + // action row: the agent-preset chip plus every plugin entry registered next + // to it) looks right and never receives a click. Ahead of the app root the + // opt-outs that follow do subtract, which is what the `no-drag` rule above + // exists for. + document.body.insertBefore(dragRegion, document.body.firstChild) // When any modal or dialog is open, hide the drag region completely // so all buttons (especially near the top 36px) are 100% clickable. diff --git a/test/windows-titlebar.test.ts b/test/windows-titlebar.test.ts index 68cbfb085..348d3d6ae 100644 --- a/test/windows-titlebar.test.ts +++ b/test/windows-titlebar.test.ts @@ -180,4 +180,25 @@ describe('Windows titlebar menu', () => { expect(preload).toContain("attributeFilter: ['data-ds-dark-theme', 'class', 'style']") expect(preload).toContain("ipcRenderer.invoke('desktop-titlebar:set-theme', isDark)") }) + + it('mounts the drag strip ahead of the app root and behind the content it covers', async () => { + const preload = await readFile('src/preload/windows-titlebar.ts', 'utf8') + const ruleStart = preload.indexOf('#${DRAG_REGION_ID} {') + const rule = preload.slice(ruleStart, preload.indexOf('\n }', ruleStart)) + + // Electron resolves app-region rectangles in tree order, so the drag + // rectangle has to be mounted before the `no-drag` opt-outs it must yield + // to: appended after the app root it wins over every one of them and the + // controls drawn inside the strip never receive a click. + expect(preload).toContain('document.body.insertBefore(dragRegion, document.body.firstChild)') + expect(preload).not.toContain('document.body.appendChild(dragRegion)') + + // The marker also stays out of the hit-test path it covers, so an exempted + // point reaches the control underneath, while dragging keeps coming from + // the app-region rectangle itself. + expect(ruleStart).toBeGreaterThan(-1) + expect(rule).toContain('position: fixed;') + expect(rule).toContain('z-index: -1;') + expect(rule).toContain('-webkit-app-region: drag;') + }) })