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 <m4air@Mac.localdomain>
This commit is contained in:
OrcaWin
2026-10-28 13:51:55 -07:00
committed by GitHub
co-authored by m4air
parent 6e2b245ac6
commit dd0c4cf35e
5 changed files with 125 additions and 3 deletions
+5
View File
@@ -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: '';
@@ -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<Record<string, unknown>> {
const actual = await vi.importActual<typeof ReactModule>('react')
const contextDefaults = new WeakMap<object, unknown>()
return {
...actual,
memo: <T>(component: T) => component,
createContext: <T>(defaultValue: T) => {
const context = actual.createContext(defaultValue)
contextDefaults.set(context, defaultValue)
return context
},
useContext: (context: ReactModule.Context<unknown>) => contextDefaults.get(context),
useEffect: () => {},
useLayoutEffect: () => {},
useCallback: <T extends (...args: never[]) => unknown>(callback: T) => callback,
@@ -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<HTMLDivElement>(null)
const stripResizeListenersRef = useRef<Set<() => void>>(new Set())
const prevStripRef = useRef<{ worktreeId: string; tabIds: ReadonlySet<string> } | 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(() => {
@@ -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<HTMLElement>(`:scope > ${DOCK_SELECTOR}`)
return strip.hasAttribute(DOCK_DISABLED_ATTRIBUTE)
? null
: strip.querySelector<HTMLElement>(`:scope > ${DOCK_SELECTOR}`)
}
export function updateTabStripDockAvailability(strip: HTMLElement): void {
const dock = strip.querySelector<HTMLElement>(`: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 {