mirror of
https://github.com/stablyai/orca.git
synced 2026-10-30 08:37:49 +00:00
Size tabs to titles and preserve floating panel drag space (#34025)
* Size tabs to titles and preserve floating panel drag space * Stabilize agent tab layout during activity changes * Balance tab widths across titles and tab types * Reserve terminal tab icon space before agent detection * Run floating header drag E2E in the isolated headful project * Prevent native text selection on the floating panel drag handle * Cap expanded tab width at 24rem * Let tabs expand to fit titles in available space
This commit is contained in:
@@ -181,6 +181,12 @@ export function renderFloatingTerminalPanelSurface({
|
||||
tabStripChrome="floating-panel"
|
||||
/>
|
||||
</div>
|
||||
{/* Keep a grab area even when the tab strip overflows. */}
|
||||
<div
|
||||
className="h-full w-16 shrink-0 select-none"
|
||||
data-floating-terminal-drag-handle
|
||||
aria-hidden
|
||||
/>
|
||||
<FloatingTerminalWindowControls
|
||||
maximized={maximized}
|
||||
onToggleMaximized={toggleMaximized}
|
||||
|
||||
@@ -24,7 +24,7 @@ import {
|
||||
} from './drop-indicator'
|
||||
import { preventMiddleButtonDefault } from './middle-button-default-guard'
|
||||
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 { TabWorkspaceLayoutMenuSection } from './TabWorkspaceLayoutMenuSection'
|
||||
import { useTabStripPointerActivation } from './tab-strip-pointer-activation'
|
||||
@@ -158,7 +158,7 @@ export default function BrowserTab({
|
||||
data-pinned={isPinned ? 'true' : 'false'}
|
||||
{...attributes}
|
||||
{...listeners}
|
||||
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,
|
||||
|
||||
@@ -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({
|
||||
>
|
||||
<div
|
||||
data-client-hosted-browser-row-id={row.browserPageId}
|
||||
className={`group relative flex h-full cursor-pointer select-none items-center px-1.5 text-xs outline-none focus:outline-none focus-visible:outline-none ${getTabStripBorderClasses(hasTabsToRight, { includeTopBorder: includeTopTabBorder })} ${getTabRootStateClasses(isActive)}`}
|
||||
className={`${TAB_ROOT_WIDTH_CLASSES} group relative flex h-full cursor-pointer select-none items-center px-1.5 text-xs outline-none focus:outline-none focus-visible:outline-none ${getTabStripBorderClasses(hasTabsToRight, { includeTopBorder: includeTopTabBorder })} ${getTabRootStateClasses(isActive)}`}
|
||||
onPointerDown={onActivate}
|
||||
onMouseDown={(event) => {
|
||||
if (event.button === 1) {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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')
|
||||
|
||||
@@ -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<TabBarProps, 'browserTabs' | 'editorFiles' | 'tabBarOrder'>): 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 (
|
||||
<DndContext>
|
||||
<div className="@container/tab-strip h-8 bg-card">
|
||||
<TabBar
|
||||
tabs={tabs}
|
||||
activeTabId={activeTabId}
|
||||
activeTabType="terminal"
|
||||
worktreeId={WORKSPACE_ID}
|
||||
groupId="tab-width-group"
|
||||
browserTabs={browserTabs}
|
||||
editorFiles={editorFiles}
|
||||
tabBarOrder={tabBarOrder}
|
||||
expandedPaneByTabId={{}}
|
||||
showAgentLaunchItems={false}
|
||||
onActivate={setActiveTabId}
|
||||
onClose={noop}
|
||||
onCloseOthers={noop}
|
||||
onCloseToRight={noop}
|
||||
onCloseToLeft={noop}
|
||||
onNewTerminalTab={noop}
|
||||
onNewBrowserTab={noop}
|
||||
onSetCustomTitle={noop}
|
||||
onSetTabColor={noop}
|
||||
onTogglePaneExpand={noop}
|
||||
/>
|
||||
</div>
|
||||
</DndContext>
|
||||
)
|
||||
}
|
||||
|
||||
export const spacious = defineFixture({
|
||||
width: 1280,
|
||||
render: () => <TabWidthFixture count={4} />
|
||||
})
|
||||
|
||||
export const single = defineFixture({
|
||||
width: 1280,
|
||||
render: () => <TabWidthFixture count={1} titles={['Terminal 1']} />
|
||||
})
|
||||
|
||||
export const long = defineFixture({
|
||||
width: 1280,
|
||||
render: () => <TabWidthFixture count={1} titles={['A very long conversation title '.repeat(8)]} />
|
||||
})
|
||||
|
||||
export const contentFit = defineFixture({
|
||||
width: 1280,
|
||||
render: () => (
|
||||
<TabWidthFixture
|
||||
count={3}
|
||||
titles={[
|
||||
'Measure the effect of the new tab layout on conversation navigation',
|
||||
'PR implementation approach',
|
||||
'Review the implementation for layout and interaction regressions'
|
||||
]}
|
||||
/>
|
||||
)
|
||||
})
|
||||
|
||||
export const narrow = defineFixture({
|
||||
width: 420,
|
||||
render: () => <TabWidthFixture count={4} />
|
||||
})
|
||||
|
||||
export const mixed = defineFixture({
|
||||
width: 620,
|
||||
render: () => (
|
||||
<TabWidthFixture
|
||||
count={3}
|
||||
titles={['A very long conversation title '.repeat(8), 'Go', 'Notes']}
|
||||
/>
|
||||
)
|
||||
})
|
||||
|
||||
export const overflowing = defineFixture({
|
||||
width: 420,
|
||||
render: () => <TabWidthFixture count={12} />
|
||||
})
|
||||
|
||||
export const mixedTypes = defineFixture({
|
||||
width: 620,
|
||||
render: () => (
|
||||
<TabWidthFixture
|
||||
count={1}
|
||||
titles={['A very long conversation title '.repeat(12)]}
|
||||
tabBarOrder={['tab-0', 'browser-0', 'file-0']}
|
||||
browserTabs={[
|
||||
{
|
||||
id: 'browser-0',
|
||||
worktreeId: WORKSPACE_ID,
|
||||
url: 'https://google.com',
|
||||
title: 'Google',
|
||||
loading: false,
|
||||
faviconUrl: null,
|
||||
canGoBack: false,
|
||||
canGoForward: false,
|
||||
loadError: null,
|
||||
createdAt: 0
|
||||
}
|
||||
]}
|
||||
editorFiles={[
|
||||
{
|
||||
id: 'file-0',
|
||||
worktreeId: WORKSPACE_ID,
|
||||
filePath: '/tmp/untitled.md',
|
||||
relativePath: 'untitled.md',
|
||||
language: 'markdown',
|
||||
isDirty: true,
|
||||
mode: 'edit'
|
||||
}
|
||||
]}
|
||||
/>
|
||||
)
|
||||
})
|
||||
|
||||
export const competingTitles = defineFixture({
|
||||
width: 620,
|
||||
render: () => (
|
||||
<TabWidthFixture
|
||||
count={3}
|
||||
titles={[
|
||||
'A very long conversation title '.repeat(12),
|
||||
'Go',
|
||||
'Another long conversation title '.repeat(3)
|
||||
]}
|
||||
/>
|
||||
)
|
||||
})
|
||||
@@ -56,7 +56,7 @@ export function TabHoverCard({
|
||||
className="flex items-center gap-2 text-muted-foreground"
|
||||
data-tab-hover-card-program
|
||||
>
|
||||
<span className="flex size-4 shrink-0 items-center justify-center" aria-hidden>
|
||||
<span className="flex h-4 min-w-4 shrink-0 items-center justify-center" aria-hidden>
|
||||
{icon}
|
||||
</span>
|
||||
<span className="min-w-0 break-all">{programName}</span>
|
||||
|
||||
@@ -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 (
|
||||
<span
|
||||
@@ -59,7 +61,7 @@ export function TerminalTabLeadingIcon({
|
||||
'auto.components.tab.bar.TerminalTabLeadingIcon.7ab2964bea',
|
||||
'Unread agent completion'
|
||||
)}
|
||||
className="mr-1 inline-flex shrink-0 items-center gap-1"
|
||||
className={iconClassName}
|
||||
>
|
||||
<FilledBellIcon className="size-3 text-amber-500 drop-shadow-sm" />
|
||||
{agent ? <TerminalTabAgentIdentityIcon agent={agent} isActive={isActive} /> : null}
|
||||
@@ -74,7 +76,7 @@ export function TerminalTabLeadingIcon({
|
||||
<span
|
||||
data-testid="tab-agent-activity-indicator"
|
||||
data-agent-activity-status={activityStatus}
|
||||
className="mr-1 inline-flex shrink-0 items-center gap-1"
|
||||
className={iconClassName}
|
||||
>
|
||||
{/* Why: the state glyph is self-describing and the tab title already has a tooltip; hover here stays quiet. */}
|
||||
<AgentStateDot state={dotState} size="md" title={null} progress={progress} />
|
||||
@@ -86,13 +88,17 @@ export function TerminalTabLeadingIcon({
|
||||
|
||||
if (agent) {
|
||||
return (
|
||||
<TerminalTabAgentIdentityIcon agent={agent} isActive={isActive} className="mr-1 shrink-0" />
|
||||
<TerminalTabAgentIdentityIcon
|
||||
agent={agent}
|
||||
isActive={isActive}
|
||||
className={cn(iconClassName, 'justify-end')}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<span
|
||||
className={`mr-1 inline-flex shrink-0 ${isActive ? '' : 'opacity-70'}`}
|
||||
className={cn(iconClassName, !isActive && 'opacity-70')}
|
||||
data-shell-icon={shell ?? 'generic'}
|
||||
aria-hidden
|
||||
>
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -42,40 +42,43 @@ type E2EWindow = typeof window & {
|
||||
__store?: E2EStore
|
||||
}
|
||||
|
||||
async function seedFloatingSimulatorTabs(page: Page): Promise<E2EUnifiedTab[]> {
|
||||
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<E2EUnifiedTab[]> {
|
||||
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<string[]> =>
|
||||
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<number> =>
|
||||
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<number[]> =>
|
||||
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)
|
||||
}
|
||||
})
|
||||
|
||||
@@ -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<number> =>
|
||||
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<number[][]> =>
|
||||
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<number[][]> =>
|
||||
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
|
||||
}) => {
|
||||
|
||||
Reference in New Issue
Block a user