diff --git a/src/renderer/src/components/tab-bar/tab-bar-surface.tsx b/src/renderer/src/components/tab-bar/tab-bar-surface.tsx
index 6e1c49ddc94..c37597e2932 100644
--- a/src/renderer/src/components/tab-bar/tab-bar-surface.tsx
+++ b/src/renderer/src/components/tab-bar/tab-bar-surface.tsx
@@ -163,6 +163,8 @@ export function renderTabBarSurface({
.join(' ')}
>
{renderedItems}
+ {/* Why: a short end inset keeps a last tab's close control off the chevron and fade. */}
+
diff --git a/src/renderer/src/components/tab-bar/tab-strip-active-tab-scroll.test.ts b/src/renderer/src/components/tab-bar/tab-strip-active-tab-scroll.test.ts
new file mode 100644
index 00000000000..28745773ad6
--- /dev/null
+++ b/src/renderer/src/components/tab-bar/tab-strip-active-tab-scroll.test.ts
@@ -0,0 +1,169 @@
+// @vitest-environment happy-dom
+import { describe, expect, it } from 'vitest'
+import {
+ computeTabStripEndInset,
+ computeTabStripScrollLeft,
+ findLastTabStripTab,
+ isLastTabStripTab,
+ syncTabStripEndPad,
+ TAB_STRIP_REVEAL_INSET_PX
+} from './tab-strip-active-tab-scroll'
+
+function mockBox(
+ el: HTMLElement,
+ box: {
+ offsetLeft?: number
+ offsetWidth?: number
+ clientWidth?: number
+ scrollWidth?: number
+ }
+): void {
+ Object.defineProperties(el, {
+ offsetLeft: { configurable: true, value: box.offsetLeft ?? 0 },
+ offsetWidth: { configurable: true, value: box.offsetWidth ?? 0 },
+ clientWidth: { configurable: true, value: box.clientWidth ?? 0 },
+ scrollWidth: { configurable: true, value: box.scrollWidth ?? 0 }
+ })
+}
+
+describe('computeTabStripEndInset', () => {
+ it('is zero when tabs already fit', () => {
+ expect(
+ computeTabStripEndInset({
+ stripClientWidth: 400,
+ lastTabWidth: 180,
+ contentWidth: 360
+ })
+ ).toBe(0)
+ })
+
+ it('uses the fade-sized inset when the last tab overflows', () => {
+ expect(
+ computeTabStripEndInset({
+ stripClientWidth: 400,
+ lastTabWidth: 180,
+ contentWidth: 900
+ })
+ ).toBe(TAB_STRIP_REVEAL_INSET_PX)
+ })
+
+ it('shrinks the inset when the last tab almost fills the strip', () => {
+ expect(
+ computeTabStripEndInset({
+ stripClientWidth: 200,
+ lastTabWidth: 190,
+ contentWidth: 400
+ })
+ ).toBe(10)
+ })
+
+ it('is zero when the last tab is at least as wide as the strip', () => {
+ expect(
+ computeTabStripEndInset({
+ stripClientWidth: 180,
+ lastTabWidth: 180,
+ contentWidth: 400
+ })
+ ).toBe(0)
+ })
+})
+
+describe('computeTabStripScrollLeft', () => {
+ it('centers a last tab when trailing pad makes that scroll reachable', () => {
+ expect(
+ computeTabStripScrollLeft({
+ stripScrollWidth: 1010,
+ stripClientWidth: 400,
+ stripScrollLeft: 0,
+ tabOffsetLeft: 720,
+ tabWidth: 180,
+ inline: 'center'
+ })
+ ).toBe(610)
+ })
+
+ it('clamps a last-tab center to the end when there is no trailing pad', () => {
+ expect(
+ computeTabStripScrollLeft({
+ stripScrollWidth: 900,
+ stripClientWidth: 400,
+ stripScrollLeft: 0,
+ tabOffsetLeft: 720,
+ tabWidth: 180,
+ inline: 'center'
+ })
+ ).toBe(500)
+ })
+
+ it('keeps a fully visible middle tab in place', () => {
+ expect(
+ computeTabStripScrollLeft({
+ stripScrollWidth: 900,
+ stripClientWidth: 400,
+ stripScrollLeft: 200,
+ tabOffsetLeft: 280,
+ tabWidth: 120,
+ inline: 'nearest'
+ })
+ ).toBe(200)
+ })
+
+ it('scrolls far enough that a clipped trailing tab clears the fade inset', () => {
+ expect(
+ computeTabStripScrollLeft({
+ stripScrollWidth: 900,
+ stripClientWidth: 400,
+ stripScrollLeft: 0,
+ tabOffsetLeft: 320,
+ tabWidth: 180,
+ inline: 'nearest'
+ })
+ ).toBe(320 + 180 - 400 + TAB_STRIP_REVEAL_INSET_PX)
+ })
+})
+
+describe('tab strip last-tab helpers', () => {
+ it('treats the last data-tab-id node as the last tab and ignores the end pad', () => {
+ const strip = document.createElement('div')
+ const first = document.createElement('div')
+ first.setAttribute('data-tab-id', 'one')
+ const last = document.createElement('div')
+ last.setAttribute('data-tab-id', 'two')
+ const pad = document.createElement('div')
+ pad.setAttribute('data-tab-strip-end-pad', '')
+ strip.append(first, last, pad)
+
+ expect(findLastTabStripTab(strip)).toBe(last)
+ expect(isLastTabStripTab(strip, last)).toBe(true)
+ expect(isLastTabStripTab(strip, first)).toBe(false)
+ })
+
+ it('sizes the end pad to the fade inset when the last tab overflows', () => {
+ const strip = document.createElement('div')
+ const last = document.createElement('div')
+ last.setAttribute('data-tab-id', 'end')
+ const pad = document.createElement('div')
+ pad.setAttribute('data-tab-strip-end-pad', '')
+ strip.append(last, pad)
+ mockBox(strip, { clientWidth: 400 })
+ mockBox(last, { offsetLeft: 720, offsetWidth: 180 })
+
+ expect(syncTabStripEndPad(strip)).toBe(TAB_STRIP_REVEAL_INSET_PX)
+ expect(pad.style.width).toBe(`${TAB_STRIP_REVEAL_INSET_PX}px`)
+ })
+
+ it('clears the end pad when every tab already fits', () => {
+ const strip = document.createElement('div')
+ const last = document.createElement('div')
+ last.setAttribute('data-tab-id', 'end')
+ const pad = document.createElement('div')
+ pad.setAttribute('data-tab-strip-end-pad', '')
+ pad.style.width = '110px'
+ strip.append(last, pad)
+ mockBox(strip, { clientWidth: 400 })
+ mockBox(last, { offsetLeft: 0, offsetWidth: 180 })
+
+ expect(syncTabStripEndPad(strip)).toBe(0)
+ expect(pad.style.width).toBe('0px')
+ })
+})
diff --git a/src/renderer/src/components/tab-bar/tab-strip-active-tab-scroll.ts b/src/renderer/src/components/tab-bar/tab-strip-active-tab-scroll.ts
new file mode 100644
index 00000000000..e19012ff962
--- /dev/null
+++ b/src/renderer/src/components/tab-bar/tab-strip-active-tab-scroll.ts
@@ -0,0 +1,100 @@
+export const TAB_STRIP_TAB_ID_SELECTOR = '[data-tab-id]'
+export const TAB_STRIP_END_PAD_ATTR = 'data-tab-strip-end-pad'
+
+// Why: matches the 1.5rem fade mask so nearest-reveal keeps the close control out of the fade.
+export const TAB_STRIP_REVEAL_INSET_PX = 24
+
+export function findLastTabStripTab(strip: ParentNode): HTMLElement | null {
+ const tabs = strip.querySelectorAll(TAB_STRIP_TAB_ID_SELECTOR)
+ return [...tabs].at(-1) ?? null
+}
+
+export function isLastTabStripTab(strip: ParentNode, tab: Element): boolean {
+ return findLastTabStripTab(strip) === tab
+}
+
+export function computeTabStripEndInset({
+ stripClientWidth,
+ lastTabWidth,
+ contentWidth
+}: {
+ stripClientWidth: number
+ lastTabWidth: number
+ contentWidth: number
+}): number {
+ if (stripClientWidth <= 0 || lastTabWidth <= 0 || contentWidth <= stripClientWidth) {
+ return 0
+ }
+ // Why: a half-viewport pad centered the last tab but hid earlier tabs and
+ // created a drop dead-zone; a fade-sized inset keeps the close control clear.
+ return Math.min(TAB_STRIP_REVEAL_INSET_PX, Math.max(0, stripClientWidth - lastTabWidth))
+}
+
+export function computeTabStripScrollLeft({
+ stripScrollWidth,
+ stripClientWidth,
+ stripScrollLeft,
+ tabOffsetLeft,
+ tabWidth,
+ inline
+}: {
+ stripScrollWidth: number
+ stripClientWidth: number
+ stripScrollLeft: number
+ tabOffsetLeft: number
+ tabWidth: number
+ inline: 'nearest' | 'center'
+}): number {
+ const maxScrollLeft = Math.max(0, stripScrollWidth - stripClientWidth)
+ if (inline === 'center') {
+ return clamp(tabOffsetLeft + tabWidth / 2 - stripClientWidth / 2, 0, maxScrollLeft)
+ }
+
+ const tabStart = tabOffsetLeft
+ const tabEnd = tabOffsetLeft + tabWidth
+ const viewStart = stripScrollLeft + TAB_STRIP_REVEAL_INSET_PX
+ const viewEnd = stripScrollLeft + stripClientWidth - TAB_STRIP_REVEAL_INSET_PX
+ if (tabStart >= viewStart && tabEnd <= viewEnd) {
+ return stripScrollLeft
+ }
+ if (tabEnd > viewEnd) {
+ return clamp(tabEnd - stripClientWidth + TAB_STRIP_REVEAL_INSET_PX, 0, maxScrollLeft)
+ }
+ return clamp(tabStart - TAB_STRIP_REVEAL_INSET_PX, 0, maxScrollLeft)
+}
+
+export function syncTabStripEndPad(strip: HTMLElement): number {
+ const lastTab = findLastTabStripTab(strip)
+ const pad = computeTabStripEndInset({
+ stripClientWidth: strip.clientWidth,
+ lastTabWidth: lastTab?.offsetWidth ?? 0,
+ contentWidth: lastTab ? lastTab.offsetLeft + lastTab.offsetWidth : 0
+ })
+ const spacer = strip.querySelector(`[${TAB_STRIP_END_PAD_ATTR}]`)
+ if (spacer) {
+ const nextWidth = `${pad}px`
+ if (spacer.style.width !== nextWidth) {
+ spacer.style.width = nextWidth
+ }
+ }
+ return pad
+}
+
+export function scrollTabStripTabIntoView(
+ strip: HTMLElement,
+ tab: HTMLElement,
+ inline: 'nearest' | 'center'
+): void {
+ strip.scrollLeft = computeTabStripScrollLeft({
+ stripScrollWidth: strip.scrollWidth,
+ stripClientWidth: strip.clientWidth,
+ stripScrollLeft: strip.scrollLeft,
+ tabOffsetLeft: tab.offsetLeft,
+ tabWidth: tab.offsetWidth,
+ inline
+ })
+}
+
+function clamp(value: number, min: number, max: number): number {
+ return Math.min(max, Math.max(min, value))
+}
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 77af550b748..682b8434d04 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
@@ -6,6 +6,11 @@ import {
type TabStripScrollMetrics
} from './tab-strip-scroll-metrics'
import { isTabStripPointerGestureActive } from './tab-strip-pointer-gesture'
+import {
+ findLastTabStripTab,
+ scrollTabStripTabIntoView,
+ syncTabStripEndPad
+} from './tab-strip-active-tab-scroll'
const TAB_STRIP_SCROLL_FRACTION = 0.75
const TAB_STRIP_MIN_SCROLL_STEP_PX = 120
@@ -51,6 +56,8 @@ export function useTabStripOverflowNavigation({
const tabStripRef = useRef(null)
const prevStripLenRef = useRef<{ worktreeId: string; len: number } | null>(null)
const stickToEndRef = useRef(false)
+ const activeVisibleTabIdRef = useRef(activeVisibleTabId)
+ activeVisibleTabIdRef.current = activeVisibleTabId
const [tabStripOverflowState, setTabStripOverflowState] = useState(
EMPTY_TAB_STRIP_OVERFLOW_STATE
)
@@ -109,6 +116,7 @@ export function useTabStripOverflowNavigation({
onScroll()
const handleStripResize = (): void => {
+ syncTabStripEndPad(el)
updateTabStripOverflowState()
// If the user is pinned to the right edge, keep it pinned even as tab
// labels (e.g. "Terminal 5" -> branch name) expand and change scrollWidth.
@@ -138,6 +146,7 @@ export function useTabStripOverflowNavigation({
}
if (!prev || prev.worktreeId !== worktreeId) {
prevStripLenRef.current = { worktreeId, len: tabCount }
+ syncTabStripEndPad(strip)
updateTabStripOverflowState()
return
}
@@ -148,6 +157,7 @@ export function useTabStripOverflowNavigation({
if (!el) {
return
}
+ syncTabStripEndPad(el)
el.scrollLeft = Math.max(0, el.scrollWidth - el.clientWidth)
updateTabStripOverflowState()
}
@@ -155,17 +165,28 @@ export function useTabStripOverflowNavigation({
requestAnimationFrame(scrollToEnd)
}
if (tabCount > prev.len && !pointerGestureActive) {
- const scrollToEnd = (): void => {
+ const revealNewEndTab = (): void => {
const el = tabStripRef.current
if (!el) {
return
}
- el.scrollLeft = Math.max(0, el.scrollWidth - el.clientWidth)
- stickToEndRef.current = true
+ syncTabStripEndPad(el)
+ const lastTab = findLastTabStripTab(el)
+ const activeId = activeVisibleTabIdRef.current
+ const activeTab =
+ activeId == null
+ ? null
+ : el.querySelector(`[data-tab-id="${CSS.escape(activeId)}"]`)
+ // Why: only a newly opened last tab should pin to the end; a restored
+ // mid-strip tab must not yank the viewport to the last tab.
+ if (lastTab && (activeTab == null || lastTab === activeTab)) {
+ scrollTabStripTabIntoView(el, lastTab, 'center')
+ stickToEndRef.current = true
+ }
updateTabStripOverflowState()
}
- scrollToEnd()
- requestAnimationFrame(scrollToEnd)
+ revealNewEndTab()
+ requestAnimationFrame(revealNewEndTab)
}
prevStripLenRef.current = { worktreeId, len: tabCount }
updateTabStripOverflowState()
@@ -189,7 +210,8 @@ export function useTabStripOverflowNavigation({
requestAnimationFrame(updateTabStripOverflowState)
return
}
- activeTab.scrollIntoView({ block: 'nearest', inline: 'nearest' })
+ syncTabStripEndPad(strip)
+ scrollTabStripTabIntoView(strip, activeTab, 'nearest')
requestAnimationFrame(updateTabStripOverflowState)
}, [activeVisibleTabId, updateTabStripOverflowState])