From 2ed0633a4afec72e8b4af0fa1479bbc3d1c63ec2 Mon Sep 17 00:00:00 2001
From: Jinjing <6427696+AmethystLiang@users.noreply.github.com>
Date: Fri, 6 Nov 2026 22:58:41 -0800
Subject: [PATCH] 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
---
.../src/components/tab-bar/SortableTab.tsx | 6 +-
.../src/components/tab-bar/TabBar.fixture.tsx | 91 ++++++++++-
.../src/components/tab-bar/tab-width-rules.ts | 8 +-
.../tab-bar/use-tab-strip-slot-props.ts | 20 ++-
.../component-fixtures/tab-bar-width.spec.ts | 152 ++++++++++++++++++
5 files changed, 267 insertions(+), 10 deletions(-)
create mode 100644 tests/component-fixtures/tab-bar-width.spec.ts
diff --git a/src/renderer/src/components/tab-bar/SortableTab.tsx b/src/renderer/src/components/tab-bar/SortableTab.tsx
index 001f7833651..e700a496d50 100644
--- a/src/renderer/src/components/tab-bar/SortableTab.tsx
+++ b/src/renderer/src/components/tab-bar/SortableTab.tsx
@@ -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 = (
{
// 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()}
diff --git a/src/renderer/src/components/tab-bar/TabBar.fixture.tsx b/src/renderer/src/components/tab-bar/TabBar.fixture.tsx
index e255d860c91..dc788e91fb3 100644
--- a/src/renderer/src/components/tab-bar/TabBar.fixture.tsx
+++ b/src/renderer/src/components/tab-bar/TabBar.fixture.tsx
@@ -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 (
+
({
+ 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: () =>
+})
+
+export const nonAgentTabsOverflowing = defineFixture({
+ width: 420,
+ render: () =>
+})
+
+export const constrainedFiles = defineFixture({
+ width: 590,
+ render: () => (
+ ({
+ 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 (
+ <>
+
+
+ >
+ )
+}
+
+export const agentIdentityChanges = defineFixture({
+ width: 900,
+ render: () =>
+})
diff --git a/src/renderer/src/components/tab-bar/tab-width-rules.ts b/src/renderer/src/components/tab-bar/tab-width-rules.ts
index 70a9ba7a69d..fabb54d43d7 100644
--- a/src/renderer/src/components/tab-bar/tab-width-rules.ts
+++ b/src/renderer/src/components/tab-bar/tab-width-rules.ts
@@ -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'
diff --git a/src/renderer/src/components/tab-bar/use-tab-strip-slot-props.ts b/src/renderer/src/components/tab-bar/use-tab-strip-slot-props.ts
index b8dc0870fe0..ad1533f1685 100644
--- a/src/renderer/src/components/tab-bar/use-tab-strip-slot-props.ts
+++ b/src/renderer/src/components/tab-bar/use-tab-strip-slot-props.ts
@@ -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
}
diff --git a/tests/component-fixtures/tab-bar-width.spec.ts b/tests/component-fixtures/tab-bar-width.spec.ts
new file mode 100644
index 00000000000..d9c9336439d
--- /dev/null
+++ b/tests/component-fixtures/tab-bar-width.spec.ts
@@ -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()
+ }
+})