From dd0c4cf35e754ffaeebe59b9979347d22988b2cb Mon Sep 17 00:00:00 2001 From: OrcaWin Date: Wed, 28 Oct 2026 13:51:55 -0700 Subject: [PATCH] Keep inactive tabs clickable in narrow docked strips (#34881) * Keep inactive tabs clickable when active dock crowds the strip * Fix narrow tab dock state after drag cancellation --------- Co-authored-by: m4air --- src/renderer/src/assets/main.css | 5 + ...b-bar-windows-shell-launch-render-stubs.ts | 7 ++ .../tab-bar/tab-strip-overflow-navigation.ts | 8 +- .../tab-bar/tab-strip-slot-geometry.ts | 14 ++- tests/e2e/tab-strip-active-dock.spec.ts | 94 +++++++++++++++++++ 5 files changed, 125 insertions(+), 3 deletions(-) diff --git a/src/renderer/src/assets/main.css b/src/renderer/src/assets/main.css index 09ec5cdc5ac..0e3ca501991 100644 --- a/src/renderer/src/assets/main.css +++ b/src/renderer/src/assets/main.css @@ -838,6 +838,11 @@ body[data-modal-overlay-open] mask-image: linear-gradient(to right, #000 0, #000 calc(100% - 1.5rem), transparent); } +.terminal-tab-strip[data-active-tab-dock-disabled] > [data-active-tab-dock] { + position: relative; + z-index: auto; +} + /* Why: tabs only draw a right border, so a tab docked at the end has no seam against the tab it covers. */ .terminal-tab-strip[data-active-tab-docked='end'] > [data-active-tab-dock]::before { content: ''; diff --git a/src/renderer/src/components/tab-bar/tab-bar-windows-shell-launch-render-stubs.ts b/src/renderer/src/components/tab-bar/tab-bar-windows-shell-launch-render-stubs.ts index 28c2ab732c9..e4266320e0a 100644 --- a/src/renderer/src/components/tab-bar/tab-bar-windows-shell-launch-render-stubs.ts +++ b/src/renderer/src/components/tab-bar/tab-bar-windows-shell-launch-render-stubs.ts @@ -4,9 +4,16 @@ import { vi } from 'vitest' // Headless React: hooks are stubbed so TabBar can be invoked as a plain function. export async function stubHeadlessReact(): Promise> { const actual = await vi.importActual('react') + const contextDefaults = new WeakMap() return { ...actual, memo: (component: T) => component, + createContext: (defaultValue: T) => { + const context = actual.createContext(defaultValue) + contextDefaults.set(context, defaultValue) + return context + }, + useContext: (context: ReactModule.Context) => contextDefaults.get(context), useEffect: () => {}, useLayoutEffect: () => {}, useCallback: unknown>(callback: T) => callback, diff --git a/src/renderer/src/components/tab-bar/tab-strip-overflow-navigation.ts b/src/renderer/src/components/tab-bar/tab-strip-overflow-navigation.ts index 6853c425e90..0aee4d83b31 100644 --- a/src/renderer/src/components/tab-bar/tab-strip-overflow-navigation.ts +++ b/src/renderer/src/components/tab-bar/tab-strip-overflow-navigation.ts @@ -18,9 +18,11 @@ import { getActiveTabDockSide, readTabStripSlotIds, revealTabStripSlot, + updateTabStripDockAvailability, type ActiveTabDockSide } from './tab-strip-slot-geometry' import { motionSafeScrollBehavior } from '@/hooks/usePrefersReducedMotion' +import { useTabDragActive } from '../tab-group/tab-drag-context' const TAB_STRIP_SCROLL_FRACTION = 0.75 const TAB_STRIP_MIN_SCROLL_STEP_PX = 120 @@ -69,6 +71,7 @@ export function useTabStripOverflowNavigation({ scrollTabStrip: (direction: 'start' | 'end', behavior?: ScrollBehavior) => void subscribeToStripResize: (listener: () => void) => () => void } { + const isTabDragActive = useTabDragActive() const tabStripRef = useRef(null) const stripResizeListenersRef = useRef void>>(new Set()) const prevStripRef = useRef<{ worktreeId: string; tabIds: ReadonlySet } | null>(null) @@ -94,6 +97,7 @@ export function useTabStripOverflowNavigation({ setTabStripOverflowState((previous) => sameTabStripOverflowState(previous, next) ? previous : next ) + updateTabStripDockAvailability(el) setActiveTabDockSide(getActiveTabDockSide(el)) }, []) const scrollTabStrip = useCallback( @@ -291,10 +295,10 @@ export function useTabStripOverflowNavigation({ recordScrollAnchor() }, [activeVisibleTabId, recordScrollAnchor, updateTabStripOverflowState]) - // Why: moving the dock between slots shifts which edge it is drawn at with no scroll or resize. + // Dock ownership and drag transitions change sticky layout without scrolling or resizing. useLayoutEffect(() => { updateTabStripOverflowState() - }, [activeDockSlotId, updateTabStripOverflowState]) + }, [activeDockSlotId, isTabDragActive, updateTabStripOverflowState]) // Why every render: the close flag belongs to the commit that set it, not a later tab switch. useLayoutEffect(() => { diff --git a/src/renderer/src/components/tab-bar/tab-strip-slot-geometry.ts b/src/renderer/src/components/tab-bar/tab-strip-slot-geometry.ts index 6a87353cd43..2717849fc5c 100644 --- a/src/renderer/src/components/tab-bar/tab-strip-slot-geometry.ts +++ b/src/renderer/src/components/tab-bar/tab-strip-slot-geometry.ts @@ -2,6 +2,7 @@ const SLOT_SELECTOR = '[data-tab-strip-slot]' /** Marks the slot of the tab that renders active; it is sticky, so it docks to the edge it would scroll past. */ const DOCK_SELECTOR = '[data-active-tab-dock]' +const DOCK_DISABLED_ATTRIBUTE = 'data-active-tab-dock-disabled' export type ActiveTabDockSide = 'start' | 'end' @@ -14,7 +15,18 @@ function getSlotId(slot: HTMLElement): string { } function getActiveTabDock(strip: HTMLElement): HTMLElement | null { - return strip.querySelector(`:scope > ${DOCK_SELECTOR}`) + return strip.hasAttribute(DOCK_DISABLED_ATTRIBUTE) + ? null + : strip.querySelector(`:scope > ${DOCK_SELECTOR}`) +} + +export function updateTabStripDockAvailability(strip: HTMLElement): void { + const dock = strip.querySelector(`:scope > ${DOCK_SELECTOR}`) + // Leave room for another tab beside the dock, or inactive tabs become unreachable. + strip.toggleAttribute( + DOCK_DISABLED_ATTRIBUTE, + dock !== null && strip.clientWidth < dock.getBoundingClientRect().width * 2 + ) } export function findTabStripSlot(strip: HTMLElement, tabId: string): HTMLElement | undefined { diff --git a/tests/e2e/tab-strip-active-dock.spec.ts b/tests/e2e/tab-strip-active-dock.spec.ts index c4c521cac7d..db72b0c2afe 100644 --- a/tests/e2e/tab-strip-active-dock.spec.ts +++ b/tests/e2e/tab-strip-active-dock.spec.ts @@ -74,6 +74,100 @@ test.describe('Tab strip active-tab dock', () => { await ensureTerminalVisible(orcaPage) }) + for (const edge of ['start', 'end'] as const) { + test(`keeps inactive tabs clickable in a strip too narrow for the ${edge} dock`, async ({ + orcaPage + }) => { + const worktreeId = await waitForActiveWorktree(orcaPage) + for (let i = 0; i < 3; i++) { + await createBackgroundTerminalTab(orcaPage, worktreeId) + } + const ids = await readStripSlotIds(orcaPage) + const activeTabId = (edge === 'start' ? ids.at(0) : ids.at(-1))! + const inactiveTabId = (edge === 'start' ? ids.at(-1) : ids.at(0))! + const activeTab = orcaPage.locator( + `${slotSelector(activeTabId)} [data-testid="sortable-tab"]` + ) + const inactiveTab = orcaPage.locator( + `${slotSelector(inactiveTabId)} [data-testid="sortable-tab"]` + ) + await activeTab.click() + + const strip = orcaPage.locator(STRIP).first() + await strip.evaluate((el) => { + el.style.maxWidth = '100px' + }) + await expect(strip).toHaveAttribute('data-active-tab-dock-disabled', '') + await strip.evaluate((el, side) => { + el.scrollLeft = side === 'start' ? el.scrollWidth : 0 + }, edge) + await expect.poll(() => isSlotInView(orcaPage, inactiveTabId)).toBe(true) + await inactiveTab.click() + await expect(inactiveTab).toHaveAttribute('data-active', 'true') + await activeTab.locator('[data-tab-close-button]').click() + await expect(activeTab).toHaveCount(0) + + await strip.evaluate((el) => { + el.style.maxWidth = '200px' + }) + await expect(strip).not.toHaveAttribute('data-active-tab-dock-disabled') + await strip.evaluate((el, side) => { + el.scrollLeft = side === 'start' ? 0 : el.scrollWidth + }, edge) + await expect(strip).toHaveAttribute( + 'data-active-tab-docked', + edge === 'start' ? 'end' : 'start' + ) + expect(await isSlotInView(orcaPage, inactiveTabId)).toBe(true) + }) + } + + test('keeps inactive tabs clickable after cancelling a drag that scrolled a narrow strip', async ({ + orcaPage + }) => { + const worktreeId = await waitForActiveWorktree(orcaPage) + for (let i = 0; i < 3; i++) { + await createBackgroundTerminalTab(orcaPage, worktreeId) + } + const ids = await readStripSlotIds(orcaPage) + const activeSlot = orcaPage.locator(slotSelector(ids[0])) + const activeTab = activeSlot.locator('[data-testid="sortable-tab"]') + const inactiveTab = orcaPage.locator(`${slotSelector(ids[1])} [data-testid="sortable-tab"]`) + await activeTab.click() + + const strip = orcaPage.locator(STRIP).first() + await strip.evaluate((el) => { + el.style.maxWidth = '100px' + }) + await expect(strip).toHaveAttribute('data-active-tab-dock-disabled', '') + await strip.evaluate((el) => { + el.scrollLeft = 0 + }) + await expect.poll(() => isSlotInView(orcaPage, ids[0])).toBe(true) + + const box = (await activeTab.boundingBox())! + const y = box.y + box.height / 2 + await orcaPage.mouse.move(box.x + 20, y) + await orcaPage.mouse.down() + await orcaPage.mouse.move(box.x + 34, y) + await orcaPage.mouse.move(box.x + 38, y) + await expect(strip.locator('[data-active-tab-dock]')).toHaveCount(0) + + // Hidden-window drag overlays can intercept native wheel input before it reaches the strip. + await strip.evaluate((el) => { + el.dispatchEvent(new WheelEvent('wheel', { deltaY: 72, cancelable: true })) + }) + await expect.poll(() => strip.evaluate((el) => el.scrollLeft)).toBe(72) + await nextFrames(orcaPage, 2) + await orcaPage.evaluate(() => window.dispatchEvent(new Event('blur'))) + await orcaPage.mouse.up() + + await expect(activeSlot).toHaveAttribute('data-active-tab-dock', '') + await expect(strip).toHaveAttribute('data-active-tab-dock-disabled', '') + await inactiveTab.click() + await expect(inactiveTab).toHaveAttribute('data-active', 'true') + }) + test('docks an offscreen active tab and reveals an arriving tab beside it without flicker', async ({ orcaPage }) => {