fix(desktop): scroll the tab strip so a new tab and the "+" stay in view

Opening a tab in a zone with more tabs than fit appended it past the right
edge of the scrolling strip — the new tab and the "+" that created it were
both off-screen, so a second new tab meant scrolling back by hand. Activating
a tab from a keybind had the same problem in the other direction.

The zone header now scrolls its active tab into view on activation, and
scrolls all the way to the end when that tab is the last one so the trailing
"+" comes with it.
This commit is contained in:
Brooklyn Nicholson 2026-07-27 20:57:32 -05:00
parent 71e7eb3c16
commit dfe3a23a60
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 => {