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 && (
)}