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() + } +})