mirror of
https://github.com/stablyai/orca.git
synced 2026-11-15 02:17:31 +00:00
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:
@@ -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()
|
||||
}
|
||||
})
|
||||
Reference in New Issue
Block a user