diff --git a/apps/desktop/src/app/shell/titlebar.test.ts b/apps/desktop/src/app/shell/titlebar.test.ts index 73d7e46e25..3531adb08b 100644 --- a/apps/desktop/src/app/shell/titlebar.test.ts +++ b/apps/desktop/src/app/shell/titlebar.test.ts @@ -4,7 +4,6 @@ import { MACOS_TAHOE_DARWIN_MAJOR, TITLEBAR_CONTROL_OFFSET_X, TITLEBAR_CONTROL_SIZE, - TITLEBAR_DRAG_HANDLE_WIDTH, TITLEBAR_EDGE_INSET, TITLEBAR_FALLBACK_WINDOW_BUTTON_X, TITLEBAR_ICON_SIZE, @@ -23,10 +22,6 @@ describe('titlebar sizing', () => { expect(titlebarIconSizeCss()).toBe('13.9px') }) - it('reserves a fixed native drag target beside titlebar tabs', () => { - expect(TITLEBAR_DRAG_HANDLE_WIDTH).toBe(48) - }) - it('reserves width from abutting hit targets only', () => { expect(titlebarToolsWidthCss(4)).toBe('calc(4 * var(--titlebar-control-size))') }) diff --git a/apps/desktop/src/components/pane-shell/tree/renderer/tree-group.test.tsx b/apps/desktop/src/components/pane-shell/tree/renderer/tree-group.test.tsx index 2bb454aacc..5ba031eea2 100644 --- a/apps/desktop/src/components/pane-shell/tree/renderer/tree-group.test.tsx +++ b/apps/desktop/src/components/pane-shell/tree/renderer/tree-group.test.tsx @@ -59,53 +59,81 @@ afterEach(() => { }) describe('TreeGroup', () => { - it('keeps a fixed native drag handle beside crowded top-edge tabs', () => { + // Titlebar geometry (#112964): tabs sharing the native titlebar band are all + // `no-drag`, so once the list overflows they can cover every draggable pixel. + // A window must keep a drag target that no tab can occupy — a fixed-width + // handle OUTSIDE the scrolling tablist. When the tabs instead drop below the + // window controls, the free band above them stays the (flexible) handle. + describe('top-edge window drag handle', () => { const paneIds = ['terminal', 'terminal-2', 'terminal-3', 'terminal-4', 'terminal-5'] - const disposers = paneIds.map(id => - registry.register({ - area: 'panes', - data: { placement: 'main' }, - id, - render: () =>
{id}
, - title: id + function mountCrowdedStrip(titlebarWidth: number) { + const disposers = paneIds.map(id => + registry.register({ + area: 'panes', + data: { placement: 'main' }, + id, + render: () =>
{id}
, + title: id + }) + ) + + disposePane = () => disposers.forEach(dispose => dispose()) + vi.stubGlobal('CSS', { escape: (value: string) => value }) + vi.stubGlobal( + 'ResizeObserver', + class { + observe() {} + unobserve() {} + disconnect() {} + } + ) + // jsdom has no layout: give usePanelTitlebar real chrome rects so it + // picks the tabs-in-titlebar layout (wide) or below-controls (narrow). + vi.spyOn(HTMLElement.prototype, 'getBoundingClientRect').mockImplementation(function (this: HTMLElement) { + if (this.matches('[data-titlebar-cluster="left"]')) { + return { left: 0, right: 100 } as DOMRect + } + + if (this.matches('[data-titlebar-cluster="right"]')) { + return { left: titlebarWidth - 100, right: titlebarWidth } as DOMRect + } + + return { left: 0, right: titlebarWidth, width: titlebarWidth } as DOMRect }) - ) + const leftControls = globalThis.document.createElement('div') + leftControls.dataset.titlebarCluster = 'left' + const rightControls = globalThis.document.createElement('div') + rightControls.dataset.titlebarCluster = 'right' + globalThis.document.body.append(leftControls, rightControls) - disposePane = () => disposers.forEach(dispose => dispose()) - vi.stubGlobal('CSS', { escape: (value: string) => value }) - vi.stubGlobal( - 'ResizeObserver', - class { - observe() {} - unobserve() {} - disconnect() {} - } - ) - vi.spyOn(HTMLElement.prototype, 'getBoundingClientRect').mockImplementation(function () { - if (this.matches('[data-titlebar-cluster="left"]')) { - return { left: 0, right: 100 } as DOMRect - } + render() - if (this.matches('[data-titlebar-cluster="right"]')) { - return { left: 700, right: 800 } as DOMRect - } + const zone = container!.querySelector('[data-tree-group]')! + expect(zone.querySelectorAll('[data-tree-tab]').length).toBe(paneIds.length) - return { left: 0, right: 800, width: 800 } as DOMRect + return { + handles: [...zone.querySelectorAll('[data-window-drag-handle]')], + strip: zone.querySelector('[data-zone-tabstrip]')! + } + } + + it('keeps a fixed drag handle outside the tablist when tabs share the titlebar', () => { + const { handles, strip } = mountCrowdedStrip(800) + const fixed = handles.filter(handle => !handle.closest('[role="tablist"]') && handle.style.width !== '') + + expect(fixed.length).toBeGreaterThan(0) + expect(fixed[0]!.className).toContain('shrink-0') + // The strip itself still spans the band, so the handle is ADDITIONAL to it. + expect(strip.className).toContain('flex-1') }) - const leftControls = globalThis.document.createElement('div') - leftControls.dataset.titlebarCluster = 'left' - const rightControls = globalThis.document.createElement('div') - rightControls.dataset.titlebarCluster = 'right' - globalThis.document.body.append(leftControls, rightControls) - render() + it('leaves the whole free band draggable when tabs drop below the controls', () => { + const { handles, strip } = mountCrowdedStrip(300) - const handle = container!.querySelector('[data-window-drag-handle]')! - expect(container!.querySelectorAll('[data-tree-tab]').length).toBe(paneIds.length) - expect(handle).toBeTruthy() - expect(handle.style.width).not.toBe('') - expect(handle.className).toContain('shrink-0') + expect(strip.className).toContain('bottom-0') + expect(handles.some(handle => handle.className.includes('flex-1') && handle.style.width === '')).toBe(true) + }) }) it('keeps a top-edge strip inside its panel and yields native drag while moving a pane', () => { diff --git a/apps/desktop/src/components/pane-shell/tree/renderer/tree-group.tsx b/apps/desktop/src/components/pane-shell/tree/renderer/tree-group.tsx index 43e8811e13..0fe36b7dc0 100644 --- a/apps/desktop/src/components/pane-shell/tree/renderer/tree-group.tsx +++ b/apps/desktop/src/components/pane-shell/tree/renderer/tree-group.tsx @@ -696,17 +696,22 @@ export function TreeGroup({ ) : null} + {/* Tabs sharing the titlebar band are all `no-drag` and the strip's + list scrolls, so a crowded strip can cover every draggable pixel + (#112964). Keep one fixed handle OUTSIDE the list. When the tabs + drop below the controls the band above them is free — the handle + stays flexible and the whole row moves the window. */} {topEdge && (