Merge pull request #73041 from NousResearch/bb/tab-strip-scroll-into-view

fix(desktop): keep the active tab and the "+" in view when the tab strip scrolls
This commit is contained in:
brooklyn! 2026-07-27 21:04:26 -05:00 committed by GitHub
commit 7100e8d539
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
3 changed files with 149 additions and 0 deletions

View file

@ -0,0 +1,38 @@
import { describe, expect, it } from 'vitest'
import { tabStripScrollLeft } from './tab-strip-scroll'
describe('tabStripScrollLeft', () => {
// 300px window over 900px of tabs + "+" → 600px of scroll range.
const strip = (over: Partial<Parameters<typeof tabStripScrollLeft>[0]> = {}) =>
tabStripScrollLeft({
clientWidth: 300,
last: false,
scrollLeft: 0,
scrollWidth: 900,
tabEnd: 0,
tabStart: 0,
...over
})
it('scrolls to the end for the last tab so the "+" comes with it', () => {
expect(strip({ last: true, scrollLeft: 0, tabEnd: 880, tabStart: 780 })).toBe(600)
})
it('leaves the offset alone when the tab is already fully visible', () => {
expect(strip({ scrollLeft: 200, tabEnd: 400, tabStart: 300 })).toBe(200)
})
it('scrolls left to the tab start when it sits before the window', () => {
expect(strip({ scrollLeft: 400, tabEnd: 200, tabStart: 100 })).toBe(100)
})
it('scrolls right just far enough to reveal a tab past the window', () => {
expect(strip({ scrollLeft: 0, tabEnd: 450, tabStart: 350 })).toBe(150)
})
it('never exceeds the scrollable range', () => {
expect(strip({ last: true, scrollWidth: 250 })).toBe(0)
expect(strip({ scrollLeft: 0, tabEnd: 1200, tabStart: 1100 })).toBe(600)
})
})

View file

@ -0,0 +1,97 @@
/**
* Keeping the active tab (and the trailing "+") inside a scrolling tab strip.
*
* Once a zone has more tabs than fit, opening a new one appended it past the
* right edge: the tab you just created and the "+" that created it were
* both off-screen, so a second new tab meant scrolling back by hand first.
* Activating a tab from a keybind or the session list had the same problem in
* the other direction.
*/
import { type RefObject, useLayoutEffect } from 'react'
export interface TabStripGeometry {
/** Visible width of the strip. */
clientWidth: number
/** Active tab is the LAST one, so reveal the trailing "+" along with it. */
last: boolean
scrollLeft: number
/** Full scroll content: every tab plus the trailing "+". */
scrollWidth: number
/** Active tab's edges, measured from the start of the scroll content. */
tabEnd: number
tabStart: number
}
/** Where the strip should be scrolled to for the active tab to be in view
* the current offset when it already is (the caller skips the write). */
export function tabStripScrollLeft({
clientWidth,
last,
scrollLeft,
scrollWidth,
tabEnd,
tabStart
}: TabStripGeometry): number {
const max = Math.max(0, scrollWidth - clientWidth)
// The last tab scrolls to the very end rather than to its own edge: the "+"
// lives after it in the same scroll content and has to come along.
if (last) {
return max
}
if (tabStart < scrollLeft) {
return Math.min(tabStart, max)
}
if (tabEnd > scrollLeft + clientWidth) {
return Math.min(tabEnd - clientWidth, max)
}
return Math.min(scrollLeft, max)
}
/** Scroll `activeId`'s tab into view whenever the activation or the tab set
* changes. `last` drives the "+"-follows-the-final-tab case. */
export function useActiveTabVisible(
scrollerRef: RefObject<HTMLDivElement | null>,
activeId: string,
{ enabled, last, tabCount }: { enabled: boolean; last: boolean; tabCount: number }
): void {
// Layout effect: the scroll write lands in the same frame as the tab's
// insertion, so a new tab never paints off-screen first.
useLayoutEffect(() => {
const scroller = scrollerRef.current
if (!enabled || !scroller) {
return
}
const tab = scroller.querySelector<HTMLElement>(`[data-tree-tab="${CSS.escape(activeId)}"]`)
if (!tab) {
return
}
// Rects, not offsetLeft: the tab's offsetParent is the header strip (the
// scroller itself isn't positioned), so offsets would be measured against
// the wrong origin.
const view = scroller.getBoundingClientRect()
const rect = tab.getBoundingClientRect()
const tabStart = rect.left - view.left + scroller.scrollLeft
const next = tabStripScrollLeft({
clientWidth: scroller.clientWidth,
last,
scrollLeft: scroller.scrollLeft,
scrollWidth: scroller.scrollWidth,
tabEnd: tabStart + rect.width,
tabStart
})
if (next !== scroller.scrollLeft) {
scroller.scrollLeft = next
}
}, [activeId, enabled, last, scrollerRef, tabCount])
}

View file

@ -49,6 +49,7 @@ import {
import { type DoubleTapContext, startPaneDrag } from './drag-session'
import { forceLoneHeaderForPanes } from './lone-header'
import { useActiveTabVisible } from './tab-strip-scroll'
import { paneChrome } from './track-model'
/** A directional action in the zone menu (computed per group state). */
@ -144,6 +145,9 @@ export function TreeGroup({
const { t } = useI18n()
const ref = useRef<HTMLDivElement>(null)
const stripRef = useRef<HTMLDivElement>(null)
// The scrolling tab list inside the header (the strip also holds the
// minimize chevron, which must not scroll away).
const tabsRef = useRef<HTMLDivElement>(null)
// The chip under the last right-click — the pane the zone menu's Split
// actions carry into the new zone (header background = the active pane).
// STATE, not a ref: the menu items (incl. Close's visibility) are JSX
@ -229,6 +233,15 @@ export function TreeGroup({
const verticalCollapse = Boolean(node.minimized) && parentAxis === 'row' && !isEmpty
const headerVisible = !isEmpty && !verticalCollapse && (Boolean(node.minimized) || !headerHidden)
// Keep the activated tab — and, on the last one, the trailing "+" — inside
// the strip's scroll window. Opening a tab past the right edge otherwise
// left both the new tab and the button that made it out of view.
useActiveTabVisible(tabsRef, activeId, {
enabled: headerVisible,
last: shown[shown.length - 1] === activeId,
tabCount: shown.length
})
// Drag handles preventDefault pointerdown (no native dblclick), so the
// header + chips share a synthesized double-tap: restore if collapsed
// (undoing the first tap's minimize toggle) and hide the chrome.
@ -426,6 +439,7 @@ export function TreeGroup({
>
<div
className="flex min-w-0 flex-1 overflow-x-auto overflow-y-hidden [-ms-overflow-style:none] [scrollbar-width:none] [&::-webkit-scrollbar]:hidden"
ref={tabsRef}
role="tablist"
>
{shown.map(paneId => {