diff --git a/src/renderer/src/components/floating-terminal/FloatingTerminalPanelSurface.tsx b/src/renderer/src/components/floating-terminal/FloatingTerminalPanelSurface.tsx index 0b7563f55f5..5a3f2b34a51 100644 --- a/src/renderer/src/components/floating-terminal/FloatingTerminalPanelSurface.tsx +++ b/src/renderer/src/components/floating-terminal/FloatingTerminalPanelSurface.tsx @@ -181,6 +181,12 @@ export function renderFloatingTerminalPanelSurface({ tabStripChrome="floating-panel" /> + {/* Keep a grab area even when the tab strip overflows. */} +
{ onTabPointerDown( e, diff --git a/src/renderer/src/components/tab-bar/ClientHostedBrowserTab.tsx b/src/renderer/src/components/tab-bar/ClientHostedBrowserTab.tsx index b3e83d037a0..c968f9a4069 100644 --- a/src/renderer/src/components/tab-bar/ClientHostedBrowserTab.tsx +++ b/src/renderer/src/components/tab-bar/ClientHostedBrowserTab.tsx @@ -12,7 +12,7 @@ import { getClientHostedBrowserRowLabel } from './client-hosted-browser-row-label' import { preventMiddleButtonDefault } from './middle-button-default-guard' -import { TAB_LABEL_WIDTH_CLASSES } from './tab-width-rules' +import { TAB_LABEL_WIDTH_CLASSES, TAB_ROOT_WIDTH_CLASSES } from './tab-width-rules' import { useTabStripSlotProps } from './use-tab-strip-slot-props' /** Paired-client pages have no unified tab: expose presence and close without persisted ordering, pinning or drag actions. */ @@ -47,7 +47,7 @@ export default function ClientHostedBrowserTab({ >
{ if (event.button === 1) { diff --git a/src/renderer/src/components/tab-bar/EditorFileTab.tsx b/src/renderer/src/components/tab-bar/EditorFileTab.tsx index 2375c75e61a..80f7df546c7 100644 --- a/src/renderer/src/components/tab-bar/EditorFileTab.tsx +++ b/src/renderer/src/components/tab-bar/EditorFileTab.tsx @@ -28,7 +28,7 @@ import { import { canOpenMarkdownPreview } from '@/components/editor/markdown-preview-controls' import { EditorFileTabContextMenu } from './EditorFileTabContextMenu' import { translate } from '@/i18n/i18n' -import { TAB_LABEL_WIDTH_CLASSES } from './tab-width-rules' +import { TAB_LABEL_WIDTH_CLASSES, TAB_ROOT_WIDTH_CLASSES } from './tab-width-rules' import { useTabStripSlotProps } from './use-tab-strip-slot-props' import { EditorFileTabCloseButton } from './EditorFileTabCloseButton' import { useTabStripPointerActivation } from './tab-strip-pointer-activation' @@ -229,7 +229,7 @@ export default function EditorFileTab({ data-pinned={isPinned ? 'true' : 'false'} {...attributes} {...dragListeners} - className={`group relative flex items-center h-full px-1.5 text-xs cursor-pointer select-none outline-none focus:outline-none focus-visible:outline-none ${getTabStripBorderClasses(hasTabsToRight, { includeTopBorder: includeTopTabBorder })} ${getDropIndicatorClasses(dropIndicator ?? null)} ${getTabRootStateClasses(isActive)}`} + className={`${TAB_ROOT_WIDTH_CLASSES} group relative flex items-center h-full px-1.5 text-xs cursor-pointer select-none outline-none focus:outline-none focus-visible:outline-none ${getTabStripBorderClasses(hasTabsToRight, { includeTopBorder: includeTopTabBorder })} ${getDropIndicatorClasses(dropIndicator ?? null)} ${getTabRootStateClasses(isActive)}`} onPointerDown={(e) => { onTabPointerDown( e, diff --git a/src/renderer/src/components/tab-bar/SortableTab.tsx b/src/renderer/src/components/tab-bar/SortableTab.tsx index df8bca0ec35..e58ba574244 100644 --- a/src/renderer/src/components/tab-bar/SortableTab.tsx +++ b/src/renderer/src/components/tab-bar/SortableTab.tsx @@ -23,7 +23,7 @@ import { useTabRenameReturnFocus } from './use-tab-rename-return-focus' import { SortableTabContextMenu } from './SortableTabContextMenu' import { useSortableTabContextMenu } from './use-sortable-tab-context-menu' import { translate } from '@/i18n/i18n' -import { TAB_LABEL_WIDTH_CLASSES } from './tab-width-rules' +import { TAB_LABEL_WIDTH_CLASSES, TAB_ROOT_WIDTH_CLASSES } from './tab-width-rules' import { useTabStripSlotProps } from './use-tab-strip-slot-props' import { useTabStripPointerActivation } from './tab-strip-pointer-activation' import { TerminalTabLeadingIcon } from './TerminalTabLeadingIcon' @@ -209,7 +209,7 @@ export default function SortableTab({ data-agent-activity-status={activityStatus} {...attributes} {...dragListeners} - className={`group relative flex items-center h-full px-1.5 text-xs cursor-pointer select-none outline-none focus:outline-none focus-visible:outline-none ${getTabStripBorderClasses(hasTabsToRight, { includeTopBorder: includeTopTabBorder })} ${getDropIndicatorClasses(dropIndicator ?? null)} ${getTabRootStateClasses(isActive)}`} + className={`${TAB_ROOT_WIDTH_CLASSES} group relative flex items-center h-full px-1.5 text-xs cursor-pointer select-none outline-none focus:outline-none focus-visible:outline-none ${getTabStripBorderClasses(hasTabsToRight, { includeTopBorder: includeTopTabBorder })} ${getDropIndicatorClasses(dropIndicator ?? null)} ${getTabRootStateClasses(isActive)}`} onDoubleClick={(e) => { if (isEditing) { return diff --git a/src/renderer/src/components/tab-bar/TabBar.context-menu.test.ts b/src/renderer/src/components/tab-bar/TabBar.context-menu.test.ts index 8b2c58b5b33..c10ec6739d4 100644 --- a/src/renderer/src/components/tab-bar/TabBar.context-menu.test.ts +++ b/src/renderer/src/components/tab-bar/TabBar.context-menu.test.ts @@ -347,7 +347,7 @@ describe('TabBar context menu wiring', () => { expect(sortable[0].props.tabCount).toBe(2) }) - it('keeps the tab strip content-sized until horizontal scrolling is needed', async () => { + it('keeps the tab strip content-sized while allowing horizontal scrolling', async () => { const element = await renderTabBar({ tabs: [TERMINAL_TAB], editorFiles: [EDITOR_FILE], @@ -356,7 +356,7 @@ describe('TabBar context menu wiring', () => { }) const divs = findChildrenByType(element, 'div') const stripWrapper = divs.find((candidate) => - String(candidate.props.className ?? '').includes('flex-[0_1_auto]') + String(candidate.props.className ?? '').includes('group/tab-strip') ) const strip = divs.find((candidate) => String(candidate.props.className ?? '').includes('terminal-tab-strip') @@ -365,6 +365,7 @@ describe('TabBar context menu wiring', () => { expect(stripWrapper).toBeTruthy() expect(stripWrapper?.props.className).toContain('min-w-0') expect(stripWrapper?.props.className).toContain('max-w-full') + expect(stripWrapper?.props.className).toContain('flex-[0_1_auto]') expect(strip).toBeTruthy() expect(strip?.props.className).toContain('min-w-0') expect(strip?.props.className).toContain('flex-1') diff --git a/src/renderer/src/components/tab-bar/TabBar.fixture.tsx b/src/renderer/src/components/tab-bar/TabBar.fixture.tsx new file mode 100644 index 00000000000..b4d7516155d --- /dev/null +++ b/src/renderer/src/components/tab-bar/TabBar.fixture.tsx @@ -0,0 +1,167 @@ +import { useState } from 'react' +import { DndContext } from '@dnd-kit/core' +import { defineFixture } from '../../component-fixtures/component-fixture' +import type { TerminalTab } from '../../../../shared/terminal-tab-types' +import TabBar from './TabBar' +import type { TabBarProps } from './tab-bar-props' + +const WORKSPACE_ID = 'tab-width-fixture' +const TITLES = [ + 'Should we regenerate the conversation title?', + 'Native chat tab title regeneration', + 'Codex Chat', + "You're the president agent coordinating this review" +] +const noop = (): void => {} + +function TabWidthFixture({ + count, + titles = TITLES, + browserTabs, + editorFiles, + tabBarOrder +}: { + count: number + titles?: string[] +} & Pick): React.JSX.Element { + const [activeTabId, setActiveTabId] = useState('tab-0') + const tabs: TerminalTab[] = Array.from({ length: count }, (_, index) => ({ + id: `tab-${index}`, + worktreeId: WORKSPACE_ID, + ptyId: null, + title: titles[index % titles.length], + customTitle: null, + color: null, + sortOrder: index, + createdAt: 0, + launchAgent: 'codex' + })) + + return ( + +
+ +
+
+ ) +} + +export const spacious = defineFixture({ + width: 1280, + render: () => +}) + +export const single = defineFixture({ + width: 1280, + render: () => +}) + +export const long = defineFixture({ + width: 1280, + render: () => +}) + +export const contentFit = defineFixture({ + width: 1280, + render: () => ( + + ) +}) + +export const narrow = defineFixture({ + width: 420, + render: () => +}) + +export const mixed = defineFixture({ + width: 620, + render: () => ( + + ) +}) + +export const overflowing = defineFixture({ + width: 420, + render: () => +}) + +export const mixedTypes = defineFixture({ + width: 620, + render: () => ( + + ) +}) + +export const competingTitles = defineFixture({ + width: 620, + render: () => ( + + ) +}) diff --git a/src/renderer/src/components/tab-bar/TabHoverCard.tsx b/src/renderer/src/components/tab-bar/TabHoverCard.tsx index 0068112123d..a84c307afe6 100644 --- a/src/renderer/src/components/tab-bar/TabHoverCard.tsx +++ b/src/renderer/src/components/tab-bar/TabHoverCard.tsx @@ -56,7 +56,7 @@ export function TabHoverCard({ className="flex items-center gap-2 text-muted-foreground" data-tab-hover-card-program > - + {icon} {programName} diff --git a/src/renderer/src/components/tab-bar/TerminalTabLeadingIcon.tsx b/src/renderer/src/components/tab-bar/TerminalTabLeadingIcon.tsx index 677297e17ca..a86be6e78fc 100644 --- a/src/renderer/src/components/tab-bar/TerminalTabLeadingIcon.tsx +++ b/src/renderer/src/components/tab-bar/TerminalTabLeadingIcon.tsx @@ -51,6 +51,8 @@ export function TerminalTabLeadingIcon({ isActive, progress }: TerminalTabLeadingIconProps): React.JSX.Element { + // Reserve activity and identity slots before agent detection as well as status changes. + const iconClassName = 'mr-1 inline-flex w-7 shrink-0 items-center gap-1' if (showUnreadActivity) { return ( {agent ? : null} @@ -74,7 +76,7 @@ export function TerminalTabLeadingIcon({ {/* Why: the state glyph is self-describing and the tab title already has a tooltip; hover here stays quiet. */} @@ -86,13 +88,17 @@ export function TerminalTabLeadingIcon({ if (agent) { return ( - + ) } return ( diff --git a/src/renderer/src/components/tab-bar/tab-title-tooltip.test.tsx b/src/renderer/src/components/tab-bar/tab-title-tooltip.test.tsx index 41996d9dc17..2e1d5c88e5e 100644 --- a/src/renderer/src/components/tab-bar/tab-title-tooltip.test.tsx +++ b/src/renderer/src/components/tab-bar/tab-title-tooltip.test.tsx @@ -199,12 +199,10 @@ function textSpanHtml(markup: string, text: string): string { function expectTabContainerWidth(markup: string, root: string): void { const container = firstOpeningTag(markup) - // Assert literal widths so changing the shared constant cannot silently allow live-title tab resizing. - const widthClasses = 'w-[180px] min-w-[72px] min-[1280px]:w-[220px]' + const widthClasses = 'min-w-25 max-w-max flex-[1_1_0px]' expect(container).toContain(widthClasses) - expect(root).not.toContain('w-[180px]') - expect(root).not.toContain('min-w-[72px]') - expect(root).not.toContain('min-[1280px]:w-[220px]') + expect(root).not.toContain('min-w-25') + expect(root).toContain('w-full') } function expectTooltipContent(markup: string, text: string): void { 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 47a7b68f7a0..be09c2f1d6d 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,6 @@ -// Definite widths prevent live titles from resizing the strip; flex-shrink still allows narrowing to the minimum. -export const TAB_CONTAINER_WIDTH_CLASSES = 'w-[180px] min-w-[72px] min-[1280px]:w-[220px]' +// Share crowded strips equally, releasing unused space from tabs that fit their titles. +export const TAB_CONTAINER_WIDTH_CLASSES = 'min-w-25 max-w-max flex-[1_1_0px]' + +export const TAB_ROOT_WIDTH_CLASSES = 'w-full' export const TAB_LABEL_WIDTH_CLASSES = 'min-w-0 flex-1 truncate' diff --git a/tests/e2e/floating-workspace-pane-height.spec.ts b/tests/e2e/floating-workspace-pane-height.spec.ts index 6adfd316538..8b6bd504b7a 100644 --- a/tests/e2e/floating-workspace-pane-height.spec.ts +++ b/tests/e2e/floating-workspace-pane-height.spec.ts @@ -42,40 +42,43 @@ type E2EWindow = typeof window & { __store?: E2EStore } -async function seedFloatingSimulatorTabs(page: Page): Promise { - const tabs = await page.evaluate((worktreeId) => { - // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: `__store` is the dev/e2e store handle Orca injects on window; the cast only names it. - const store = (window as E2EWindow).__store - if (!store) { - throw new Error('Store unavailable') - } - const state = store.getState() - store.setState({ - settings: { - ...state.settings, - floatingTerminalEnabled: true, - mobileEmulatorEnabled: true +async function seedFloatingSimulatorTabs(page: Page, count = 2): Promise { + const tabs = await page.evaluate( + ({ worktreeId, count }) => { + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: `__store` is the dev/e2e store handle Orca injects on window; the cast only names it. + const store = (window as E2EWindow).__store + if (!store) { + throw new Error('Store unavailable') } - }) - const refreshed = store.getState() - const existing = (refreshed.unifiedTabsByWorktree[worktreeId] ?? []).filter( - (tab) => tab.contentType === 'simulator' - ) - while (existing.length < 2) { - existing.push( - refreshed.createUnifiedTab(worktreeId, 'simulator', { - label: `Mobile Emulator ${existing.length + 1}`, - recordInteraction: false - }) + const state = store.getState() + store.setState({ + settings: { + ...state.settings, + floatingTerminalEnabled: true, + mobileEmulatorEnabled: true + } + }) + const refreshed = store.getState() + const existing = (refreshed.unifiedTabsByWorktree[worktreeId] ?? []).filter( + (tab) => tab.contentType === 'simulator' ) - } - refreshed.activateTab(existing[0].id) - return existing.map((tab) => ({ - id: tab.id, - contentType: tab.contentType, - groupId: tab.groupId - })) - }, FLOATING_WORKTREE_ID) + while (existing.length < count) { + existing.push( + refreshed.createUnifiedTab(worktreeId, 'simulator', { + label: `Mobile Emulator ${existing.length + 1}`, + recordInteraction: false + }) + ) + } + refreshed.activateTab(existing[0].id) + return existing.map((tab) => ({ + id: tab.id, + contentType: tab.contentType, + groupId: tab.groupId + })) + }, + { worktreeId: FLOATING_WORKTREE_ID, count } + ) await page.waitForFunction( (panelSelector) => Boolean(document.querySelector(panelSelector)), PANEL_SELECTOR, @@ -144,3 +147,126 @@ test('floating panel group bodies and panes own real pixels, single group and sp await orcaPage.mouse.click(firstBodyBox!.x + 20, firstBodyBox!.y + 20) await expect(openPanel.locator(`[data-tab-id="${first.id}"]`)).toBeVisible() }) + +test('tabs use spare room for titles, share crowded space, and scroll at the minimum', async ({ + orcaPage +}) => { + const titles = [ + 'A very long conversation title '.repeat(12), + 'Go', + 'Another long conversation title '.repeat(3) + ] + const seedFloatingTerminalTabs = (count: number): Promise => + orcaPage.evaluate( + ({ count, titles, worktreeId }) => { + const store = window.__store! + store.setState((state) => ({ + settings: { ...state.settings!, floatingTerminalEnabled: true } + })) + const tabs = [...(store.getState().tabsByWorktree[worktreeId] ?? [])] + while (tabs.length < count) { + const tab = store + .getState() + .createTab(worktreeId, undefined, undefined, { activate: false }) + store + .getState() + .setTabCustomTitle(tab.id, titles[tabs.length] ?? `Tab ${tabs.length + 1}`) + tabs.push(tab) + } + store.getState().setActiveTabForWorktree(worktreeId, tabs[0].id) + store.getState().activateTab(tabs[0].id) + return tabs.map((tab) => tab.id) + }, + { count, titles, worktreeId: FLOATING_WORKTREE_ID } + ) + const [first] = await seedFloatingTerminalTabs(1) + await openFloatingPanel(orcaPage) + const panel = orcaPage.locator(OPEN_PANEL_SELECTOR).first() + await panel.evaluate((element) => { + element.style.maxWidth = '720px' + }) + const firstSlot = panel.locator(`[data-tab-strip-slot="${first}"]`) + const width = (): Promise => + firstSlot.evaluate((element) => element.getBoundingClientRect().width) + await expect.poll(width).toBeGreaterThan(384) + const singleWidth = await width() + const strip = panel.locator('.terminal-tab-strip').first() + + const [, second] = await seedFloatingTerminalTabs(2) + await expect(panel.locator(`[data-tab-strip-slot="${second}"]`)).toContainText('Go') + await expect.poll(width).toBeCloseTo(singleWidth - 100, 0) + + const [, , third] = await seedFloatingTerminalTabs(3) + await expect(panel.locator(`[data-tab-strip-slot="${third}"]`)).toContainText( + 'Another long conversation title' + ) + await expect + .poll(() => strip.evaluate((element) => element.scrollWidth > element.clientWidth)) + .toBe(false) + const widths = (): Promise => + panel + .locator('[data-tab-strip-slot]') + .evaluateAll((slots) => slots.map((element) => element.getBoundingClientRect().width)) + const crowdedWidths = await widths() + expect(crowdedWidths.every((value) => value >= 100)).toBe(true) + expect(crowdedWidths[0]).toBeCloseTo(crowdedWidths[2], 0) + expect(crowdedWidths[1]).toBe(100) + + await seedFloatingTerminalTabs(6) + await expect(panel.locator('[data-tab-strip-slot]')).toHaveCount(6) + await expect + .poll(() => strip.evaluate((element) => element.scrollWidth > element.clientWidth)) + .toBe(true) + expect(await widths()).toEqual([100, 100, 100, 100, 100, 100]) +}) + +// Real pointer capture requires the isolated visible-window project. +test('@headful floating header keeps a drag and maximize target with expanded and overflowing tabs', async ({ + orcaPage +}) => { + for (const count of [1, 16]) { + await seedFloatingSimulatorTabs(orcaPage, count) + await openFloatingPanel(orcaPage) + + const panel = orcaPage.locator(OPEN_PANEL_SELECTOR).first() + const handle = panel.locator('[data-floating-terminal-drag-handle]') + const strip = panel.locator('.terminal-tab-strip').first() + await expect + .poll(() => strip.evaluate((element) => element.scrollWidth > element.clientWidth)) + .toBe(count > 1) + + const before = await panel.boundingBox() + const grab = await handle.boundingBox() + expect(before).not.toBeNull() + expect(grab).not.toBeNull() + expect(grab!.width).toBeGreaterThanOrEqual(48) + expect(grab!.height).toBeGreaterThanOrEqual(28) + + const x = grab!.x + grab!.width / 2 + const y = grab!.y + grab!.height / 2 + await orcaPage.mouse.move(x, y) + await orcaPage.mouse.down() + await orcaPage.mouse.move(x - 40, y + 24, { steps: 4 }) + await orcaPage.mouse.up() + await expect + .poll(() => panel.evaluate((element) => element.getBoundingClientRect().x)) + .toBeCloseTo(before!.x - 40, 0) + await expect + .poll(() => panel.evaluate((element) => element.getBoundingClientRect().y)) + .toBeCloseTo(before!.y + 24, 0) + + await handle.dblclick() + await expect(panel.getByRole('button', { name: 'Restore floating workspace' })).toHaveAttribute( + 'aria-pressed', + 'true' + ) + await handle.dblclick() + await expect( + panel.getByRole('button', { name: 'Maximize floating workspace' }) + ).toHaveAttribute('aria-pressed', 'false') + expect(await orcaPage.evaluate(() => window.getSelection()?.isCollapsed ?? true)).toBe(true) + await expect + .poll(() => panel.evaluate((element) => element.getBoundingClientRect().x)) + .toBeCloseTo(before!.x - 40, 0) + } +}) diff --git a/tests/e2e/tab-strip-active-dock.spec.ts b/tests/e2e/tab-strip-active-dock.spec.ts index c4c521cac7d..624a84cbb65 100644 --- a/tests/e2e/tab-strip-active-dock.spec.ts +++ b/tests/e2e/tab-strip-active-dock.spec.ts @@ -74,6 +74,172 @@ test.describe('Tab strip active-tab dock', () => { await ensureTerminalVisible(orcaPage) }) + test('fits titles without resizing neighbors when tabs open and close in spare space', async ({ + orcaPage + }) => { + const worktreeId = await waitForActiveWorktree(orcaPage) + const tabIds = await orcaPage.evaluate((worktreeId) => { + const state = window.__store!.getState() + const short = state.createTab(worktreeId, undefined, undefined, { activate: false }) + const long = state.createTab(worktreeId, undefined, undefined, { activate: false }) + state.setTabCustomTitle(short.id, 'Terminal 1') + state.setTabCustomTitle( + long.id, + 'Measure the effect of the new tab layout on conversation navigation' + ) + return { short: short.id, long: long.id } + }, worktreeId) + const short = orcaPage.locator(slotSelector(tabIds.short)) + const long = orcaPage.locator(slotSelector(tabIds.long)) + const width = (tab: typeof short): Promise => + tab.evaluate((element) => element.getBoundingClientRect().width) + + await expect(short).toContainText('Terminal 1') + await expect(long).toContainText( + 'Measure the effect of the new tab layout on conversation navigation' + ) + expect(await width(short)).toBeLessThan(140) + expect(await width(long)).toBeGreaterThan(384) + const label = long.locator('span.truncate') + expect(await label.evaluate((element) => element.scrollWidth <= element.clientWidth)).toBe(true) + const geometry = (): Promise => + orcaPage.evaluate( + (ids) => + ids.map((id) => { + const slot = document.querySelector(`[data-tab-strip-slot="${id}"]`)! + const tab = slot.getBoundingClientRect() + const close = slot.querySelector('[data-tab-close-button]')!.getBoundingClientRect() + return [tab.x, close.x] + }), + [tabIds.short, tabIds.long] + ) + const before = await geometry() + const added = await orcaPage.evaluate((worktreeId) => { + const state = window.__store!.getState() + const tab = state.createTab(worktreeId, undefined, undefined, { activate: false }) + state.setTabCustomTitle(tab.id, 'Go') + return tab.id + }, worktreeId) + await expect(orcaPage.locator(slotSelector(added))).toContainText('Go') + for (let frame = 0; frame < 4; frame++) { + await nextFrames(orcaPage, 1) + expect(await geometry()).toEqual(before) + } + await long.locator('[data-testid="sortable-tab"]').click() + await expect(long.locator('[data-active="true"]')).toHaveCount(1) + expect(await geometry()).toEqual(before) + await orcaPage.locator(`${slotSelector(added)} [data-tab-close-button]`).click() + await expect(orcaPage.locator(slotSelector(added))).toHaveCount(0) + for (let frame = 0; frame < 4; frame++) { + await nextFrames(orcaPage, 1) + expect(await geometry()).toEqual(before) + } + + await orcaPage.evaluate( + ({ id }) => { + window.__store!.getState().setTabCustomTitle(id, 'Chat') + }, + { id: tabIds.long } + ) + await expect(long).toContainText('Chat') + await expect.poll(() => width(long)).toBeLessThanOrEqual(100) + + await orcaPage.evaluate( + ({ id }) => { + window + .__store!.getState() + .setTabCustomTitle(id, 'A very long conversation title '.repeat(20)) + }, + { id: tabIds.long } + ) + await expect(long).toContainText('A very long conversation title') + await expect.poll(() => width(long)).toBeGreaterThan(384) + const strip = orcaPage.locator(STRIP).first() + expect(await strip.evaluate((element) => element.scrollWidth <= element.clientWidth)).toBe(true) + const stripBox = await strip.boundingBox() + const buttonBox = await orcaPage + .getByRole('button', { name: 'New tab', exact: true }) + .first() + .boundingBox() + expect(stripBox).not.toBeNull() + expect(buttonBox).not.toBeNull() + expect(buttonBox!.x - stripBox!.x - stripBox!.width).toBeGreaterThanOrEqual(0) + expect(buttonBox!.x - stripBox!.x - stripBox!.width).toBeLessThanOrEqual(12) + }) + + test('keeps tab and close-button positions stable through agent detection and activity changes', async ({ + orcaPage + }) => { + const worktreeId = await waitForActiveWorktree(orcaPage) + const ids = await orcaPage.evaluate((worktreeId) => { + const store = window.__store! + const agent = store + .getState() + .createTab(worktreeId, undefined, undefined, { activate: false }) + const next = store.getState().createTab(worktreeId, undefined, undefined, { activate: false }) + store.getState().setTabCustomTitle(agent.id, 'Agent conversation') + store.getState().setTabCustomTitle(next.id, 'Neighbor') + return [agent.id, next.id] + }, worktreeId) + const agent = orcaPage.locator(slotSelector(ids[0])) + await expect(agent.locator('[data-shell-icon]')).toBeVisible() + await expect(orcaPage.locator(slotSelector(ids[1]))).toContainText('Neighbor') + const geometry = (): Promise => + orcaPage.evaluate( + (ids) => + ids.map((id) => { + const slot = document.querySelector(`[data-tab-strip-slot="${id}"]`)! + const tab = slot.getBoundingClientRect() + const close = slot.querySelector('[data-tab-close-button]')!.getBoundingClientRect() + return [tab.x, tab.width, close.x] + }), + ids + ) + const before = await geometry() + + for (const stage of ['working', 'unread', 'idle'] as const) { + await orcaPage.evaluate( + ({ tabId, worktreeId, stage }) => { + const store = window.__store! + const paneKey = `${tabId}:00000000-0000-4000-8000-000000000001` + store.setState((state) => { + const agentStatusByPaneKey = { ...state.agentStatusByPaneKey } + const unreadTerminalTabs = { ...state.unreadTerminalTabs } + delete agentStatusByPaneKey[paneKey] + delete unreadTerminalTabs[tabId] + if (stage === 'working') { + agentStatusByPaneKey[paneKey] = { + paneKey, + worktreeId, + agentType: 'codex', + state: 'working', + prompt: '', + updatedAt: Date.now(), + stateStartedAt: Date.now(), + stateHistory: [] + } + } else if (stage === 'unread') { + unreadTerminalTabs[tabId] = true + } + return { agentStatusByPaneKey, unreadTerminalTabs } + }) + }, + { tabId: ids[0], worktreeId, stage } + ) + await expect(agent.locator('[data-testid="tab-agent-activity-indicator"]')).toHaveCount( + stage === 'working' ? 1 : 0 + ) + await expect(agent.locator('[data-testid="tab-activity-bell"]')).toHaveCount( + stage === 'unread' ? 1 : 0 + ) + await expect(agent.locator('[data-agent-icon="codex"]')).toHaveCount( + stage === 'working' ? 1 : 0 + ) + await expect(agent).toContainText('Agent conversation') + await expect.poll(geometry).toEqual(before) + } + }) + test('docks an offscreen active tab and reveals an arriving tab beside it without flicker', async ({ orcaPage }) => {