Fit file and browser tabs to their content (#39328)

* Fit file and browser tabs to their content

Keep terminal tabs at their preferred width and reserve stable editing
space while renaming tabs.

* Remove obsolete tab rename width hook
This commit is contained in:
Jinjing
2026-11-06 22:58:41 -08:00
committed by GitHub
parent 9a6edae4a1
commit 2ed0633a4a
5 changed files with 267 additions and 10 deletions
@@ -197,7 +197,7 @@ export default function SortableTab({
disabled: isEditing
})
const handleMenuOpenChange = useTabContextMenuOpening(setMenuOpen, retirePendingActivation)
const slotProps = useTabStripSlotProps(tab.id, isActive)
const slotProps = useTabStripSlotProps(tab.id, isActive, 'terminal')
const tabTitle = tab.customTitle ?? tab.title
const tabRoot = (
<div
@@ -277,9 +277,7 @@ export default function SortableTab({
onMouseDown={(event) => {
// Why: stopPropagation avoids outer tab activation/drag; preventDefault on middle-click blocks Linux X11 paste.
event.stopPropagation()
if (event.button === 1) {
event.preventDefault()
}
preventMiddleButtonDefault(event)
}}
onClick={(event) => event.stopPropagation()}
onDoubleClick={(event) => event.stopPropagation()}
@@ -16,6 +16,7 @@ const noop = (): void => {}
function TabWidthFixture({
count,
titles = TITLES,
agentTabs = true,
browserTabs,
clientHostedBrowserRows,
editorFiles,
@@ -23,6 +24,7 @@ function TabWidthFixture({
}: {
count: number
titles?: string[]
agentTabs?: boolean
} & Pick<
TabBarProps,
'browserTabs' | 'clientHostedBrowserRows' | 'editorFiles' | 'tabBarOrder'
@@ -37,7 +39,7 @@ function TabWidthFixture({
color: null,
sortOrder: index,
createdAt: 0,
launchAgent: 'codex'
launchAgent: agentTabs ? 'codex' : undefined
}))
return (
@@ -202,3 +204,90 @@ export const singleHostedPage = defineFixture({
/>
)
})
function NonAgentTabsFixture(): React.JSX.Element {
return (
<TabWidthFixture
count={2}
titles={['sh', 'A very long terminal title '.repeat(8)]}
agentTabs={false}
tabBarOrder={['tab-0', 'browser-0', 'file-0', 'tab-1', 'browser-1', 'file-1']}
browserTabs={['Example', 'A very long browser page title '.repeat(8)].map((title, index) => ({
id: `browser-${index}`,
worktreeId: WORKSPACE_ID,
url: 'https://example.com',
title,
loading: false,
faviconUrl: null,
canGoBack: false,
canGoForward: false,
loadError: null,
createdAt: 0
}))}
editorFiles={['notes.md', `${'a-long-file-name-'.repeat(8)}.md`].map((name, index) => ({
id: `file-${index}`,
worktreeId: WORKSPACE_ID,
filePath: `/tmp/${name}`,
relativePath: name,
language: 'markdown',
isDirty: false,
mode: 'edit'
}))}
clientHostedBrowserRows={[
{
browserPageId: 'hosted-0',
worktreeId: WORKSPACE_ID,
url: 'https://example.com',
title: 'Hosted page',
loading: false,
browserHostClientId: 'client-0',
hostDeviceName: 'Phone',
hostAbsent: false
}
]}
/>
)
}
export const nonAgentTabs = defineFixture({
width: 1800,
render: () => <NonAgentTabsFixture />
})
export const nonAgentTabsOverflowing = defineFixture({
width: 420,
render: () => <NonAgentTabsFixture />
})
export const constrainedFiles = defineFixture({
width: 590,
render: () => (
<TabWidthFixture
count={0}
editorFiles={['first', 'second'].map((name, index) => ({
id: `file-${index}`,
worktreeId: WORKSPACE_ID,
filePath: `/tmp/${name}-${'long-name-'.repeat(12)}.md`,
relativePath: `${name}-${'long-name-'.repeat(12)}.md`,
language: 'markdown',
isDirty: false,
mode: 'edit'
}))}
/>
)
})
function AgentIdentityFixture(): React.JSX.Element {
const [agentTabs, setAgentTabs] = useState(false)
return (
<>
<button onClick={() => setAgentTabs(!agentTabs)}>Toggle agent identity</button>
<TabWidthFixture count={2} titles={['sh', 'sh']} agentTabs={agentTabs} />
</>
)
}
export const agentIdentityChanges = defineFixture({
width: 900,
render: () => <AgentIdentityFixture />
})
@@ -1,4 +1,8 @@
// Preferred widths size the content-fit strip; tabs share constrained space down to 144px.
export const TAB_CONTAINER_WIDTH_CLASSES = 'w-[300px] max-w-[300px] min-w-[144px] flex-1'
// Tabs share constrained space down to 144px; terminals reserve room for changing titles.
export const TERMINAL_TAB_CONTAINER_WIDTH_CLASSES =
'w-[300px] max-w-[300px] min-w-[144px] flex-auto'
// Cap intrinsic child widths too, so flex shrink starts from 300px even for very long labels.
export const CONTENT_TAB_CONTAINER_WIDTH_CLASSES =
'w-max max-w-[300px] min-w-[144px] flex-initial [&>*]:max-w-[300px]'
export const TAB_LABEL_WIDTH_CLASSES = 'tab-label min-w-0 flex-1 truncate'
@@ -1,6 +1,9 @@
import { cn } from '@/lib/utils'
import { useTabDragActive } from '../tab-group/tab-drag-context'
import { TAB_CONTAINER_WIDTH_CLASSES } from './tab-width-rules'
import {
TERMINAL_TAB_CONTAINER_WIDTH_CLASSES,
CONTENT_TAB_CONTAINER_WIDTH_CLASSES
} from './tab-width-rules'
type TabStripSlotProps = {
className: string
@@ -9,11 +12,22 @@ type TabStripSlotProps = {
}
/** Dock the active slot at viewport edges except during drag, when overlapping rects would corrupt drop targets. */
export function useTabStripSlotProps(tabId: string, isActive: boolean): TabStripSlotProps {
export function useTabStripSlotProps(
tabId: string,
isActive: boolean,
sizing: 'content' | 'terminal' = 'content'
): TabStripSlotProps {
const isTabDragActive = useTabDragActive()
const docks = isActive && !isTabDragActive
return {
className: cn(TAB_CONTAINER_WIDTH_CLASSES, docks && 'sticky inset-x-0 z-10'),
className: cn(
sizing === 'terminal'
? TERMINAL_TAB_CONTAINER_WIDTH_CLASSES
: CONTENT_TAB_CONTAINER_WIDTH_CLASSES,
// Reserve a stable editing width without measuring or storing layout in React.
'has-[[data-tab-rename-input]]:w-[300px] has-[[data-tab-rename-input]]:flex-none',
docks && 'sticky inset-x-0 z-10'
),
'data-tab-strip-slot': tabId,
'data-active-tab-dock': docks ? '' : undefined
}
@@ -0,0 +1,152 @@
import { expect, test, type Locator, type Page } from '@playwright/test'
async function openFixture(page: Page, name: string) {
await page.goto(`/fixtures.html?id=tab-bar/TabBar:${name}&theme=light`)
await expect(page.locator('html')).toHaveAttribute('data-fixture-state', 'ready')
await page.evaluate(() => document.fonts.ready)
}
function slot(page: Page, id: string) {
return page.locator(`[data-tab-strip-slot="${id}"]`)
}
async function width(element: Locator) {
return element.evaluate((node) => node.getBoundingClientRect().width)
}
async function stripLayout(page: Page) {
return page.locator('.terminal-tab-strip').evaluate((strip) => ({
scrollLeft: strip.scrollLeft,
scrollWidth: strip.scrollWidth,
slots: Array.from(strip.querySelectorAll('[data-tab-strip-slot]'), (node) => {
const rect = node.getBoundingClientRect()
return { x: rect.x, width: rect.width }
})
}))
}
test.use({ viewport: { width: 1900, height: 720 } })
test('content tabs use intrinsic widths and shrink to their minimum before overflowing', async ({
page
}) => {
await openFixture(page, 'nonAgentTabs')
const strip = page.locator('.terminal-tab-strip')
const assertWidths = async () => {
for (const id of ['browser-0', 'file-0']) {
expect(await width(slot(page, id))).toBeCloseTo(144, 0)
}
for (const id of ['tab-1', 'browser-1', 'file-1']) {
expect(await width(slot(page, id))).toBeCloseTo(300, 0)
const label = slot(page, id).locator('.tab-label')
expect(await label.evaluate((node) => node.scrollWidth > node.clientWidth)).toBe(true)
expect(await label.evaluate((node) => getComputedStyle(node).textOverflow)).toBe('ellipsis')
}
}
await assertWidths()
expect(await strip.evaluate((node) => node.scrollWidth > node.clientWidth)).toBe(false)
await page.locator('#component-fixture-frame').evaluate((node) => {
node.style.width = '1280px'
})
for (const id of ['tab-0', 'tab-1', 'browser-1', 'file-1']) {
await expect.poll(() => width(slot(page, id))).toBeGreaterThan(180)
expect(await width(slot(page, id))).toBeLessThan(250)
}
expect(await strip.evaluate((node) => node.scrollWidth > node.clientWidth)).toBe(false)
await page.locator('#component-fixture-frame').evaluate((node) => {
node.style.width = '420px'
})
await expect(page.getByRole('button', { name: 'Scroll tabs right' })).toBeVisible()
for (const id of ['tab-0', 'browser-0', 'file-0', 'tab-1', 'browser-1', 'file-1']) {
expect(await width(slot(page, id))).toBeCloseTo(144, 0)
}
expect(await strip.evaluate((node) => node.scrollWidth > node.clientWidth)).toBe(true)
await test.info().attach('overflowing-content-tabs', {
body: await page.locator('#component-fixture-frame').screenshot(),
contentType: 'image/png'
})
})
test('agent tabs keep their preferred width and share space down to their minimum', async ({
page
}) => {
await openFixture(page, 'twoTabs')
expect(await width(slot(page, 'tab-0'))).toBeCloseTo(300, 0)
expect(await width(slot(page, 'tab-1'))).toBeCloseTo(300, 0)
await page.locator('#component-fixture-frame').evaluate((node) => {
node.style.width = '420px'
})
await expect.poll(() => width(slot(page, 'tab-0'))).toBeLessThan(210)
expect(await width(slot(page, 'tab-0'))).toBeGreaterThanOrEqual(144)
expect(await width(slot(page, 'tab-0'))).toBeCloseTo(await width(slot(page, 'tab-1')), 0)
await page.locator('#component-fixture-frame').evaluate((node) => {
node.style.width = '280px'
})
await expect.poll(() => width(slot(page, 'tab-0'))).toBeCloseTo(144, 0)
expect(await width(slot(page, 'tab-1'))).toBeCloseTo(144, 0)
expect(
await page
.locator('.terminal-tab-strip')
.evaluate((node) => node.scrollWidth > node.clientWidth)
).toBe(true)
})
for (const fixture of ['nonAgentTabs', 'nonAgentTabsOverflowing']) {
test(`renaming reserves usable input space and keeps typing stable in ${fixture}`, async ({
page
}) => {
await openFixture(page, fixture)
for (const id of ['tab-0', 'file-0', 'tab-1', 'file-1']) {
const label = slot(page, id).locator('.tab-label')
await label.click()
await label.scrollIntoViewIfNeeded()
const before = await stripLayout(page)
await label.dblclick()
const input = slot(page, id).getByRole('textbox')
await expect(input).toBeFocused()
expect(await width(input)).toBeGreaterThanOrEqual(180)
await input.scrollIntoViewIfNeeded()
const editing = await stripLayout(page)
await input.fill('A much longer replacement title '.repeat(8))
expect(await stripLayout(page)).toEqual(editing)
await input.press('Escape')
await expect(input).toHaveCount(0)
expect((await stripLayout(page)).slots.map((item) => item.width)).toEqual(
before.slots.map((item) => item.width)
)
await label.dblclick()
await expect(input).toBeFocused()
await input.press('Enter')
await expect(input).toHaveCount(0)
expect((await stripLayout(page)).slots.map((item) => item.width)).toEqual(
before.slots.map((item) => item.width)
)
}
})
}
test('long file tabs share available space before the strip overflows', async ({ page }) => {
await openFixture(page, 'constrainedFiles')
for (const id of ['file-0', 'file-1']) {
expect(await width(slot(page, id))).toBeGreaterThan(240)
expect(await width(slot(page, id))).toBeLessThan(270)
}
expect(
await page
.locator('.terminal-tab-strip')
.evaluate((node) => node.scrollWidth > node.clientWidth)
).toBe(false)
})
test('terminal sizing stays stable as agent identity appears and disappears', async ({ page }) => {
await openFixture(page, 'agentIdentityChanges')
for (let index = 0; index < 3; index++) {
expect(await width(slot(page, 'tab-0'))).toBeCloseTo(300, 0)
expect(await width(slot(page, 'tab-1'))).toBeCloseTo(300, 0)
await page.getByRole('button', { name: 'Toggle agent identity' }).click()
}
})