mirror of
https://github.com/stablyai/orca.git
synced 2026-10-30 08:37:49 +00:00
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:
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
}) => {
|
||||
|
||||
Reference in New Issue
Block a user