Files
Maestro/src/__tests__/renderer/components/TabBar.test.tsx
Pedram Amini 7be82ce338 fix(tab-bar): ensure full tab visibility when scrolling into view
Changed from centering the tab to using scrollIntoView with 'nearest' option.
This ensures the entire tab including the close button is visible, rather than
potentially cutting off the right edge when near container boundaries.
2026-02-03 14:26:24 -06:00

5507 lines
144 KiB
TypeScript

import React from 'react';
import { render, screen, fireEvent, act, waitFor } from '@testing-library/react';
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import { TabBar } from '../../../renderer/components/TabBar';
import type { AITab, Theme, FilePreviewTab } from '../../../renderer/types';
// Mock lucide-react icons
vi.mock('lucide-react', () => ({
X: ({ className, style }: { className?: string; style?: React.CSSProperties }) => (
<span data-testid="x-icon" className={className} style={style}>
X
</span>
),
Plus: ({ className, style }: { className?: string; style?: React.CSSProperties }) => (
<span data-testid="plus-icon" className={className} style={style}>
+
</span>
),
Star: ({ className, style }: { className?: string; style?: React.CSSProperties }) => (
<span data-testid="star-icon" className={className} style={style}>
</span>
),
Copy: ({ className, style }: { className?: string; style?: React.CSSProperties }) => (
<span data-testid="copy-icon" className={className} style={style}>
📋
</span>
),
Edit2: ({ className, style }: { className?: string; style?: React.CSSProperties }) => (
<span data-testid="edit-icon" className={className} style={style}>
</span>
),
Mail: ({ className, style }: { className?: string; style?: React.CSSProperties }) => (
<span data-testid="mail-icon" className={className} style={style}>
</span>
),
Pencil: ({ className, style }: { className?: string; style?: React.CSSProperties }) => (
<span data-testid="pencil-icon" className={className} style={style}>
</span>
),
Search: ({ className, style }: { className?: string; style?: React.CSSProperties }) => (
<span data-testid="search-icon" className={className} style={style}>
🔍
</span>
),
GitMerge: ({ className, style }: { className?: string; style?: React.CSSProperties }) => (
<span data-testid="git-merge-icon" className={className} style={style}>
</span>
),
ArrowRightCircle: ({ className, style }: { className?: string; style?: React.CSSProperties }) => (
<span data-testid="arrow-right-circle-icon" className={className} style={style}>
</span>
),
Minimize2: ({ className, style }: { className?: string; style?: React.CSSProperties }) => (
<span data-testid="minimize-icon" className={className} style={style}>
</span>
),
Download: ({ className, style }: { className?: string; style?: React.CSSProperties }) => (
<span data-testid="download-icon" className={className} style={style}>
</span>
),
Clipboard: ({ className, style }: { className?: string; style?: React.CSSProperties }) => (
<span data-testid="clipboard-icon" className={className} style={style}>
📎
</span>
),
Share2: ({ className, style }: { className?: string; style?: React.CSSProperties }) => (
<span data-testid="share2-icon" className={className} style={style}>
</span>
),
ChevronsLeft: ({ className, style }: { className?: string; style?: React.CSSProperties }) => (
<span data-testid="chevrons-left-icon" className={className} style={style}>
«
</span>
),
ChevronsRight: ({ className, style }: { className?: string; style?: React.CSSProperties }) => (
<span data-testid="chevrons-right-icon" className={className} style={style}>
»
</span>
),
ExternalLink: ({ className, style }: { className?: string; style?: React.CSSProperties }) => (
<span data-testid="external-link-icon" className={className} style={style}>
</span>
),
FolderOpen: ({ className, style }: { className?: string; style?: React.CSSProperties }) => (
<span data-testid="folder-open-icon" className={className} style={style}>
📂
</span>
),
FileText: ({ className, style }: { className?: string; style?: React.CSSProperties }) => (
<span data-testid="file-text-icon" className={className} style={style}>
📄
</span>
),
}));
// Mock react-dom createPortal
vi.mock('react-dom', async () => {
const actual = await vi.importActual('react-dom');
return {
...actual,
createPortal: (children: React.ReactNode) => children,
};
});
// Test theme
const mockTheme: Theme = {
id: 'test-theme',
name: 'Test Theme',
mode: 'dark',
colors: {
bgMain: '#1a1a1a',
bgSidebar: '#2a2a2a',
bgActivity: '#3a3a3a',
textMain: '#ffffff',
textDim: '#888888',
accent: '#007acc',
border: '#444444',
error: '#ff4444',
success: '#44ff44',
warning: '#ffaa00',
vibe: '#ff00ff',
agentStatus: '#00ff00',
},
};
// Helper to create tabs
function createTab(overrides: Partial<AITab> = {}): AITab {
return {
id: 'tab-1',
agentSessionId: 'a1b2c3d4-e5f6-7890-abcd-ef1234567890',
state: 'idle',
name: '',
starred: false,
hasUnread: false,
inputValue: '',
stagedImages: [],
...overrides,
};
}
describe('TabBar', () => {
const mockOnTabSelect = vi.fn();
const mockOnTabClose = vi.fn();
const mockOnNewTab = vi.fn();
const mockOnTabRename = vi.fn();
const mockOnRequestRename = vi.fn();
const mockOnTabReorder = vi.fn();
const mockOnTabStar = vi.fn();
const mockOnTabMarkUnread = vi.fn();
const mockOnToggleUnreadFilter = vi.fn();
const mockOnOpenTabSearch = vi.fn();
// Mock timers for hover delays
beforeEach(() => {
vi.useFakeTimers();
vi.clearAllMocks();
// Mock scrollTo and scrollIntoView
Element.prototype.scrollTo = vi.fn();
Element.prototype.scrollIntoView = vi.fn();
// Mock clipboard
Object.assign(navigator, {
clipboard: {
writeText: vi.fn().mockResolvedValue(undefined),
},
});
});
afterEach(() => {
vi.useRealTimers();
});
describe('rendering', () => {
it('renders tabs correctly', () => {
const tabs = [createTab({ id: 'tab-1', name: 'Tab 1' })];
render(
<TabBar
tabs={tabs}
activeTabId="tab-1"
theme={mockTheme}
onTabSelect={mockOnTabSelect}
onTabClose={mockOnTabClose}
onNewTab={mockOnNewTab}
/>
);
expect(screen.getByText('Tab 1')).toBeInTheDocument();
});
it('renders new tab button', () => {
render(
<TabBar
tabs={[createTab()]}
activeTabId="tab-1"
theme={mockTheme}
onTabSelect={mockOnTabSelect}
onTabClose={mockOnTabClose}
onNewTab={mockOnNewTab}
/>
);
expect(screen.getByTitle('New tab (Cmd+T)')).toBeInTheDocument();
});
it('renders unread filter button', () => {
render(
<TabBar
tabs={[createTab()]}
activeTabId="tab-1"
theme={mockTheme}
onTabSelect={mockOnTabSelect}
onTabClose={mockOnTabClose}
onNewTab={mockOnNewTab}
/>
);
expect(screen.getByTitle(/Filter unread tabs/)).toBeInTheDocument();
});
it('renders tab search button when onOpenTabSearch provided', () => {
render(
<TabBar
tabs={[createTab()]}
activeTabId="tab-1"
theme={mockTheme}
onTabSelect={mockOnTabSelect}
onTabClose={mockOnTabClose}
onNewTab={mockOnNewTab}
onOpenTabSearch={mockOnOpenTabSearch}
/>
);
expect(screen.getByTitle('Search tabs (Cmd+Shift+O)')).toBeInTheDocument();
});
it('does not render tab search button when onOpenTabSearch not provided', () => {
render(
<TabBar
tabs={[createTab()]}
activeTabId="tab-1"
theme={mockTheme}
onTabSelect={mockOnTabSelect}
onTabClose={mockOnTabClose}
onNewTab={mockOnNewTab}
/>
);
expect(screen.queryByTitle('Search tabs (Cmd+Shift+O)')).not.toBeInTheDocument();
});
});
describe('getTabDisplayName', () => {
it('displays tab name when provided', () => {
const tabs = [createTab({ id: 'tab-1', name: 'My Custom Tab' })];
render(
<TabBar
tabs={tabs}
activeTabId="tab-1"
theme={mockTheme}
onTabSelect={mockOnTabSelect}
onTabClose={mockOnTabClose}
onNewTab={mockOnNewTab}
/>
);
expect(screen.getByText('My Custom Tab')).toBeInTheDocument();
});
it('displays first UUID octet when no name but agentSessionId exists', () => {
const tabs = [
createTab({
id: 'tab-1',
name: '',
agentSessionId: 'abcd1234-5678-9abc-def0-123456789012',
}),
];
render(
<TabBar
tabs={tabs}
activeTabId="tab-1"
theme={mockTheme}
onTabSelect={mockOnTabSelect}
onTabClose={mockOnTabClose}
onNewTab={mockOnNewTab}
/>
);
expect(screen.getByText('ABCD1234')).toBeInTheDocument();
});
it('displays "New Session" when no name and no agentSessionId', () => {
const tabs = [
createTab({
id: 'tab-1',
name: '',
agentSessionId: undefined,
}),
];
render(
<TabBar
tabs={tabs}
activeTabId="tab-1"
theme={mockTheme}
onTabSelect={mockOnTabSelect}
onTabClose={mockOnTabClose}
onNewTab={mockOnNewTab}
/>
);
expect(screen.getByText('New Session')).toBeInTheDocument();
});
});
describe('tab selection', () => {
it('calls onTabSelect when tab is clicked', () => {
const tabs = [
createTab({ id: 'tab-1', name: 'Tab 1' }),
createTab({ id: 'tab-2', name: 'Tab 2' }),
];
render(
<TabBar
tabs={tabs}
activeTabId="tab-1"
theme={mockTheme}
onTabSelect={mockOnTabSelect}
onTabClose={mockOnTabClose}
onNewTab={mockOnNewTab}
/>
);
fireEvent.click(screen.getByText('Tab 2'));
expect(mockOnTabSelect).toHaveBeenCalledWith('tab-2');
});
it('applies active styles to active tab', () => {
const tabs = [
createTab({ id: 'tab-1', name: 'Tab 1' }),
createTab({ id: 'tab-2', name: 'Tab 2' }),
];
render(
<TabBar
tabs={tabs}
activeTabId="tab-1"
theme={mockTheme}
onTabSelect={mockOnTabSelect}
onTabClose={mockOnTabClose}
onNewTab={mockOnNewTab}
/>
);
const activeTab = screen.getByText('Tab 1').closest('[data-tab-id]');
expect(activeTab).toHaveStyle({ backgroundColor: mockTheme.colors.bgMain });
});
});
describe('tab close', () => {
it('calls onTabClose when close button is clicked', () => {
const tabs = [createTab({ id: 'tab-1', name: 'Tab 1' })];
render(
<TabBar
tabs={tabs}
activeTabId="tab-1"
theme={mockTheme}
onTabSelect={mockOnTabSelect}
onTabClose={mockOnTabClose}
onNewTab={mockOnNewTab}
/>
);
const closeButton = screen.getByTitle('Close tab');
fireEvent.click(closeButton);
expect(mockOnTabClose).toHaveBeenCalledWith('tab-1');
});
it('calls onTabClose on middle-click', () => {
const tabs = [createTab({ id: 'tab-1', name: 'Tab 1' })];
render(
<TabBar
tabs={tabs}
activeTabId="tab-1"
theme={mockTheme}
onTabSelect={mockOnTabSelect}
onTabClose={mockOnTabClose}
onNewTab={mockOnNewTab}
/>
);
const tab = screen.getByText('Tab 1').closest('[data-tab-id]')!;
fireEvent.mouseDown(tab, { button: 1 });
expect(mockOnTabClose).toHaveBeenCalledWith('tab-1');
});
it('does not close on left-click mouseDown', () => {
const tabs = [createTab({ id: 'tab-1', name: 'Tab 1' })];
render(
<TabBar
tabs={tabs}
activeTabId="tab-1"
theme={mockTheme}
onTabSelect={mockOnTabSelect}
onTabClose={mockOnTabClose}
onNewTab={mockOnNewTab}
/>
);
const tab = screen.getByText('Tab 1').closest('[data-tab-id]')!;
fireEvent.mouseDown(tab, { button: 0 });
expect(mockOnTabClose).not.toHaveBeenCalled();
});
});
describe('new tab', () => {
it('calls onNewTab when new tab button is clicked', () => {
render(
<TabBar
tabs={[createTab()]}
activeTabId="tab-1"
theme={mockTheme}
onTabSelect={mockOnTabSelect}
onTabClose={mockOnTabClose}
onNewTab={mockOnNewTab}
/>
);
fireEvent.click(screen.getByTitle('New tab (Cmd+T)'));
expect(mockOnNewTab).toHaveBeenCalled();
});
});
describe('tab indicators', () => {
it('shows busy indicator when tab is busy', () => {
const tabs = [createTab({ id: 'tab-1', name: 'Tab 1', state: 'busy' })];
const { container } = render(
<TabBar
tabs={tabs}
activeTabId="tab-1"
theme={mockTheme}
onTabSelect={mockOnTabSelect}
onTabClose={mockOnTabClose}
onNewTab={mockOnNewTab}
/>
);
const busyDot = container.querySelector('.animate-pulse');
expect(busyDot).toBeInTheDocument();
expect(busyDot).toHaveStyle({ backgroundColor: mockTheme.colors.warning });
});
it('shows unread indicator for inactive tab with unread messages', () => {
const tabs = [
createTab({ id: 'tab-1', name: 'Tab 1' }),
createTab({ id: 'tab-2', name: 'Tab 2', hasUnread: true }),
];
const { container } = render(
<TabBar
tabs={tabs}
activeTabId="tab-1"
theme={mockTheme}
onTabSelect={mockOnTabSelect}
onTabClose={mockOnTabClose}
onNewTab={mockOnNewTab}
/>
);
const unreadDot = container.querySelector('[title="New messages"]');
expect(unreadDot).toBeInTheDocument();
expect(unreadDot).toHaveStyle({ backgroundColor: mockTheme.colors.accent });
});
it('shows unread indicator for active tab (when manually marked)', () => {
const tabs = [createTab({ id: 'tab-1', name: 'Tab 1', hasUnread: true })];
const { container } = render(
<TabBar
tabs={tabs}
activeTabId="tab-1"
theme={mockTheme}
onTabSelect={mockOnTabSelect}
onTabClose={mockOnTabClose}
onNewTab={mockOnNewTab}
/>
);
// Unread indicator should show immediately even on active tab
// This allows users to mark a tab as unread and see the indicator right away
const unreadDot = container.querySelector('[title="New messages"]');
expect(unreadDot).toBeInTheDocument();
expect(unreadDot).toHaveStyle({ backgroundColor: mockTheme.colors.accent });
});
it('does not show unread indicator for busy tab', () => {
const tabs = [
createTab({ id: 'tab-1', name: 'Tab 1' }),
createTab({ id: 'tab-2', name: 'Tab 2', hasUnread: true, state: 'busy' }),
];
const { container } = render(
<TabBar
tabs={tabs}
activeTabId="tab-1"
theme={mockTheme}
onTabSelect={mockOnTabSelect}
onTabClose={mockOnTabClose}
onNewTab={mockOnNewTab}
/>
);
expect(container.querySelector('[title="New messages"]')).not.toBeInTheDocument();
});
it('shows star indicator for starred tabs', () => {
const tabs = [createTab({ id: 'tab-1', name: 'Tab 1', starred: true })];
render(
<TabBar
tabs={tabs}
activeTabId="tab-1"
theme={mockTheme}
onTabSelect={mockOnTabSelect}
onTabClose={mockOnTabClose}
onNewTab={mockOnNewTab}
/>
);
expect(screen.getByTestId('star-icon')).toBeInTheDocument();
});
it('shows draft indicator for tabs with unsent input', () => {
const tabs = [createTab({ id: 'tab-1', name: 'Tab 1', inputValue: 'draft message' })];
render(
<TabBar
tabs={tabs}
activeTabId="tab-1"
theme={mockTheme}
onTabSelect={mockOnTabSelect}
onTabClose={mockOnTabClose}
onNewTab={mockOnNewTab}
/>
);
// The pencil icon component is rendered with testid
expect(screen.getByTestId('pencil-icon')).toBeInTheDocument();
});
it('shows draft indicator for tabs with staged images', () => {
const tabs = [createTab({ id: 'tab-1', name: 'Tab 1', stagedImages: ['image.png'] })];
render(
<TabBar
tabs={tabs}
activeTabId="tab-1"
theme={mockTheme}
onTabSelect={mockOnTabSelect}
onTabClose={mockOnTabClose}
onNewTab={mockOnNewTab}
/>
);
// The pencil icon component is rendered with testid
expect(screen.getByTestId('pencil-icon')).toBeInTheDocument();
});
it('shows shortcut hints for first 9 tabs', () => {
const tabs = Array.from({ length: 10 }, (_, i) =>
createTab({ id: `tab-${i}`, name: `Tab ${i + 1}` })
);
render(
<TabBar
tabs={tabs}
activeTabId="tab-0"
theme={mockTheme}
onTabSelect={mockOnTabSelect}
onTabClose={mockOnTabClose}
onNewTab={mockOnNewTab}
/>
);
// Should show 1-9 but not 10
for (let i = 1; i <= 9; i++) {
expect(screen.getByText(String(i))).toBeInTheDocument();
}
expect(screen.queryByText('10')).not.toBeInTheDocument();
});
it('hides shortcut hints when showUnreadOnly is true', () => {
const tabs = [createTab({ id: 'tab-1', name: 'Tab 1' })];
render(
<TabBar
tabs={tabs}
activeTabId="tab-1"
theme={mockTheme}
onTabSelect={mockOnTabSelect}
onTabClose={mockOnTabClose}
onNewTab={mockOnNewTab}
showUnreadOnly={true}
/>
);
expect(screen.queryByText('1')).not.toBeInTheDocument();
});
});
describe('unread filter', () => {
it('toggles unread filter when button clicked (uncontrolled)', () => {
const tabs = [
createTab({ id: 'tab-1', name: 'Tab 1' }),
createTab({ id: 'tab-2', name: 'Tab 2', hasUnread: true }),
];
render(
<TabBar
tabs={tabs}
activeTabId="tab-1"
theme={mockTheme}
onTabSelect={mockOnTabSelect}
onTabClose={mockOnTabClose}
onNewTab={mockOnNewTab}
/>
);
// Initially both tabs visible
expect(screen.getByText('Tab 1')).toBeInTheDocument();
expect(screen.getByText('Tab 2')).toBeInTheDocument();
// Toggle filter
fireEvent.click(screen.getByTitle(/Filter unread tabs/));
// Now only unread and active tab visible
expect(screen.getByText('Tab 1')).toBeInTheDocument(); // Active
expect(screen.getByText('Tab 2')).toBeInTheDocument(); // Unread
});
it('calls onToggleUnreadFilter when provided (controlled)', () => {
const tabs = [createTab({ id: 'tab-1', name: 'Tab 1' })];
render(
<TabBar
tabs={tabs}
activeTabId="tab-1"
theme={mockTheme}
onTabSelect={mockOnTabSelect}
onTabClose={mockOnTabClose}
onNewTab={mockOnNewTab}
onToggleUnreadFilter={mockOnToggleUnreadFilter}
/>
);
fireEvent.click(screen.getByTitle(/Filter unread tabs/));
expect(mockOnToggleUnreadFilter).toHaveBeenCalled();
});
it('shows empty state when filter is on but no unread tabs', () => {
const tabs = [createTab({ id: 'tab-1', name: 'Tab 1' })];
render(
<TabBar
tabs={tabs}
activeTabId="tab-2" // Different from tab-1
theme={mockTheme}
onTabSelect={mockOnTabSelect}
onTabClose={mockOnTabClose}
onNewTab={mockOnNewTab}
showUnreadOnly={true}
/>
);
expect(screen.getByText('No unread tabs')).toBeInTheDocument();
});
it('includes tabs with drafts in filtered view', () => {
const tabs = [
createTab({ id: 'tab-1', name: 'Tab 1' }),
createTab({ id: 'tab-2', name: 'Draft Tab', inputValue: 'draft' }),
];
render(
<TabBar
tabs={tabs}
activeTabId="tab-3" // Not in the list
theme={mockTheme}
onTabSelect={mockOnTabSelect}
onTabClose={mockOnTabClose}
onNewTab={mockOnNewTab}
showUnreadOnly={true}
/>
);
// Only draft tab should be visible
expect(screen.queryByText('Tab 1')).not.toBeInTheDocument();
expect(screen.getByText('Draft Tab')).toBeInTheDocument();
});
it('updates filter button title based on state', () => {
const tabs = [createTab({ id: 'tab-1', name: 'Tab 1' })];
const { rerender } = render(
<TabBar
tabs={tabs}
activeTabId="tab-1"
theme={mockTheme}
onTabSelect={mockOnTabSelect}
onTabClose={mockOnTabClose}
onNewTab={mockOnNewTab}
showUnreadOnly={false}
/>
);
expect(screen.getByTitle('Filter unread tabs (Cmd+U)')).toBeInTheDocument();
rerender(
<TabBar
tabs={tabs}
activeTabId="tab-1"
theme={mockTheme}
onTabSelect={mockOnTabSelect}
onTabClose={mockOnTabClose}
onNewTab={mockOnNewTab}
showUnreadOnly={true}
/>
);
expect(screen.getByTitle('Showing unread only (Cmd+U)')).toBeInTheDocument();
});
});
describe('tab search', () => {
it('calls onOpenTabSearch when search button clicked', () => {
render(
<TabBar
tabs={[createTab()]}
activeTabId="tab-1"
theme={mockTheme}
onTabSelect={mockOnTabSelect}
onTabClose={mockOnTabClose}
onNewTab={mockOnNewTab}
onOpenTabSearch={mockOnOpenTabSearch}
/>
);
fireEvent.click(screen.getByTitle('Search tabs (Cmd+Shift+O)'));
expect(mockOnOpenTabSearch).toHaveBeenCalled();
});
});
describe('drag and drop', () => {
it('handles drag start', () => {
const tabs = [createTab({ id: 'tab-1', name: 'Tab 1' })];
render(
<TabBar
tabs={tabs}
activeTabId="tab-1"
theme={mockTheme}
onTabSelect={mockOnTabSelect}
onTabClose={mockOnTabClose}
onNewTab={mockOnNewTab}
onTabReorder={mockOnTabReorder}
/>
);
const tab = screen.getByText('Tab 1').closest('[data-tab-id]')!;
const dataTransfer = {
effectAllowed: '',
setData: vi.fn(),
getData: vi.fn().mockReturnValue('tab-1'),
};
fireEvent.dragStart(tab, { dataTransfer });
expect(dataTransfer.effectAllowed).toBe('move');
expect(dataTransfer.setData).toHaveBeenCalledWith('text/plain', 'tab-1');
});
it('handles drag over', () => {
const tabs = [
createTab({ id: 'tab-1', name: 'Tab 1' }),
createTab({ id: 'tab-2', name: 'Tab 2' }),
];
render(
<TabBar
tabs={tabs}
activeTabId="tab-1"
theme={mockTheme}
onTabSelect={mockOnTabSelect}
onTabClose={mockOnTabClose}
onNewTab={mockOnNewTab}
onTabReorder={mockOnTabReorder}
/>
);
const tab2 = screen.getByText('Tab 2').closest('[data-tab-id]')!;
const dataTransfer = {
dropEffect: '',
};
const event = fireEvent.dragOver(tab2, { dataTransfer });
expect(dataTransfer.dropEffect).toBe('move');
});
it('handles drop and reorders tabs', () => {
const tabs = [
createTab({ id: 'tab-1', name: 'Tab 1' }),
createTab({ id: 'tab-2', name: 'Tab 2' }),
];
render(
<TabBar
tabs={tabs}
activeTabId="tab-1"
theme={mockTheme}
onTabSelect={mockOnTabSelect}
onTabClose={mockOnTabClose}
onNewTab={mockOnNewTab}
onTabReorder={mockOnTabReorder}
/>
);
const tab1 = screen.getByText('Tab 1').closest('[data-tab-id]')!;
const tab2 = screen.getByText('Tab 2').closest('[data-tab-id]')!;
// Start dragging tab-1
fireEvent.dragStart(tab1, {
dataTransfer: {
effectAllowed: '',
setData: vi.fn(),
getData: vi.fn().mockReturnValue('tab-1'),
},
});
// Drop on tab-2
fireEvent.drop(tab2, {
dataTransfer: {
getData: vi.fn().mockReturnValue('tab-1'),
},
});
expect(mockOnTabReorder).toHaveBeenCalledWith(0, 1);
});
it('does not reorder when dropping on same tab', () => {
const tabs = [createTab({ id: 'tab-1', name: 'Tab 1' })];
render(
<TabBar
tabs={tabs}
activeTabId="tab-1"
theme={mockTheme}
onTabSelect={mockOnTabSelect}
onTabClose={mockOnTabClose}
onNewTab={mockOnNewTab}
onTabReorder={mockOnTabReorder}
/>
);
const tab = screen.getByText('Tab 1').closest('[data-tab-id]')!;
fireEvent.drop(tab, {
dataTransfer: {
getData: vi.fn().mockReturnValue('tab-1'),
},
});
expect(mockOnTabReorder).not.toHaveBeenCalled();
});
it('handles drag end', () => {
const tabs = [createTab({ id: 'tab-1', name: 'Tab 1' })];
render(
<TabBar
tabs={tabs}
activeTabId="tab-1"
theme={mockTheme}
onTabSelect={mockOnTabSelect}
onTabClose={mockOnTabClose}
onNewTab={mockOnNewTab}
onTabReorder={mockOnTabReorder}
/>
);
const tab = screen.getByText('Tab 1').closest('[data-tab-id]')!;
// Start drag to set draggingTabId
fireEvent.dragStart(tab, {
dataTransfer: {
effectAllowed: '',
setData: vi.fn(),
},
});
// Drag end should reset state
fireEvent.dragEnd(tab);
// Tab should no longer have opacity-50 class (dragging state)
expect(tab).not.toHaveClass('opacity-50');
});
});
describe('hover overlay', () => {
it('shows overlay after hover delay for tabs with agentSessionId', async () => {
const tabs = [
createTab({
id: 'tab-1',
name: 'Tab 1',
agentSessionId: 'abc123-def456',
}),
];
render(
<TabBar
tabs={tabs}
activeTabId="tab-1"
theme={mockTheme}
onTabSelect={mockOnTabSelect}
onTabClose={mockOnTabClose}
onNewTab={mockOnNewTab}
onTabStar={mockOnTabStar}
onRequestRename={mockOnRequestRename}
/>
);
const tab = screen.getByText('Tab 1').closest('[data-tab-id]')!;
fireEvent.mouseEnter(tab);
// Overlay not visible yet
expect(screen.queryByText('Copy Session ID')).not.toBeInTheDocument();
// Advance timers past the 400ms delay
act(() => {
vi.advanceTimersByTime(450);
});
// Now overlay should be visible
expect(screen.getByText('Copy Session ID')).toBeInTheDocument();
expect(screen.getByText('Star Session')).toBeInTheDocument();
expect(screen.getByText('Rename Tab')).toBeInTheDocument();
});
it('does not show overlay for tabs without agentSessionId', () => {
const tabs = [
createTab({
id: 'tab-1',
name: '',
agentSessionId: undefined,
}),
];
render(
<TabBar
tabs={tabs}
activeTabId="tab-1"
theme={mockTheme}
onTabSelect={mockOnTabSelect}
onTabClose={mockOnTabClose}
onNewTab={mockOnNewTab}
/>
);
const tab = screen.getByText('New Session').closest('[data-tab-id]')!;
fireEvent.mouseEnter(tab);
act(() => {
vi.advanceTimersByTime(500);
});
expect(screen.queryByText('Copy Session ID')).not.toBeInTheDocument();
});
it('closes overlay on mouse leave', async () => {
const tabs = [
createTab({
id: 'tab-1',
name: 'Tab 1',
agentSessionId: 'abc123',
}),
];
render(
<TabBar
tabs={tabs}
activeTabId="tab-1"
theme={mockTheme}
onTabSelect={mockOnTabSelect}
onTabClose={mockOnTabClose}
onNewTab={mockOnNewTab}
onTabStar={mockOnTabStar}
/>
);
const tab = screen.getByText('Tab 1').closest('[data-tab-id]')!;
// Open overlay
fireEvent.mouseEnter(tab);
act(() => {
vi.advanceTimersByTime(450);
});
expect(screen.getByText('Copy Session ID')).toBeInTheDocument();
// Leave tab
fireEvent.mouseLeave(tab);
// Wait for close delay
act(() => {
vi.advanceTimersByTime(150);
});
expect(screen.queryByText('Copy Session ID')).not.toBeInTheDocument();
});
it('keeps overlay open when mouse enters overlay', async () => {
const tabs = [
createTab({
id: 'tab-1',
name: 'Tab 1',
agentSessionId: 'abc123',
}),
];
render(
<TabBar
tabs={tabs}
activeTabId="tab-1"
theme={mockTheme}
onTabSelect={mockOnTabSelect}
onTabClose={mockOnTabClose}
onNewTab={mockOnNewTab}
onTabStar={mockOnTabStar}
/>
);
const tab = screen.getByText('Tab 1').closest('[data-tab-id]')!;
// Open overlay
fireEvent.mouseEnter(tab);
act(() => {
vi.advanceTimersByTime(450);
});
const overlay = screen.getByText('Copy Session ID').closest('.fixed')!;
// Leave tab but enter overlay
fireEvent.mouseLeave(tab);
fireEvent.mouseEnter(overlay);
// Wait past close delay
act(() => {
vi.advanceTimersByTime(200);
});
// Overlay should still be visible
expect(screen.getByText('Copy Session ID')).toBeInTheDocument();
});
it('closes overlay when mouse leaves overlay', async () => {
const tabs = [
createTab({
id: 'tab-1',
name: 'Tab 1',
agentSessionId: 'abc123',
}),
];
render(
<TabBar
tabs={tabs}
activeTabId="tab-1"
theme={mockTheme}
onTabSelect={mockOnTabSelect}
onTabClose={mockOnTabClose}
onNewTab={mockOnNewTab}
onTabStar={mockOnTabStar}
/>
);
const tab = screen.getByText('Tab 1').closest('[data-tab-id]')!;
// Open overlay
fireEvent.mouseEnter(tab);
act(() => {
vi.advanceTimersByTime(450);
});
const overlay = screen.getByText('Copy Session ID').closest('.fixed')!;
// Leave tab but enter overlay (to keep it open)
fireEvent.mouseLeave(tab);
fireEvent.mouseEnter(overlay);
// Verify overlay is still visible
expect(screen.getByText('Copy Session ID')).toBeInTheDocument();
// Now leave the overlay
fireEvent.mouseLeave(overlay);
// Overlay should close immediately
expect(screen.queryByText('Copy Session ID')).not.toBeInTheDocument();
});
it('prevents click event propagation on overlay', async () => {
const tabs = [
createTab({
id: 'tab-1',
name: 'Tab 1',
agentSessionId: 'abc123',
}),
];
render(
<TabBar
tabs={tabs}
activeTabId="tab-1"
theme={mockTheme}
onTabSelect={mockOnTabSelect}
onTabClose={mockOnTabClose}
onNewTab={mockOnNewTab}
onTabStar={mockOnTabStar}
/>
);
const tab = screen.getByText('Tab 1').closest('[data-tab-id]')!;
// Open overlay
fireEvent.mouseEnter(tab);
act(() => {
vi.advanceTimersByTime(450);
});
const overlay = screen.getByText('Copy Session ID').closest('.fixed')!;
// Click on overlay should not propagate
fireEvent.click(overlay);
// Overlay should still be open (event was stopped)
expect(screen.getByText('Copy Session ID')).toBeInTheDocument();
});
it('copies session ID to clipboard', async () => {
const tabs = [
createTab({
id: 'tab-1',
name: 'Tab 1',
agentSessionId: 'abc123-xyz789',
}),
];
render(
<TabBar
tabs={tabs}
activeTabId="tab-1"
theme={mockTheme}
onTabSelect={mockOnTabSelect}
onTabClose={mockOnTabClose}
onNewTab={mockOnNewTab}
/>
);
const tab = screen.getByText('Tab 1').closest('[data-tab-id]')!;
fireEvent.mouseEnter(tab);
act(() => {
vi.advanceTimersByTime(450);
});
fireEvent.click(screen.getByText('Copy Session ID'));
expect(navigator.clipboard.writeText).toHaveBeenCalledWith('abc123-xyz789');
expect(screen.getByText('Copied!')).toBeInTheDocument();
// Reset after delay
act(() => {
vi.advanceTimersByTime(1600);
});
expect(screen.queryByText('Copied!')).not.toBeInTheDocument();
});
it('calls onTabStar when star button clicked', async () => {
const tabs = [
createTab({
id: 'tab-1',
name: 'Tab 1',
agentSessionId: 'abc123',
starred: false,
}),
];
render(
<TabBar
tabs={tabs}
activeTabId="tab-1"
theme={mockTheme}
onTabSelect={mockOnTabSelect}
onTabClose={mockOnTabClose}
onNewTab={mockOnNewTab}
onTabStar={mockOnTabStar}
/>
);
const tab = screen.getByText('Tab 1').closest('[data-tab-id]')!;
fireEvent.mouseEnter(tab);
act(() => {
vi.advanceTimersByTime(450);
});
fireEvent.click(screen.getByText('Star Session'));
expect(mockOnTabStar).toHaveBeenCalledWith('tab-1', true);
});
it('shows "Unstar Session" for starred tabs', async () => {
const tabs = [
createTab({
id: 'tab-1',
name: 'Tab 1',
agentSessionId: 'abc123',
starred: true,
}),
];
render(
<TabBar
tabs={tabs}
activeTabId="tab-1"
theme={mockTheme}
onTabSelect={mockOnTabSelect}
onTabClose={mockOnTabClose}
onNewTab={mockOnNewTab}
onTabStar={mockOnTabStar}
/>
);
const tab = screen.getByText('Tab 1').closest('[data-tab-id]')!;
fireEvent.mouseEnter(tab);
act(() => {
vi.advanceTimersByTime(450);
});
expect(screen.getByText('Unstar Session')).toBeInTheDocument();
});
it('calls onRequestRename when rename clicked', async () => {
const tabs = [
createTab({
id: 'tab-1',
name: 'Tab 1',
agentSessionId: 'abc123',
}),
];
render(
<TabBar
tabs={tabs}
activeTabId="tab-1"
theme={mockTheme}
onTabSelect={mockOnTabSelect}
onTabClose={mockOnTabClose}
onNewTab={mockOnNewTab}
onRequestRename={mockOnRequestRename}
/>
);
const tab = screen.getByText('Tab 1').closest('[data-tab-id]')!;
fireEvent.mouseEnter(tab);
act(() => {
vi.advanceTimersByTime(450);
});
fireEvent.click(screen.getByText('Rename Tab'));
expect(mockOnRequestRename).toHaveBeenCalledWith('tab-1');
});
it('calls onTabMarkUnread when Mark as Unread clicked', async () => {
const tabs = [
createTab({
id: 'tab-1',
name: 'Tab 1',
agentSessionId: 'abc123',
}),
];
render(
<TabBar
tabs={tabs}
activeTabId="tab-1"
theme={mockTheme}
onTabSelect={mockOnTabSelect}
onTabClose={mockOnTabClose}
onNewTab={mockOnNewTab}
onTabMarkUnread={mockOnTabMarkUnread}
/>
);
const tab = screen.getByText('Tab 1').closest('[data-tab-id]')!;
fireEvent.mouseEnter(tab);
act(() => {
vi.advanceTimersByTime(450);
});
fireEvent.click(screen.getByText('Mark as Unread'));
expect(mockOnTabMarkUnread).toHaveBeenCalledWith('tab-1');
});
it('displays session name in overlay header', async () => {
const tabs = [
createTab({
id: 'tab-1',
name: 'My Session Name',
agentSessionId: 'abc123',
}),
];
render(
<TabBar
tabs={tabs}
activeTabId="tab-1"
theme={mockTheme}
onTabSelect={mockOnTabSelect}
onTabClose={mockOnTabClose}
onNewTab={mockOnNewTab}
/>
);
const tab = screen.getByText('My Session Name').closest('[data-tab-id]')!;
fireEvent.mouseEnter(tab);
act(() => {
vi.advanceTimersByTime(450);
});
// Session name appears in overlay header
const overlayNames = screen.getAllByText('My Session Name');
expect(overlayNames.length).toBeGreaterThan(1); // Tab name + overlay header
});
it('displays session ID in overlay header', async () => {
const tabs = [
createTab({
id: 'tab-1',
name: '',
agentSessionId: 'full-session-id-12345',
}),
];
render(
<TabBar
tabs={tabs}
activeTabId="tab-1"
theme={mockTheme}
onTabSelect={mockOnTabSelect}
onTabClose={mockOnTabClose}
onNewTab={mockOnNewTab}
/>
);
const tab = screen.getByText('FULL').closest('[data-tab-id]')!;
fireEvent.mouseEnter(tab);
act(() => {
vi.advanceTimersByTime(450);
});
expect(screen.getByText('full-session-id-12345')).toBeInTheDocument();
});
});
describe('separators', () => {
it('shows separators between inactive tabs', () => {
const tabs = [
createTab({ id: 'tab-1', name: 'Tab 1' }),
createTab({ id: 'tab-2', name: 'Tab 2' }),
createTab({ id: 'tab-3', name: 'Tab 3' }),
];
const { container } = render(
<TabBar
tabs={tabs}
activeTabId="tab-1"
theme={mockTheme}
onTabSelect={mockOnTabSelect}
onTabClose={mockOnTabClose}
onNewTab={mockOnNewTab}
/>
);
// Separators between inactive tabs (tab-2 and tab-3)
const separators = container.querySelectorAll('.w-px');
expect(separators.length).toBeGreaterThan(0);
});
it('does not show separator next to active tab', () => {
const tabs = [
createTab({ id: 'tab-1', name: 'Tab 1' }),
createTab({ id: 'tab-2', name: 'Tab 2' }),
];
const { container } = render(
<TabBar
tabs={tabs}
activeTabId="tab-2"
theme={mockTheme}
onTabSelect={mockOnTabSelect}
onTabClose={mockOnTabClose}
onNewTab={mockOnNewTab}
/>
);
// No separator when active tab is involved
const separators = container.querySelectorAll('.w-px');
// Separator should not appear before tab-2 (which is active)
expect(separators.length).toBe(0);
});
});
describe('scroll behavior', () => {
it('scrolls active tab into view when activeTabId changes', async () => {
// Mock requestAnimationFrame
const rafSpy = vi.spyOn(window, 'requestAnimationFrame').mockImplementation((cb) => {
cb(0);
return 0;
});
const scrollIntoViewSpy = vi.fn();
const tabs = [
createTab({ id: 'tab-1', name: 'Tab 1' }),
createTab({ id: 'tab-2', name: 'Tab 2' }),
];
const { rerender, container } = render(
<TabBar
tabs={tabs}
activeTabId="tab-1"
theme={mockTheme}
onTabSelect={mockOnTabSelect}
onTabClose={mockOnTabClose}
onNewTab={mockOnNewTab}
/>
);
// Mock scrollIntoView on the tab elements
const tabElements = container.querySelectorAll('[data-tab-id]');
tabElements.forEach((el) => {
(el as HTMLElement).scrollIntoView = scrollIntoViewSpy;
});
// Change active tab
rerender(
<TabBar
tabs={tabs}
activeTabId="tab-2"
theme={mockTheme}
onTabSelect={mockOnTabSelect}
onTabClose={mockOnTabClose}
onNewTab={mockOnNewTab}
/>
);
// Re-mock scrollIntoView on tab elements after rerender
const newTabElements = container.querySelectorAll('[data-tab-id]');
newTabElements.forEach((el) => {
(el as HTMLElement).scrollIntoView = scrollIntoViewSpy;
});
// scrollIntoView should have been called via requestAnimationFrame
expect(scrollIntoViewSpy).toHaveBeenCalledWith({
inline: 'nearest',
behavior: 'smooth',
block: 'nearest',
});
rafSpy.mockRestore();
});
it('scrolls active tab into view when showUnreadOnly filter is toggled off', async () => {
// Mock requestAnimationFrame
const rafSpy = vi.spyOn(window, 'requestAnimationFrame').mockImplementation((cb) => {
cb(0);
return 0;
});
const scrollIntoViewSpy = vi.fn();
const tabs = [
createTab({ id: 'tab-1', name: 'Tab 1' }),
createTab({ id: 'tab-2', name: 'Tab 2', hasUnread: true }),
createTab({ id: 'tab-3', name: 'Tab 3' }),
];
const { rerender, container } = render(
<TabBar
tabs={tabs}
activeTabId="tab-3"
theme={mockTheme}
onTabSelect={mockOnTabSelect}
onTabClose={mockOnTabClose}
onNewTab={mockOnNewTab}
showUnreadOnly={true}
/>
);
// Mock scrollIntoView on the tab elements
const tabElements = container.querySelectorAll('[data-tab-id]');
tabElements.forEach((el) => {
(el as HTMLElement).scrollIntoView = scrollIntoViewSpy;
});
// Clear initial calls
scrollIntoViewSpy.mockClear();
// Toggle filter off - this should trigger scroll to active tab
rerender(
<TabBar
tabs={tabs}
activeTabId="tab-3"
theme={mockTheme}
onTabSelect={mockOnTabSelect}
onTabClose={mockOnTabClose}
onNewTab={mockOnNewTab}
showUnreadOnly={false}
/>
);
// Re-mock scrollIntoView on tab elements after rerender
const newTabElements = container.querySelectorAll('[data-tab-id]');
newTabElements.forEach((el) => {
(el as HTMLElement).scrollIntoView = scrollIntoViewSpy;
});
// scrollIntoView should have been called when filter was toggled
expect(scrollIntoViewSpy).toHaveBeenCalledWith({
inline: 'nearest',
behavior: 'smooth',
block: 'nearest',
});
rafSpy.mockRestore();
});
it('scrolls file tab into view when activeFileTabId changes', async () => {
// Mock requestAnimationFrame
const rafSpy = vi.spyOn(window, 'requestAnimationFrame').mockImplementation((cb) => {
cb(0);
return 0;
});
const scrollIntoViewSpy = vi.fn();
const tabs = [createTab({ id: 'tab-1', name: 'Tab 1' })];
const fileTab: FilePreviewTab = {
id: 'file-1',
path: '/path/to/file.ts',
name: 'file',
extension: '.ts',
};
const unifiedTabs = [
{ id: 'tab-1', type: 'ai' as const, data: tabs[0] },
{ id: 'file-1', type: 'file' as const, data: fileTab },
];
const { rerender, container } = render(
<TabBar
tabs={tabs}
activeTabId="tab-1"
theme={mockTheme}
onTabSelect={mockOnTabSelect}
onTabClose={mockOnTabClose}
onNewTab={mockOnNewTab}
unifiedTabs={unifiedTabs}
activeFileTabId={null}
onFileTabSelect={vi.fn()}
onFileTabClose={vi.fn()}
/>
);
// Mock scrollIntoView on the tab elements
const tabElements = container.querySelectorAll('[data-tab-id]');
tabElements.forEach((el) => {
(el as HTMLElement).scrollIntoView = scrollIntoViewSpy;
});
// Clear initial calls
scrollIntoViewSpy.mockClear();
// Select the file tab - this should trigger scroll to file tab
rerender(
<TabBar
tabs={tabs}
activeTabId="tab-1"
theme={mockTheme}
onTabSelect={mockOnTabSelect}
onTabClose={mockOnTabClose}
onNewTab={mockOnNewTab}
unifiedTabs={unifiedTabs}
activeFileTabId="file-1"
onFileTabSelect={vi.fn()}
onFileTabClose={vi.fn()}
/>
);
// Re-mock scrollIntoView on tab elements after rerender
const newTabElements = container.querySelectorAll('[data-tab-id]');
newTabElements.forEach((el) => {
(el as HTMLElement).scrollIntoView = scrollIntoViewSpy;
});
// scrollIntoView should have been called when file tab was selected
expect(scrollIntoViewSpy).toHaveBeenCalledWith({
inline: 'nearest',
behavior: 'smooth',
block: 'nearest',
});
rafSpy.mockRestore();
});
});
describe('styling', () => {
it('applies theme colors correctly', () => {
const tabs = [createTab({ id: 'tab-1', name: 'Tab 1' })];
const { container } = render(
<TabBar
tabs={tabs}
activeTabId="tab-1"
theme={mockTheme}
onTabSelect={mockOnTabSelect}
onTabClose={mockOnTabClose}
onNewTab={mockOnNewTab}
/>
);
const tabBar = container.firstChild as HTMLElement;
expect(tabBar).toHaveStyle({ backgroundColor: mockTheme.colors.bgSidebar });
expect(tabBar).toHaveStyle({ borderColor: mockTheme.colors.border });
});
it('applies hover effect on inactive tabs', () => {
const tabs = [
createTab({ id: 'tab-1', name: 'Tab 1' }),
createTab({ id: 'tab-2', name: 'Tab 2' }),
];
render(
<TabBar
tabs={tabs}
activeTabId="tab-1"
theme={mockTheme}
onTabSelect={mockOnTabSelect}
onTabClose={mockOnTabClose}
onNewTab={mockOnNewTab}
/>
);
const inactiveTab = screen.getByText('Tab 2').closest('[data-tab-id]')! as HTMLElement;
// Before hover - check inline style is not hover state
const initialBgColor = inactiveTab.style.backgroundColor;
expect(initialBgColor).not.toBe('rgba(255, 255, 255, 0.08)');
// Hover
fireEvent.mouseEnter(inactiveTab);
expect(inactiveTab.style.backgroundColor).toBe('rgba(255, 255, 255, 0.08)');
// Leave
fireEvent.mouseLeave(inactiveTab);
// After the timeout the state is set
act(() => {
vi.advanceTimersByTime(150);
});
// Background color should no longer be hover state
expect(inactiveTab.style.backgroundColor).not.toBe('rgba(255, 255, 255, 0.08)');
});
it('does not set title attribute on tabs (removed for cleaner UX)', () => {
// Tab title tooltips were intentionally removed to streamline the tab interaction feel
const tabs = [
createTab({
id: 'tab-1',
name: 'My Tab',
agentSessionId: 'session-123',
}),
];
render(
<TabBar
tabs={tabs}
activeTabId="tab-1"
theme={mockTheme}
onTabSelect={mockOnTabSelect}
onTabClose={mockOnTabClose}
onNewTab={mockOnNewTab}
/>
);
const tab = screen.getByText('My Tab').closest('[data-tab-id]')!;
expect(tab).not.toHaveAttribute('title');
});
});
describe('edge cases', () => {
it('handles empty tabs array', () => {
render(
<TabBar
tabs={[]}
activeTabId="nonexistent"
theme={mockTheme}
onTabSelect={mockOnTabSelect}
onTabClose={mockOnTabClose}
onNewTab={mockOnNewTab}
/>
);
// Should still render the new tab button
expect(screen.getByTitle('New tab (Cmd+T)')).toBeInTheDocument();
});
it('handles special characters in tab names', () => {
const tabs = [
createTab({
id: 'tab-1',
name: '<script>alert("xss")</script>',
}),
];
render(
<TabBar
tabs={tabs}
activeTabId="tab-1"
theme={mockTheme}
onTabSelect={mockOnTabSelect}
onTabClose={mockOnTabClose}
onNewTab={mockOnNewTab}
/>
);
// Text should be escaped, not executed
expect(screen.getByText('<script>alert("xss")</script>')).toBeInTheDocument();
});
it('handles unicode in tab names', () => {
const tabs = [
createTab({
id: 'tab-1',
name: '🎵 Music Tab 日本語',
}),
];
render(
<TabBar
tabs={tabs}
activeTabId="tab-1"
theme={mockTheme}
onTabSelect={mockOnTabSelect}
onTabClose={mockOnTabClose}
onNewTab={mockOnNewTab}
/>
);
expect(screen.getByText('🎵 Music Tab 日本語')).toBeInTheDocument();
});
it('handles very long tab names with truncation for inactive tabs', () => {
const longName = 'This is a very long tab name that should be truncated';
const tabs = [
createTab({ id: 'tab-1', name: 'Active Tab' }),
createTab({ id: 'tab-2', name: longName }),
];
render(
<TabBar
tabs={tabs}
activeTabId="tab-1"
theme={mockTheme}
onTabSelect={mockOnTabSelect}
onTabClose={mockOnTabClose}
onNewTab={mockOnNewTab}
/>
);
// Inactive tab should be truncated
const inactiveTabName = screen.getByText(longName);
expect(inactiveTabName).toHaveClass('truncate');
expect(inactiveTabName).toHaveClass('max-w-[120px]');
// Active tab should show full name without truncation
const activeTabName = screen.getByText('Active Tab');
expect(activeTabName).toHaveClass('whitespace-nowrap');
expect(activeTabName).not.toHaveClass('truncate');
});
it('handles many tabs', () => {
const tabs = Array.from({ length: 50 }, (_, i) =>
createTab({ id: `tab-${i}`, name: `Tab ${i + 1}` })
);
render(
<TabBar
tabs={tabs}
activeTabId="tab-0"
theme={mockTheme}
onTabSelect={mockOnTabSelect}
onTabClose={mockOnTabClose}
onNewTab={mockOnNewTab}
/>
);
expect(screen.getByText('Tab 1')).toBeInTheDocument();
expect(screen.getByText('Tab 50')).toBeInTheDocument();
});
it('handles whitespace-only inputValue (no draft indicator)', () => {
const tabs = [
createTab({
id: 'tab-1',
name: 'Tab 1',
inputValue: ' ', // whitespace only
}),
];
render(
<TabBar
tabs={tabs}
activeTabId="tab-1"
theme={mockTheme}
onTabSelect={mockOnTabSelect}
onTabClose={mockOnTabClose}
onNewTab={mockOnNewTab}
/>
);
expect(screen.queryByTitle('Has draft message')).not.toBeInTheDocument();
});
it('handles empty stagedImages array (no draft indicator)', () => {
const tabs = [
createTab({
id: 'tab-1',
name: 'Tab 1',
stagedImages: [],
}),
];
render(
<TabBar
tabs={tabs}
activeTabId="tab-1"
theme={mockTheme}
onTabSelect={mockOnTabSelect}
onTabClose={mockOnTabClose}
onNewTab={mockOnNewTab}
/>
);
expect(screen.queryByTitle('Has draft message')).not.toBeInTheDocument();
});
it('handles rapid tab selection', () => {
const tabs = [
createTab({ id: 'tab-1', name: 'Tab 1' }),
createTab({ id: 'tab-2', name: 'Tab 2' }),
createTab({ id: 'tab-3', name: 'Tab 3' }),
];
render(
<TabBar
tabs={tabs}
activeTabId="tab-1"
theme={mockTheme}
onTabSelect={mockOnTabSelect}
onTabClose={mockOnTabClose}
onNewTab={mockOnNewTab}
/>
);
fireEvent.click(screen.getByText('Tab 2'));
fireEvent.click(screen.getByText('Tab 3'));
fireEvent.click(screen.getByText('Tab 1'));
expect(mockOnTabSelect).toHaveBeenCalledTimes(3);
expect(mockOnTabSelect).toHaveBeenNthCalledWith(1, 'tab-2');
expect(mockOnTabSelect).toHaveBeenNthCalledWith(2, 'tab-3');
expect(mockOnTabSelect).toHaveBeenNthCalledWith(3, 'tab-1');
});
});
describe('overflow detection', () => {
it('makes new tab button sticky when tabs overflow', () => {
// Mock scrollWidth > clientWidth
const originalRef = React.useRef;
vi.spyOn(React, 'useRef').mockImplementation((initial) => {
const ref = originalRef(initial);
if (ref.current === null) {
Object.defineProperty(ref, 'current', {
get: () => ({
scrollWidth: 1000,
clientWidth: 500,
querySelector: vi.fn().mockReturnValue({
offsetLeft: 100,
offsetWidth: 80,
scrollIntoView: vi.fn(),
}),
scrollTo: vi.fn(),
}),
set: () => {},
});
}
return ref;
});
const tabs = Array.from({ length: 20 }, (_, i) =>
createTab({ id: `tab-${i}`, name: `Tab ${i + 1}` })
);
render(
<TabBar
tabs={tabs}
activeTabId="tab-0"
theme={mockTheme}
onTabSelect={mockOnTabSelect}
onTabClose={mockOnTabClose}
onNewTab={mockOnNewTab}
/>
);
// Wait for overflow check
act(() => {
vi.advanceTimersByTime(100);
});
vi.restoreAllMocks();
});
});
describe('tab hover overlay menu (tab move operations)', () => {
it('shows "Move to First Position" for non-first tabs', () => {
const tabs = [
createTab({ id: 'tab-1', name: 'Tab 1', agentSessionId: 'session-1' }),
createTab({ id: 'tab-2', name: 'Tab 2', agentSessionId: 'session-2' }),
createTab({ id: 'tab-3', name: 'Tab 3', agentSessionId: 'session-3' }),
];
render(
<TabBar
tabs={tabs}
activeTabId="tab-2"
theme={mockTheme}
onTabSelect={mockOnTabSelect}
onTabClose={mockOnTabClose}
onNewTab={mockOnNewTab}
onTabReorder={mockOnTabReorder}
/>
);
const tab = screen.getByText('Tab 2').closest('[data-tab-id]')!;
fireEvent.mouseEnter(tab);
act(() => {
vi.advanceTimersByTime(450);
});
expect(screen.getByText('Move to First Position')).toBeInTheDocument();
expect(screen.getByText('Move to Last Position')).toBeInTheDocument();
});
it('hides "Move to First Position" when hovering first tab', () => {
const tabs = [
createTab({ id: 'tab-1', name: 'Tab 1', agentSessionId: 'session-1' }),
createTab({ id: 'tab-2', name: 'Tab 2', agentSessionId: 'session-2' }),
];
render(
<TabBar
tabs={tabs}
activeTabId="tab-1"
theme={mockTheme}
onTabSelect={mockOnTabSelect}
onTabClose={mockOnTabClose}
onNewTab={mockOnNewTab}
onTabReorder={mockOnTabReorder}
/>
);
const tab = screen.getByText('Tab 1').closest('[data-tab-id]')!;
fireEvent.mouseEnter(tab);
act(() => {
vi.advanceTimersByTime(450);
});
// Move to First Position is hidden on first tab
expect(screen.queryByText('Move to First Position')).not.toBeInTheDocument();
// Move to Last Position is shown
expect(screen.getByText('Move to Last Position')).toBeInTheDocument();
});
it('hides "Move to Last Position" when hovering last tab', () => {
const tabs = [
createTab({ id: 'tab-1', name: 'Tab 1', agentSessionId: 'session-1' }),
createTab({ id: 'tab-2', name: 'Tab 2', agentSessionId: 'session-2' }),
];
render(
<TabBar
tabs={tabs}
activeTabId="tab-1"
theme={mockTheme}
onTabSelect={mockOnTabSelect}
onTabClose={mockOnTabClose}
onNewTab={mockOnNewTab}
onTabReorder={mockOnTabReorder}
/>
);
const tab = screen.getByText('Tab 2').closest('[data-tab-id]')!;
fireEvent.mouseEnter(tab);
act(() => {
vi.advanceTimersByTime(450);
});
// Move to Last Position is hidden on last tab
expect(screen.queryByText('Move to Last Position')).not.toBeInTheDocument();
// Move to First Position is shown
expect(screen.getByText('Move to First Position')).toBeInTheDocument();
});
it('hides both move options when only one tab exists', () => {
const tabs = [createTab({ id: 'tab-1', name: 'Tab 1', agentSessionId: 'session-1' })];
render(
<TabBar
tabs={tabs}
activeTabId="tab-1"
theme={mockTheme}
onTabSelect={mockOnTabSelect}
onTabClose={mockOnTabClose}
onNewTab={mockOnNewTab}
onTabReorder={mockOnTabReorder}
/>
);
const tab = screen.getByText('Tab 1').closest('[data-tab-id]')!;
fireEvent.mouseEnter(tab);
act(() => {
vi.advanceTimersByTime(450);
});
// Both move options are hidden when only one tab exists
expect(screen.queryByText('Move to First Position')).not.toBeInTheDocument();
expect(screen.queryByText('Move to Last Position')).not.toBeInTheDocument();
});
it('calls onTabReorder when "Move to First Position" is clicked', () => {
const tabs = [
createTab({ id: 'tab-1', name: 'Tab 1', agentSessionId: 'session-1' }),
createTab({ id: 'tab-2', name: 'Tab 2', agentSessionId: 'session-2' }),
createTab({ id: 'tab-3', name: 'Tab 3', agentSessionId: 'session-3' }),
];
render(
<TabBar
tabs={tabs}
activeTabId="tab-2"
theme={mockTheme}
onTabSelect={mockOnTabSelect}
onTabClose={mockOnTabClose}
onNewTab={mockOnNewTab}
onTabReorder={mockOnTabReorder}
/>
);
const tab = screen.getByText('Tab 3').closest('[data-tab-id]')!;
fireEvent.mouseEnter(tab);
act(() => {
vi.advanceTimersByTime(450);
});
fireEvent.click(screen.getByText('Move to First Position'));
// Should reorder from index 2 to index 0
expect(mockOnTabReorder).toHaveBeenCalledWith(2, 0);
});
it('calls onTabReorder when "Move to Last Position" is clicked', () => {
const tabs = [
createTab({ id: 'tab-1', name: 'Tab 1', agentSessionId: 'session-1' }),
createTab({ id: 'tab-2', name: 'Tab 2', agentSessionId: 'session-2' }),
createTab({ id: 'tab-3', name: 'Tab 3', agentSessionId: 'session-3' }),
];
render(
<TabBar
tabs={tabs}
activeTabId="tab-2"
theme={mockTheme}
onTabSelect={mockOnTabSelect}
onTabClose={mockOnTabClose}
onNewTab={mockOnNewTab}
onTabReorder={mockOnTabReorder}
/>
);
const tab = screen.getByText('Tab 1').closest('[data-tab-id]')!;
fireEvent.mouseEnter(tab);
act(() => {
vi.advanceTimersByTime(450);
});
fireEvent.click(screen.getByText('Move to Last Position'));
// Should reorder from index 0 to index 2
expect(mockOnTabReorder).toHaveBeenCalledWith(0, 2);
});
it('does not show move options when onTabReorder is not provided', () => {
const tabs = [
createTab({ id: 'tab-1', name: 'Tab 1', agentSessionId: 'session-1' }),
createTab({ id: 'tab-2', name: 'Tab 2', agentSessionId: 'session-2' }),
];
render(
<TabBar
tabs={tabs}
activeTabId="tab-1"
theme={mockTheme}
onTabSelect={mockOnTabSelect}
onTabClose={mockOnTabClose}
onNewTab={mockOnNewTab}
// onTabReorder not provided
/>
);
const tab = screen.getByText('Tab 2').closest('[data-tab-id]')!;
fireEvent.mouseEnter(tab);
act(() => {
vi.advanceTimersByTime(450);
});
// Move options should not be shown without onTabReorder
expect(screen.queryByText('Move to First Position')).not.toBeInTheDocument();
expect(screen.queryByText('Move to Last Position')).not.toBeInTheDocument();
});
it('closes overlay menu after move action', () => {
const tabs = [
createTab({ id: 'tab-1', name: 'Tab 1', agentSessionId: 'session-1' }),
createTab({ id: 'tab-2', name: 'Tab 2', agentSessionId: 'session-2' }),
];
render(
<TabBar
tabs={tabs}
activeTabId="tab-1"
theme={mockTheme}
onTabSelect={mockOnTabSelect}
onTabClose={mockOnTabClose}
onNewTab={mockOnNewTab}
onTabReorder={mockOnTabReorder}
/>
);
const tab = screen.getByText('Tab 2').closest('[data-tab-id]')!;
fireEvent.mouseEnter(tab);
act(() => {
vi.advanceTimersByTime(450);
});
expect(screen.getByText('Move to First Position')).toBeInTheDocument();
fireEvent.click(screen.getByText('Move to First Position'));
// Overlay should be closed after clicking Move
expect(screen.queryByText('Move to First Position')).not.toBeInTheDocument();
});
it('renders ChevronsLeft icon for Move to First Position', () => {
const tabs = [
createTab({ id: 'tab-1', name: 'Tab 1', agentSessionId: 'session-1' }),
createTab({ id: 'tab-2', name: 'Tab 2', agentSessionId: 'session-2' }),
];
render(
<TabBar
tabs={tabs}
activeTabId="tab-1"
theme={mockTheme}
onTabSelect={mockOnTabSelect}
onTabClose={mockOnTabClose}
onNewTab={mockOnNewTab}
onTabReorder={mockOnTabReorder}
/>
);
const tab = screen.getByText('Tab 2').closest('[data-tab-id]')!;
fireEvent.mouseEnter(tab);
act(() => {
vi.advanceTimersByTime(450);
});
expect(screen.getByTestId('chevrons-left-icon')).toBeInTheDocument();
});
it('renders ChevronsRight icon for Move to Last Position', () => {
const tabs = [
createTab({ id: 'tab-1', name: 'Tab 1', agentSessionId: 'session-1' }),
createTab({ id: 'tab-2', name: 'Tab 2', agentSessionId: 'session-2' }),
];
render(
<TabBar
tabs={tabs}
activeTabId="tab-1"
theme={mockTheme}
onTabSelect={mockOnTabSelect}
onTabClose={mockOnTabClose}
onNewTab={mockOnNewTab}
onTabReorder={mockOnTabReorder}
/>
);
const tab = screen.getByText('Tab 1').closest('[data-tab-id]')!;
fireEvent.mouseEnter(tab);
act(() => {
vi.advanceTimersByTime(450);
});
expect(screen.getByTestId('chevrons-right-icon')).toBeInTheDocument();
});
it('handles overlay menu on different tabs with proper move options', () => {
const tabs = [
createTab({ id: 'tab-1', name: 'Tab 1', agentSessionId: 'session-1' }),
createTab({ id: 'tab-2', name: 'Tab 2', agentSessionId: 'session-2' }),
createTab({ id: 'tab-3', name: 'Tab 3', agentSessionId: 'session-3' }),
];
render(
<TabBar
tabs={tabs}
activeTabId="tab-1"
theme={mockTheme}
onTabSelect={mockOnTabSelect}
onTabClose={mockOnTabClose}
onNewTab={mockOnNewTab}
onTabReorder={mockOnTabReorder}
/>
);
// Open overlay menu on Tab 1 (first tab)
const tab1 = screen.getByText('Tab 1').closest('[data-tab-id]')!;
fireEvent.mouseEnter(tab1);
act(() => {
vi.advanceTimersByTime(450);
});
// Move to First Position is hidden on first tab
expect(screen.queryByText('Move to First Position')).not.toBeInTheDocument();
// Move to Last Position is shown on first tab
expect(screen.getByText('Move to Last Position')).toBeInTheDocument();
// Close menu by hovering away
fireEvent.mouseLeave(tab1);
// Open overlay menu on Tab 3 (last tab)
const tab3 = screen.getByText('Tab 3').closest('[data-tab-id]')!;
fireEvent.mouseEnter(tab3);
act(() => {
vi.advanceTimersByTime(450);
});
// Move to Last Position is hidden on last tab
expect(screen.queryByText('Move to Last Position')).not.toBeInTheDocument();
// Move to First Position is shown on last tab
expect(screen.getByText('Move to First Position')).toBeInTheDocument();
});
it('overlay menu works with many tabs', () => {
const tabs = Array.from({ length: 20 }, (_, i) =>
createTab({ id: `tab-${i}`, name: `Tab ${i + 1}`, agentSessionId: `session-${i}` })
);
render(
<TabBar
tabs={tabs}
activeTabId="tab-10"
theme={mockTheme}
onTabSelect={mockOnTabSelect}
onTabClose={mockOnTabClose}
onNewTab={mockOnNewTab}
onTabReorder={mockOnTabReorder}
/>
);
const tab = screen.getByText('Tab 11').closest('[data-tab-id]')!;
fireEvent.mouseEnter(tab);
act(() => {
vi.advanceTimersByTime(450);
});
// Middle tab should show both move options
expect(screen.getByText('Move to First Position')).toBeInTheDocument();
expect(screen.getByText('Move to Last Position')).toBeInTheDocument();
});
});
describe('Send to Agent', () => {
const mockOnSendToAgent = vi.fn();
beforeEach(() => {
mockOnSendToAgent.mockClear();
});
it('shows Send to Agent button in hover overlay when onSendToAgent is provided', async () => {
const tabs = [
createTab({
id: 'tab-1',
name: 'Tab 1',
agentSessionId: 'abc123-def456',
}),
];
render(
<TabBar
tabs={tabs}
activeTabId="tab-1"
theme={mockTheme}
onTabSelect={mockOnTabSelect}
onTabClose={mockOnTabClose}
onNewTab={mockOnNewTab}
onSendToAgent={mockOnSendToAgent}
/>
);
const tab = screen.getByText('Tab 1').closest('[data-tab-id]')!;
fireEvent.mouseEnter(tab);
// Advance timers past the 400ms delay
act(() => {
vi.advanceTimersByTime(450);
});
// Send to Agent button should be visible
expect(screen.getByText('Context: Send to Agent')).toBeInTheDocument();
});
it('does not show Send to Agent button when onSendToAgent is not provided', async () => {
const tabs = [
createTab({
id: 'tab-1',
name: 'Tab 1',
agentSessionId: 'abc123-def456',
}),
];
render(
<TabBar
tabs={tabs}
activeTabId="tab-1"
theme={mockTheme}
onTabSelect={mockOnTabSelect}
onTabClose={mockOnTabClose}
onNewTab={mockOnNewTab}
/>
);
const tab = screen.getByText('Tab 1').closest('[data-tab-id]')!;
fireEvent.mouseEnter(tab);
act(() => {
vi.advanceTimersByTime(450);
});
// Send to Agent button should NOT be visible
expect(screen.queryByText('Context: Send to Agent')).not.toBeInTheDocument();
});
it('does not show Send to Agent button for tabs without agentSessionId', async () => {
const tabs = [
createTab({
id: 'tab-1',
name: '',
agentSessionId: undefined,
}),
];
render(
<TabBar
tabs={tabs}
activeTabId="tab-1"
theme={mockTheme}
onTabSelect={mockOnTabSelect}
onTabClose={mockOnTabClose}
onNewTab={mockOnNewTab}
onSendToAgent={mockOnSendToAgent}
/>
);
const tab = screen.getByText('New Session').closest('[data-tab-id]')!;
fireEvent.mouseEnter(tab);
act(() => {
vi.advanceTimersByTime(500);
});
// Overlay shouldn't be shown for tabs without agentSessionId
expect(screen.queryByText('Context: Send to Agent')).not.toBeInTheDocument();
});
it('calls onSendToAgent with tab id when Send to Agent button is clicked', async () => {
const tabs = [
createTab({
id: 'tab-1',
name: 'Tab 1',
agentSessionId: 'abc123-def456',
}),
];
render(
<TabBar
tabs={tabs}
activeTabId="tab-1"
theme={mockTheme}
onTabSelect={mockOnTabSelect}
onTabClose={mockOnTabClose}
onNewTab={mockOnNewTab}
onSendToAgent={mockOnSendToAgent}
/>
);
const tab = screen.getByText('Tab 1').closest('[data-tab-id]')!;
fireEvent.mouseEnter(tab);
act(() => {
vi.advanceTimersByTime(450);
});
const sendToAgentButton = screen.getByText('Context: Send to Agent');
fireEvent.click(sendToAgentButton);
expect(mockOnSendToAgent).toHaveBeenCalledWith('tab-1');
expect(mockOnSendToAgent).toHaveBeenCalledTimes(1);
});
it('closes overlay after clicking Send to Agent', async () => {
const tabs = [
createTab({
id: 'tab-1',
name: 'Tab 1',
agentSessionId: 'abc123-def456',
}),
];
render(
<TabBar
tabs={tabs}
activeTabId="tab-1"
theme={mockTheme}
onTabSelect={mockOnTabSelect}
onTabClose={mockOnTabClose}
onNewTab={mockOnNewTab}
onSendToAgent={mockOnSendToAgent}
/>
);
const tab = screen.getByText('Tab 1').closest('[data-tab-id]')!;
fireEvent.mouseEnter(tab);
act(() => {
vi.advanceTimersByTime(450);
});
// Click Send to Agent
const sendToAgentButton = screen.getByText('Context: Send to Agent');
fireEvent.click(sendToAgentButton);
// Overlay should be closed
expect(screen.queryByText('Context: Send to Agent')).not.toBeInTheDocument();
});
it('renders ArrowRightCircle icon for Send to Agent button', async () => {
const tabs = [
createTab({
id: 'tab-1',
name: 'Tab 1',
agentSessionId: 'abc123-def456',
}),
];
render(
<TabBar
tabs={tabs}
activeTabId="tab-1"
theme={mockTheme}
onTabSelect={mockOnTabSelect}
onTabClose={mockOnTabClose}
onNewTab={mockOnNewTab}
onSendToAgent={mockOnSendToAgent}
/>
);
const tab = screen.getByText('Tab 1').closest('[data-tab-id]')!;
fireEvent.mouseEnter(tab);
act(() => {
vi.advanceTimersByTime(450);
});
// The ArrowRightCircle icon should be present
expect(screen.getByTestId('arrow-right-circle-icon')).toBeInTheDocument();
});
});
describe('Publish as GitHub Gist', () => {
const mockOnPublishGist = vi.fn();
beforeEach(() => {
mockOnPublishGist.mockClear();
});
it('shows Publish as GitHub Gist button when onPublishGist and ghCliAvailable are provided and tab has logs', async () => {
const tabs = [
createTab({
id: 'tab-1',
name: 'Tab 1',
agentSessionId: 'abc123-def456',
logs: [{ id: '1', timestamp: Date.now(), source: 'user', text: 'Hello' }],
}),
];
render(
<TabBar
tabs={tabs}
activeTabId="tab-1"
theme={mockTheme}
onTabSelect={mockOnTabSelect}
onTabClose={mockOnTabClose}
onNewTab={mockOnNewTab}
onPublishGist={mockOnPublishGist}
ghCliAvailable={true}
/>
);
const tab = screen.getByText('Tab 1').closest('[data-tab-id]')!;
fireEvent.mouseEnter(tab);
act(() => {
vi.advanceTimersByTime(450);
});
expect(screen.getByText('Context: Publish as GitHub Gist')).toBeInTheDocument();
});
it('does not show Publish as GitHub Gist button when ghCliAvailable is false', async () => {
const tabs = [
createTab({
id: 'tab-1',
name: 'Tab 1',
agentSessionId: 'abc123-def456',
logs: [{ id: '1', timestamp: Date.now(), source: 'user', text: 'Hello' }],
}),
];
render(
<TabBar
tabs={tabs}
activeTabId="tab-1"
theme={mockTheme}
onTabSelect={mockOnTabSelect}
onTabClose={mockOnTabClose}
onNewTab={mockOnNewTab}
onPublishGist={mockOnPublishGist}
ghCliAvailable={false}
/>
);
const tab = screen.getByText('Tab 1').closest('[data-tab-id]')!;
fireEvent.mouseEnter(tab);
act(() => {
vi.advanceTimersByTime(450);
});
expect(screen.queryByText('Context: Publish as GitHub Gist')).not.toBeInTheDocument();
});
it('does not show Publish as GitHub Gist button when tab has no logs', async () => {
const tabs = [
createTab({
id: 'tab-1',
name: 'Tab 1',
agentSessionId: 'abc123-def456',
logs: [],
}),
];
render(
<TabBar
tabs={tabs}
activeTabId="tab-1"
theme={mockTheme}
onTabSelect={mockOnTabSelect}
onTabClose={mockOnTabClose}
onNewTab={mockOnNewTab}
onPublishGist={mockOnPublishGist}
ghCliAvailable={true}
/>
);
const tab = screen.getByText('Tab 1').closest('[data-tab-id]')!;
fireEvent.mouseEnter(tab);
act(() => {
vi.advanceTimersByTime(450);
});
expect(screen.queryByText('Context: Publish as GitHub Gist')).not.toBeInTheDocument();
});
it('calls onPublishGist with tab id when clicked', async () => {
const tabs = [
createTab({
id: 'tab-1',
name: 'Tab 1',
agentSessionId: 'abc123-def456',
logs: [{ id: '1', timestamp: Date.now(), source: 'user', text: 'Hello' }],
}),
];
render(
<TabBar
tabs={tabs}
activeTabId="tab-1"
theme={mockTheme}
onTabSelect={mockOnTabSelect}
onTabClose={mockOnTabClose}
onNewTab={mockOnNewTab}
onPublishGist={mockOnPublishGist}
ghCliAvailable={true}
/>
);
const tab = screen.getByText('Tab 1').closest('[data-tab-id]')!;
fireEvent.mouseEnter(tab);
act(() => {
vi.advanceTimersByTime(450);
});
const publishGistButton = screen.getByText('Context: Publish as GitHub Gist');
fireEvent.click(publishGistButton);
expect(mockOnPublishGist).toHaveBeenCalledWith('tab-1');
expect(mockOnPublishGist).toHaveBeenCalledTimes(1);
});
it('closes overlay after clicking Publish as GitHub Gist', async () => {
const tabs = [
createTab({
id: 'tab-1',
name: 'Tab 1',
agentSessionId: 'abc123-def456',
logs: [{ id: '1', timestamp: Date.now(), source: 'user', text: 'Hello' }],
}),
];
render(
<TabBar
tabs={tabs}
activeTabId="tab-1"
theme={mockTheme}
onTabSelect={mockOnTabSelect}
onTabClose={mockOnTabClose}
onNewTab={mockOnNewTab}
onPublishGist={mockOnPublishGist}
ghCliAvailable={true}
/>
);
const tab = screen.getByText('Tab 1').closest('[data-tab-id]')!;
fireEvent.mouseEnter(tab);
act(() => {
vi.advanceTimersByTime(450);
});
const publishGistButton = screen.getByText('Context: Publish as GitHub Gist');
fireEvent.click(publishGistButton);
expect(screen.queryByText('Context: Publish as GitHub Gist')).not.toBeInTheDocument();
});
it('renders Share2 icon for Publish as GitHub Gist button', async () => {
const tabs = [
createTab({
id: 'tab-1',
name: 'Tab 1',
agentSessionId: 'abc123-def456',
logs: [{ id: '1', timestamp: Date.now(), source: 'user', text: 'Hello' }],
}),
];
render(
<TabBar
tabs={tabs}
activeTabId="tab-1"
theme={mockTheme}
onTabSelect={mockOnTabSelect}
onTabClose={mockOnTabClose}
onNewTab={mockOnNewTab}
onPublishGist={mockOnPublishGist}
ghCliAvailable={true}
/>
);
const tab = screen.getByText('Tab 1').closest('[data-tab-id]')!;
fireEvent.mouseEnter(tab);
act(() => {
vi.advanceTimersByTime(450);
});
expect(screen.getByTestId('share2-icon')).toBeInTheDocument();
});
});
});
describe('FileTab overlay menu', () => {
const aiTab = createTab({ id: 'tab-1', name: 'AI Tab 1', agentSessionId: 'sess-1' });
const defaultTabs: AITab[] = [aiTab];
const fileTab: FilePreviewTab = {
id: 'file-tab-1',
path: '/path/to/document.md',
name: 'document',
extension: '.md',
content: '# Test Document\n\nThis is test content.',
scrollTop: 0,
searchQuery: '',
editMode: false,
editContent: undefined,
createdAt: Date.now(),
lastModified: Date.now(),
};
const unifiedTabs = [
{ type: 'ai' as const, id: 'tab-1', data: aiTab },
{ type: 'file' as const, id: 'file-tab-1', data: fileTab },
];
it('shows file overlay menu on hover after delay', async () => {
vi.useFakeTimers();
render(
<TabBar
tabs={defaultTabs}
activeTabId="tab-1"
theme={mockTheme}
onTabSelect={vi.fn()}
onTabClose={vi.fn()}
onNewTab={vi.fn()}
unifiedTabs={unifiedTabs}
activeFileTabId={null}
onFileTabSelect={vi.fn()}
onFileTabClose={vi.fn()}
/>
);
const fileTabElement = screen.getByText('document').closest('[data-tab-id="file-tab-1"]');
expect(fileTabElement).toBeInTheDocument();
// Hover over the file tab
await act(async () => {
fireEvent.mouseEnter(fileTabElement!);
});
// Overlay should not be visible immediately
expect(screen.queryByText('Copy File Path')).not.toBeInTheDocument();
// Wait for the delay
await act(async () => {
vi.advanceTimersByTime(450);
});
// Overlay should now be visible with file-specific actions
expect(screen.getByText('Copy File Path')).toBeInTheDocument();
expect(screen.getByText('Copy File Name')).toBeInTheDocument();
expect(screen.getByText('Open in Default App')).toBeInTheDocument();
expect(screen.getByText('Reveal in Finder')).toBeInTheDocument();
vi.useRealTimers();
});
it('shows file-specific actions in overlay menu', async () => {
vi.useFakeTimers();
render(
<TabBar
tabs={defaultTabs}
activeTabId="tab-1"
theme={mockTheme}
onTabSelect={vi.fn()}
onTabClose={vi.fn()}
onNewTab={vi.fn()}
unifiedTabs={unifiedTabs}
activeFileTabId={null}
onFileTabSelect={vi.fn()}
onFileTabClose={vi.fn()}
/>
);
const fileTabElement = screen.getByText('document').closest('[data-tab-id="file-tab-1"]');
await act(async () => {
fireEvent.mouseEnter(fileTabElement!);
vi.advanceTimersByTime(450);
});
// Should show file-specific actions (these are unique to file tabs)
expect(screen.getByText('Copy File Path')).toBeInTheDocument();
expect(screen.getByText('Open in Default App')).toBeInTheDocument();
expect(screen.getByText('Reveal in Finder')).toBeInTheDocument();
vi.useRealTimers();
});
it('copies file path to clipboard when clicking Copy File Path', async () => {
vi.useFakeTimers();
const mockWriteText = vi.fn().mockResolvedValue(undefined);
Object.defineProperty(navigator, 'clipboard', {
value: { writeText: mockWriteText },
writable: true,
});
render(
<TabBar
tabs={defaultTabs}
activeTabId="tab-1"
theme={mockTheme}
onTabSelect={vi.fn()}
onTabClose={vi.fn()}
onNewTab={vi.fn()}
unifiedTabs={unifiedTabs}
activeFileTabId={null}
onFileTabSelect={vi.fn()}
onFileTabClose={vi.fn()}
/>
);
const fileTabElement = screen.getByText('document').closest('[data-tab-id="file-tab-1"]');
await act(async () => {
fireEvent.mouseEnter(fileTabElement!);
vi.advanceTimersByTime(450);
});
const copyPathButton = screen.getByText('Copy File Path');
await act(async () => {
fireEvent.click(copyPathButton);
});
expect(mockWriteText).toHaveBeenCalledWith('/path/to/document.md');
expect(screen.getByText('Copied!')).toBeInTheDocument();
vi.useRealTimers();
});
it('copies filename with extension when clicking Copy File Name', async () => {
vi.useFakeTimers();
const mockWriteText = vi.fn().mockResolvedValue(undefined);
Object.defineProperty(navigator, 'clipboard', {
value: { writeText: mockWriteText },
writable: true,
});
render(
<TabBar
tabs={defaultTabs}
activeTabId="tab-1"
theme={mockTheme}
onTabSelect={vi.fn()}
onTabClose={vi.fn()}
onNewTab={vi.fn()}
unifiedTabs={unifiedTabs}
activeFileTabId={null}
onFileTabSelect={vi.fn()}
onFileTabClose={vi.fn()}
/>
);
const fileTabElement = screen.getByText('document').closest('[data-tab-id="file-tab-1"]');
await act(async () => {
fireEvent.mouseEnter(fileTabElement!);
vi.advanceTimersByTime(450);
});
const copyNameButton = screen.getByText('Copy File Name');
await act(async () => {
fireEvent.click(copyNameButton);
});
expect(mockWriteText).toHaveBeenCalledWith('document.md');
vi.useRealTimers();
});
it('calls openExternal when clicking Open in Default App', async () => {
vi.useFakeTimers();
const mockOpenExternal = vi.fn().mockResolvedValue(undefined);
window.maestro = {
...window.maestro,
shell: {
...window.maestro.shell,
openExternal: mockOpenExternal,
},
} as typeof window.maestro;
render(
<TabBar
tabs={defaultTabs}
activeTabId="tab-1"
theme={mockTheme}
onTabSelect={vi.fn()}
onTabClose={vi.fn()}
onNewTab={vi.fn()}
unifiedTabs={unifiedTabs}
activeFileTabId={null}
onFileTabSelect={vi.fn()}
onFileTabClose={vi.fn()}
/>
);
const fileTabElement = screen.getByText('document').closest('[data-tab-id="file-tab-1"]');
await act(async () => {
fireEvent.mouseEnter(fileTabElement!);
vi.advanceTimersByTime(450);
});
const openButton = screen.getByText('Open in Default App');
await act(async () => {
fireEvent.click(openButton);
});
expect(mockOpenExternal).toHaveBeenCalledWith('file:///path/to/document.md');
vi.useRealTimers();
});
it('calls showItemInFolder when clicking Reveal in Finder', async () => {
vi.useFakeTimers();
const mockShowItemInFolder = vi.fn().mockResolvedValue(undefined);
window.maestro = {
...window.maestro,
shell: {
...window.maestro.shell,
showItemInFolder: mockShowItemInFolder,
},
} as typeof window.maestro;
render(
<TabBar
tabs={defaultTabs}
activeTabId="tab-1"
theme={mockTheme}
onTabSelect={vi.fn()}
onTabClose={vi.fn()}
onNewTab={vi.fn()}
unifiedTabs={unifiedTabs}
activeFileTabId={null}
onFileTabSelect={vi.fn()}
onFileTabClose={vi.fn()}
/>
);
const fileTabElement = screen.getByText('document').closest('[data-tab-id="file-tab-1"]');
await act(async () => {
fireEvent.mouseEnter(fileTabElement!);
vi.advanceTimersByTime(450);
});
const revealButton = screen.getByText('Reveal in Finder');
await act(async () => {
fireEvent.click(revealButton);
});
expect(mockShowItemInFolder).toHaveBeenCalledWith('/path/to/document.md');
vi.useRealTimers();
});
it('shows Close Tab action and calls onFileTabClose when clicked', async () => {
vi.useFakeTimers();
const mockFileTabClose = vi.fn();
render(
<TabBar
tabs={defaultTabs}
activeTabId="tab-1"
theme={mockTheme}
onTabSelect={vi.fn()}
onTabClose={vi.fn()}
onNewTab={vi.fn()}
unifiedTabs={unifiedTabs}
activeFileTabId={null}
onFileTabSelect={vi.fn()}
onFileTabClose={mockFileTabClose}
/>
);
const fileTabElement = screen.getByText('document').closest('[data-tab-id="file-tab-1"]');
await act(async () => {
fireEvent.mouseEnter(fileTabElement!);
vi.advanceTimersByTime(450);
});
// Get all "Close Tab" buttons - find the one in the file tab overlay
// The overlay buttons are in a div with specific styling
const closeTabButtons = screen.getAllByText('Close Tab');
// The file tab's Close Tab button is in a standalone button (not the one with "X" icon prefix from AI tab overlay)
const closeButton = closeTabButtons.find((btn) =>
btn.closest('.shadow-xl')?.textContent?.includes('Copy File Path')
);
expect(closeButton).toBeTruthy();
await act(async () => {
fireEvent.click(closeButton!);
});
expect(mockFileTabClose).toHaveBeenCalledWith('file-tab-1');
vi.useRealTimers();
});
it('shows Close Other Tabs action and calls handler when clicked', async () => {
vi.useFakeTimers();
const mockCloseOtherTabs = vi.fn();
// Create multiple tabs to test Close Other Tabs
const fileTab2: FilePreviewTab = {
id: 'file-tab-2',
path: '/path/to/other.ts',
name: 'other',
extension: '.ts',
content: 'const y = 2;',
scrollTop: 0,
searchQuery: '',
editMode: false,
editContent: undefined,
createdAt: Date.now(),
lastModified: Date.now(),
};
const multiFileUnifiedTabs = [
{ type: 'ai' as const, id: 'tab-1', data: aiTab },
{ type: 'file' as const, id: 'file-tab-1', data: fileTab },
{ type: 'file' as const, id: 'file-tab-2', data: fileTab2 },
];
render(
<TabBar
tabs={defaultTabs}
activeTabId="tab-1"
theme={mockTheme}
onTabSelect={vi.fn()}
onTabClose={vi.fn()}
onNewTab={vi.fn()}
unifiedTabs={multiFileUnifiedTabs}
activeFileTabId={null}
onFileTabSelect={vi.fn()}
onFileTabClose={vi.fn()}
onCloseOtherTabs={mockCloseOtherTabs}
/>
);
const fileTabElement = screen.getByText('document').closest('[data-tab-id="file-tab-1"]');
await act(async () => {
fireEvent.mouseEnter(fileTabElement!);
vi.advanceTimersByTime(450);
});
// Should show Close Other Tabs option
const closeOtherButtons = screen.getAllByText('Close Other Tabs');
// Find the one in the file tab overlay (has Copy File Path action)
const closeOtherButton = closeOtherButtons.find((btn) =>
btn.closest('.shadow-xl')?.textContent?.includes('Copy File Path')
);
expect(closeOtherButton).toBeTruthy();
await act(async () => {
fireEvent.click(closeOtherButton!);
});
expect(mockCloseOtherTabs).toHaveBeenCalled();
vi.useRealTimers();
});
it('disables Close Other Tabs when only one tab exists', async () => {
vi.useFakeTimers();
const mockCloseOtherTabs = vi.fn();
// Single tab only
const singleTabUnified = [{ type: 'file' as const, id: 'file-tab-1', data: fileTab }];
render(
<TabBar
tabs={[]}
activeTabId=""
theme={mockTheme}
onTabSelect={vi.fn()}
onTabClose={vi.fn()}
onNewTab={vi.fn()}
unifiedTabs={singleTabUnified}
activeFileTabId="file-tab-1"
onFileTabSelect={vi.fn()}
onFileTabClose={vi.fn()}
onCloseOtherTabs={mockCloseOtherTabs}
/>
);
const fileTabElement = screen.getByText('document').closest('[data-tab-id="file-tab-1"]');
await act(async () => {
fireEvent.mouseEnter(fileTabElement!);
vi.advanceTimersByTime(450);
});
// Should show Close Other Tabs but disabled
const closeOtherButton = screen.getByText('Close Other Tabs');
expect(closeOtherButton).toBeInTheDocument();
expect(closeOtherButton.closest('button')).toHaveAttribute('disabled');
vi.useRealTimers();
});
it('shows Close Tabs to Left action and calls handler when clicked', async () => {
vi.useFakeTimers();
const mockCloseTabsLeft = vi.fn();
// Create multiple tabs - file tab in the middle
const fileTab2: FilePreviewTab = {
id: 'file-tab-2',
path: '/path/to/other.ts',
name: 'other',
extension: '.ts',
content: 'const y = 2;',
scrollTop: 0,
searchQuery: '',
editMode: false,
editContent: undefined,
createdAt: Date.now(),
lastModified: Date.now(),
};
// File tab is at index 1 (has tabs to the left)
const multiTabsUnified = [
{ type: 'ai' as const, id: 'tab-1', data: aiTab },
{ type: 'file' as const, id: 'file-tab-1', data: fileTab },
{ type: 'file' as const, id: 'file-tab-2', data: fileTab2 },
];
render(
<TabBar
tabs={defaultTabs}
activeTabId="tab-1"
theme={mockTheme}
onTabSelect={vi.fn()}
onTabClose={vi.fn()}
onNewTab={vi.fn()}
unifiedTabs={multiTabsUnified}
activeFileTabId={null}
onFileTabSelect={vi.fn()}
onFileTabClose={vi.fn()}
onCloseTabsLeft={mockCloseTabsLeft}
/>
);
// Hover over the middle tab (file-tab-1 at index 1)
const fileTabElement = screen.getByText('document').closest('[data-tab-id="file-tab-1"]');
await act(async () => {
fireEvent.mouseEnter(fileTabElement!);
vi.advanceTimersByTime(450);
});
// Should show Close Tabs to Left option
const closeLeftButtons = screen.getAllByText('Close Tabs to Left');
const closeLeftButton = closeLeftButtons.find((btn) =>
btn.closest('.shadow-xl')?.textContent?.includes('Copy File Path')
);
expect(closeLeftButton).toBeTruthy();
await act(async () => {
fireEvent.click(closeLeftButton!);
});
expect(mockCloseTabsLeft).toHaveBeenCalled();
vi.useRealTimers();
});
it('disables Close Tabs to Left for first tab', async () => {
vi.useFakeTimers();
const mockCloseTabsLeft = vi.fn();
// File tab is first
const fileFirstUnified = [
{ type: 'file' as const, id: 'file-tab-1', data: fileTab },
{ type: 'ai' as const, id: 'tab-1', data: aiTab },
];
render(
<TabBar
tabs={defaultTabs}
activeTabId="tab-1"
theme={mockTheme}
onTabSelect={vi.fn()}
onTabClose={vi.fn()}
onNewTab={vi.fn()}
unifiedTabs={fileFirstUnified}
activeFileTabId={null}
onFileTabSelect={vi.fn()}
onFileTabClose={vi.fn()}
onCloseTabsLeft={mockCloseTabsLeft}
/>
);
const fileTabElement = screen.getByText('document').closest('[data-tab-id="file-tab-1"]');
await act(async () => {
fireEvent.mouseEnter(fileTabElement!);
vi.advanceTimersByTime(450);
});
// Should show Close Tabs to Left but disabled (first tab)
const closeLeftButton = screen.getByText('Close Tabs to Left');
expect(closeLeftButton).toBeInTheDocument();
expect(closeLeftButton.closest('button')).toHaveAttribute('disabled');
vi.useRealTimers();
});
it('shows Close Tabs to Right action and calls handler when clicked', async () => {
vi.useFakeTimers();
const mockCloseTabsRight = vi.fn();
// Create multiple tabs - file tab in the middle
const fileTab2: FilePreviewTab = {
id: 'file-tab-2',
path: '/path/to/other.ts',
name: 'other',
extension: '.ts',
content: 'const y = 2;',
scrollTop: 0,
searchQuery: '',
editMode: false,
editContent: undefined,
createdAt: Date.now(),
lastModified: Date.now(),
};
// File tab is at index 1 (has tabs to the right)
const multiTabsUnified = [
{ type: 'ai' as const, id: 'tab-1', data: aiTab },
{ type: 'file' as const, id: 'file-tab-1', data: fileTab },
{ type: 'file' as const, id: 'file-tab-2', data: fileTab2 },
];
render(
<TabBar
tabs={defaultTabs}
activeTabId="tab-1"
theme={mockTheme}
onTabSelect={vi.fn()}
onTabClose={vi.fn()}
onNewTab={vi.fn()}
unifiedTabs={multiTabsUnified}
activeFileTabId={null}
onFileTabSelect={vi.fn()}
onFileTabClose={vi.fn()}
onCloseTabsRight={mockCloseTabsRight}
/>
);
// Hover over the middle tab (file-tab-1 at index 1)
const fileTabElement = screen.getByText('document').closest('[data-tab-id="file-tab-1"]');
await act(async () => {
fireEvent.mouseEnter(fileTabElement!);
vi.advanceTimersByTime(450);
});
// Should show Close Tabs to Right option
const closeRightButtons = screen.getAllByText('Close Tabs to Right');
const closeRightButton = closeRightButtons.find((btn) =>
btn.closest('.shadow-xl')?.textContent?.includes('Copy File Path')
);
expect(closeRightButton).toBeTruthy();
await act(async () => {
fireEvent.click(closeRightButton!);
});
expect(mockCloseTabsRight).toHaveBeenCalled();
vi.useRealTimers();
});
it('disables Close Tabs to Right for last tab', async () => {
vi.useFakeTimers();
const mockCloseTabsRight = vi.fn();
// File tab is last
const fileLastUnified = [
{ type: 'ai' as const, id: 'tab-1', data: aiTab },
{ type: 'file' as const, id: 'file-tab-1', data: fileTab },
];
render(
<TabBar
tabs={defaultTabs}
activeTabId="tab-1"
theme={mockTheme}
onTabSelect={vi.fn()}
onTabClose={vi.fn()}
onNewTab={vi.fn()}
unifiedTabs={fileLastUnified}
activeFileTabId={null}
onFileTabSelect={vi.fn()}
onFileTabClose={vi.fn()}
onCloseTabsRight={mockCloseTabsRight}
/>
);
const fileTabElement = screen.getByText('document').closest('[data-tab-id="file-tab-1"]');
await act(async () => {
fireEvent.mouseEnter(fileTabElement!);
vi.advanceTimersByTime(450);
});
// Should show Close Tabs to Right but disabled (last tab)
const closeRightButton = screen.getByText('Close Tabs to Right');
expect(closeRightButton).toBeInTheDocument();
expect(closeRightButton.closest('button')).toHaveAttribute('disabled');
vi.useRealTimers();
});
it('shows Move to First Position for non-first file tabs', async () => {
vi.useFakeTimers();
const mockUnifiedReorder = vi.fn();
// Put file tab in second position
const unifiedTabsWithFileSecond = [
{ type: 'ai' as const, id: 'tab-1', data: aiTab },
{ type: 'file' as const, id: 'file-tab-1', data: fileTab },
];
render(
<TabBar
tabs={defaultTabs}
activeTabId="tab-1"
theme={mockTheme}
onTabSelect={vi.fn()}
onTabClose={vi.fn()}
onNewTab={vi.fn()}
unifiedTabs={unifiedTabsWithFileSecond}
activeFileTabId={null}
onFileTabSelect={vi.fn()}
onFileTabClose={vi.fn()}
onUnifiedTabReorder={mockUnifiedReorder}
/>
);
const fileTabElement = screen.getByText('document').closest('[data-tab-id="file-tab-1"]');
await act(async () => {
fireEvent.mouseEnter(fileTabElement!);
vi.advanceTimersByTime(450);
});
// Should show Move to First Position
expect(screen.getByText('Move to First Position')).toBeInTheDocument();
vi.useRealTimers();
});
it('hides Move to First Position for first file tab', async () => {
vi.useFakeTimers();
const mockUnifiedReorder = vi.fn();
// Put file tab in first position
const unifiedTabsWithFileFirst = [
{ type: 'file' as const, id: 'file-tab-1', data: fileTab },
{ type: 'ai' as const, id: 'tab-1', data: aiTab },
];
render(
<TabBar
tabs={defaultTabs}
activeTabId="tab-1"
theme={mockTheme}
onTabSelect={vi.fn()}
onTabClose={vi.fn()}
onNewTab={vi.fn()}
unifiedTabs={unifiedTabsWithFileFirst}
activeFileTabId={null}
onFileTabSelect={vi.fn()}
onFileTabClose={vi.fn()}
onUnifiedTabReorder={mockUnifiedReorder}
/>
);
const fileTabElement = screen.getByText('document').closest('[data-tab-id="file-tab-1"]');
await act(async () => {
fireEvent.mouseEnter(fileTabElement!);
vi.advanceTimersByTime(450);
});
// Should NOT show Move to First Position
expect(screen.queryByText('Move to First Position')).not.toBeInTheDocument();
vi.useRealTimers();
});
it('closes overlay when mouse leaves', async () => {
vi.useFakeTimers();
render(
<TabBar
tabs={defaultTabs}
activeTabId="tab-1"
theme={mockTheme}
onTabSelect={vi.fn()}
onTabClose={vi.fn()}
onNewTab={vi.fn()}
unifiedTabs={unifiedTabs}
activeFileTabId={null}
onFileTabSelect={vi.fn()}
onFileTabClose={vi.fn()}
/>
);
const fileTabElement = screen.getByText('document').closest('[data-tab-id="file-tab-1"]');
// Hover to open overlay
await act(async () => {
fireEvent.mouseEnter(fileTabElement!);
vi.advanceTimersByTime(450);
});
expect(screen.getByText('Copy File Path')).toBeInTheDocument();
// Mouse leave from tab
await act(async () => {
fireEvent.mouseLeave(fileTabElement!);
vi.advanceTimersByTime(150); // Wait for close delay
});
// Overlay should be closed
expect(screen.queryByText('Copy File Path')).not.toBeInTheDocument();
vi.useRealTimers();
});
});
describe('Unified tabs drag and drop', () => {
const mockOnUnifiedTabReorder = vi.fn();
const mockOnTabReorder = vi.fn();
const mockOnFileTabSelect = vi.fn();
const mockOnFileTabClose = vi.fn();
beforeEach(() => {
vi.useFakeTimers();
vi.clearAllMocks();
Element.prototype.scrollTo = vi.fn();
});
afterEach(() => {
vi.useRealTimers();
});
const aiTab1 = createTab({ id: 'ai-tab-1', name: 'AI Tab 1', agentSessionId: 'sess-1' });
const aiTab2 = createTab({ id: 'ai-tab-2', name: 'AI Tab 2', agentSessionId: 'sess-2' });
const aiTabs: AITab[] = [aiTab1, aiTab2];
const fileTab1: FilePreviewTab = {
id: 'file-tab-1',
path: '/path/to/file1.ts',
name: 'file1',
extension: '.ts',
content: 'const x = 1;',
scrollTop: 0,
searchQuery: '',
editMode: false,
editContent: undefined,
createdAt: Date.now(),
lastModified: Date.now(),
};
const fileTab2: FilePreviewTab = {
id: 'file-tab-2',
path: '/path/to/file2.md',
name: 'file2',
extension: '.md',
content: '# File 2',
scrollTop: 0,
searchQuery: '',
editMode: false,
editContent: undefined,
createdAt: Date.now() + 1,
lastModified: Date.now() + 1,
};
// Unified tabs: AI, File, AI, File
const unifiedTabs = [
{ type: 'ai' as const, id: 'ai-tab-1', data: aiTab1 },
{ type: 'file' as const, id: 'file-tab-1', data: fileTab1 },
{ type: 'ai' as const, id: 'ai-tab-2', data: aiTab2 },
{ type: 'file' as const, id: 'file-tab-2', data: fileTab2 },
];
it('drags AI tab to file tab position and calls onUnifiedTabReorder', () => {
render(
<TabBar
tabs={aiTabs}
activeTabId="ai-tab-1"
theme={mockTheme}
onTabSelect={vi.fn()}
onTabClose={vi.fn()}
onNewTab={vi.fn()}
onTabReorder={mockOnTabReorder}
onUnifiedTabReorder={mockOnUnifiedTabReorder}
unifiedTabs={unifiedTabs}
activeFileTabId={null}
onFileTabSelect={mockOnFileTabSelect}
onFileTabClose={mockOnFileTabClose}
/>
);
const aiTabElement = screen.getByText('AI Tab 1').closest('[data-tab-id]')!;
const fileTabElement = screen.getByText('file1').closest('[data-tab-id]')!;
// Start dragging ai-tab-1
fireEvent.dragStart(aiTabElement, {
dataTransfer: {
effectAllowed: '',
setData: vi.fn(),
getData: vi.fn().mockReturnValue('ai-tab-1'),
},
});
// Drop on file-tab-1
fireEvent.drop(fileTabElement, {
dataTransfer: {
getData: vi.fn().mockReturnValue('ai-tab-1'),
},
});
// Should call onUnifiedTabReorder with indices in unified array (0 to 1)
expect(mockOnUnifiedTabReorder).toHaveBeenCalledWith(0, 1);
// Should NOT call legacy onTabReorder since unified is available
expect(mockOnTabReorder).not.toHaveBeenCalled();
});
it('drags file tab to AI tab position and calls onUnifiedTabReorder', () => {
render(
<TabBar
tabs={aiTabs}
activeTabId="ai-tab-1"
theme={mockTheme}
onTabSelect={vi.fn()}
onTabClose={vi.fn()}
onNewTab={vi.fn()}
onTabReorder={mockOnTabReorder}
onUnifiedTabReorder={mockOnUnifiedTabReorder}
unifiedTabs={unifiedTabs}
activeFileTabId={null}
onFileTabSelect={mockOnFileTabSelect}
onFileTabClose={mockOnFileTabClose}
/>
);
const fileTabElement = screen.getByText('file1').closest('[data-tab-id]')!;
const aiTabElement = screen.getByText('AI Tab 2').closest('[data-tab-id]')!;
// Start dragging file-tab-1 (index 1)
fireEvent.dragStart(fileTabElement, {
dataTransfer: {
effectAllowed: '',
setData: vi.fn(),
getData: vi.fn().mockReturnValue('file-tab-1'),
},
});
// Drop on ai-tab-2 (index 2)
fireEvent.drop(aiTabElement, {
dataTransfer: {
getData: vi.fn().mockReturnValue('file-tab-1'),
},
});
// Should call onUnifiedTabReorder (from index 1 to index 2)
expect(mockOnUnifiedTabReorder).toHaveBeenCalledWith(1, 2);
});
it('drags file tab to another file tab position', () => {
render(
<TabBar
tabs={aiTabs}
activeTabId="ai-tab-1"
theme={mockTheme}
onTabSelect={vi.fn()}
onTabClose={vi.fn()}
onNewTab={vi.fn()}
onTabReorder={mockOnTabReorder}
onUnifiedTabReorder={mockOnUnifiedTabReorder}
unifiedTabs={unifiedTabs}
activeFileTabId={null}
onFileTabSelect={mockOnFileTabSelect}
onFileTabClose={mockOnFileTabClose}
/>
);
const fileTab1Element = screen.getByText('file1').closest('[data-tab-id]')!;
const fileTab2Element = screen.getByText('file2').closest('[data-tab-id]')!;
// Start dragging file-tab-1 (index 1)
fireEvent.dragStart(fileTab1Element, {
dataTransfer: {
effectAllowed: '',
setData: vi.fn(),
getData: vi.fn().mockReturnValue('file-tab-1'),
},
});
// Drop on file-tab-2 (index 3)
fireEvent.drop(fileTab2Element, {
dataTransfer: {
getData: vi.fn().mockReturnValue('file-tab-1'),
},
});
// Should call onUnifiedTabReorder (from index 1 to index 3)
expect(mockOnUnifiedTabReorder).toHaveBeenCalledWith(1, 3);
});
it('does not reorder when dropping on the same tab', () => {
render(
<TabBar
tabs={aiTabs}
activeTabId="ai-tab-1"
theme={mockTheme}
onTabSelect={vi.fn()}
onTabClose={vi.fn()}
onNewTab={vi.fn()}
onUnifiedTabReorder={mockOnUnifiedTabReorder}
unifiedTabs={unifiedTabs}
activeFileTabId={null}
onFileTabSelect={mockOnFileTabSelect}
onFileTabClose={mockOnFileTabClose}
/>
);
const fileTabElement = screen.getByText('file1').closest('[data-tab-id]')!;
// Drop on same tab
fireEvent.drop(fileTabElement, {
dataTransfer: {
getData: vi.fn().mockReturnValue('file-tab-1'),
},
});
expect(mockOnUnifiedTabReorder).not.toHaveBeenCalled();
});
it('sets drag over visual feedback on target tab', () => {
render(
<TabBar
tabs={aiTabs}
activeTabId="ai-tab-1"
theme={mockTheme}
onTabSelect={vi.fn()}
onTabClose={vi.fn()}
onNewTab={vi.fn()}
onUnifiedTabReorder={mockOnUnifiedTabReorder}
unifiedTabs={unifiedTabs}
activeFileTabId={null}
onFileTabSelect={mockOnFileTabSelect}
onFileTabClose={mockOnFileTabClose}
/>
);
const aiTabElement = screen.getByText('AI Tab 1').closest('[data-tab-id]')!;
const fileTabElement = screen.getByText('file1').closest('[data-tab-id]')!;
// Start dragging AI tab
fireEvent.dragStart(aiTabElement, {
dataTransfer: {
effectAllowed: '',
setData: vi.fn(),
getData: vi.fn().mockReturnValue('ai-tab-1'),
},
});
// Drag over file tab
fireEvent.dragOver(fileTabElement, {
dataTransfer: {
dropEffect: '',
},
});
// File tab should have ring visual
expect(fileTabElement).toHaveClass('ring-2');
});
it('uses legacy onTabReorder when unifiedTabs is not provided', () => {
render(
<TabBar
tabs={aiTabs}
activeTabId="ai-tab-1"
theme={mockTheme}
onTabSelect={vi.fn()}
onTabClose={vi.fn()}
onNewTab={vi.fn()}
onTabReorder={mockOnTabReorder}
onUnifiedTabReorder={mockOnUnifiedTabReorder}
// No unifiedTabs provided - should fall back to legacy behavior
/>
);
const tab1 = screen.getByText('AI Tab 1').closest('[data-tab-id]')!;
const tab2 = screen.getByText('AI Tab 2').closest('[data-tab-id]')!;
// Start dragging tab-1
fireEvent.dragStart(tab1, {
dataTransfer: {
effectAllowed: '',
setData: vi.fn(),
getData: vi.fn().mockReturnValue('ai-tab-1'),
},
});
// Drop on tab-2
fireEvent.drop(tab2, {
dataTransfer: {
getData: vi.fn().mockReturnValue('ai-tab-1'),
},
});
// Should use legacy onTabReorder
expect(mockOnTabReorder).toHaveBeenCalledWith(0, 1);
// Should NOT call onUnifiedTabReorder
expect(mockOnUnifiedTabReorder).not.toHaveBeenCalled();
});
it('shows Move to First/Last for file tabs when not at edges', async () => {
render(
<TabBar
tabs={aiTabs}
activeTabId="ai-tab-1"
theme={mockTheme}
onTabSelect={vi.fn()}
onTabClose={vi.fn()}
onNewTab={vi.fn()}
onUnifiedTabReorder={mockOnUnifiedTabReorder}
unifiedTabs={unifiedTabs}
activeFileTabId={null}
onFileTabSelect={mockOnFileTabSelect}
onFileTabClose={mockOnFileTabClose}
/>
);
// Hover over file1 (index 1, not first or last)
const fileTabElement = screen.getByText('file1').closest('[data-tab-id]')!;
await act(async () => {
fireEvent.mouseEnter(fileTabElement);
vi.advanceTimersByTime(450);
});
// Should show both move options
expect(screen.getByText('Move to First Position')).toBeInTheDocument();
expect(screen.getByText('Move to Last Position')).toBeInTheDocument();
});
it('hides Move to First for first tab', async () => {
render(
<TabBar
tabs={aiTabs}
activeTabId="ai-tab-1"
theme={mockTheme}
onTabSelect={vi.fn()}
onTabClose={vi.fn()}
onNewTab={vi.fn()}
onUnifiedTabReorder={mockOnUnifiedTabReorder}
unifiedTabs={unifiedTabs}
activeFileTabId={null}
onFileTabSelect={mockOnFileTabSelect}
onFileTabClose={mockOnFileTabClose}
/>
);
// Hover over AI Tab 1 (index 0, first tab)
const aiTabElement = screen.getByText('AI Tab 1').closest('[data-tab-id]')!;
await act(async () => {
fireEvent.mouseEnter(aiTabElement);
vi.advanceTimersByTime(450);
});
// Move to First should be hidden (not just disabled)
expect(screen.queryByText('Move to First Position')).not.toBeInTheDocument();
// Move to Last should be visible
expect(screen.getByText('Move to Last Position')).toBeInTheDocument();
});
it('hides Move to Last for last tab', async () => {
render(
<TabBar
tabs={aiTabs}
activeTabId="ai-tab-1"
theme={mockTheme}
onTabSelect={vi.fn()}
onTabClose={vi.fn()}
onNewTab={vi.fn()}
onUnifiedTabReorder={mockOnUnifiedTabReorder}
unifiedTabs={unifiedTabs}
activeFileTabId={null}
onFileTabSelect={mockOnFileTabSelect}
onFileTabClose={mockOnFileTabClose}
/>
);
// Hover over file2 (index 3, last tab)
const fileTabElement = screen.getByText('file2').closest('[data-tab-id]')!;
await act(async () => {
fireEvent.mouseEnter(fileTabElement);
vi.advanceTimersByTime(450);
});
// Move to First should be visible
expect(screen.getByText('Move to First Position')).toBeInTheDocument();
// Move to Last should be hidden (not just disabled)
expect(screen.queryByText('Move to Last Position')).not.toBeInTheDocument();
});
it('calls onUnifiedTabReorder when Move to First is clicked on file tab', async () => {
render(
<TabBar
tabs={aiTabs}
activeTabId="ai-tab-1"
theme={mockTheme}
onTabSelect={vi.fn()}
onTabClose={vi.fn()}
onNewTab={vi.fn()}
onUnifiedTabReorder={mockOnUnifiedTabReorder}
unifiedTabs={unifiedTabs}
activeFileTabId={null}
onFileTabSelect={mockOnFileTabSelect}
onFileTabClose={mockOnFileTabClose}
/>
);
// Hover over file1 (index 1)
const fileTabElement = screen.getByText('file1').closest('[data-tab-id]')!;
await act(async () => {
fireEvent.mouseEnter(fileTabElement);
vi.advanceTimersByTime(450);
});
// Click Move to First
const moveButton = screen.getByText('Move to First Position');
fireEvent.click(moveButton);
// Should call onUnifiedTabReorder with index 1 -> 0
expect(mockOnUnifiedTabReorder).toHaveBeenCalledWith(1, 0);
});
it('calls onUnifiedTabReorder when Move to Last is clicked on file tab', async () => {
render(
<TabBar
tabs={aiTabs}
activeTabId="ai-tab-1"
theme={mockTheme}
onTabSelect={vi.fn()}
onTabClose={vi.fn()}
onNewTab={vi.fn()}
onUnifiedTabReorder={mockOnUnifiedTabReorder}
unifiedTabs={unifiedTabs}
activeFileTabId={null}
onFileTabSelect={mockOnFileTabSelect}
onFileTabClose={mockOnFileTabClose}
/>
);
// Hover over file1 (index 1)
const fileTabElement = screen.getByText('file1').closest('[data-tab-id]')!;
await act(async () => {
fireEvent.mouseEnter(fileTabElement);
vi.advanceTimersByTime(450);
});
// Click Move to Last
const moveButton = screen.getByText('Move to Last Position');
fireEvent.click(moveButton);
// Should call onUnifiedTabReorder with index 1 -> 3 (last index)
expect(mockOnUnifiedTabReorder).toHaveBeenCalledWith(1, 3);
});
it('middle-click closes file tab', () => {
render(
<TabBar
tabs={aiTabs}
activeTabId="ai-tab-1"
theme={mockTheme}
onTabSelect={vi.fn()}
onTabClose={vi.fn()}
onNewTab={vi.fn()}
onUnifiedTabReorder={mockOnUnifiedTabReorder}
unifiedTabs={unifiedTabs}
activeFileTabId={null}
onFileTabSelect={mockOnFileTabSelect}
onFileTabClose={mockOnFileTabClose}
/>
);
const fileTabElement = screen.getByText('file1').closest('[data-tab-id]')!;
// Middle-click on file tab
fireEvent.mouseDown(fileTabElement, { button: 1 });
expect(mockOnFileTabClose).toHaveBeenCalledWith('file-tab-1');
});
it('left-click does NOT close file tab', () => {
render(
<TabBar
tabs={aiTabs}
activeTabId="ai-tab-1"
theme={mockTheme}
onTabSelect={vi.fn()}
onTabClose={vi.fn()}
onNewTab={vi.fn()}
onUnifiedTabReorder={mockOnUnifiedTabReorder}
unifiedTabs={unifiedTabs}
activeFileTabId={null}
onFileTabSelect={mockOnFileTabSelect}
onFileTabClose={mockOnFileTabClose}
/>
);
const fileTabElement = screen.getByText('file1').closest('[data-tab-id]')!;
// Left-click on file tab (button: 0)
fireEvent.mouseDown(fileTabElement, { button: 0 });
// Should NOT close the tab
expect(mockOnFileTabClose).not.toHaveBeenCalled();
});
it('right-click does NOT close file tab', () => {
render(
<TabBar
tabs={aiTabs}
activeTabId="ai-tab-1"
theme={mockTheme}
onTabSelect={vi.fn()}
onTabClose={vi.fn()}
onNewTab={vi.fn()}
onUnifiedTabReorder={mockOnUnifiedTabReorder}
unifiedTabs={unifiedTabs}
activeFileTabId={null}
onFileTabSelect={mockOnFileTabSelect}
onFileTabClose={mockOnFileTabClose}
/>
);
const fileTabElement = screen.getByText('file1').closest('[data-tab-id]')!;
// Right-click on file tab (button: 2)
fireEvent.mouseDown(fileTabElement, { button: 2 });
// Should NOT close the tab
expect(mockOnFileTabClose).not.toHaveBeenCalled();
});
it('middle-click on AI tab still works in unified mode', () => {
const mockOnAiTabClose = vi.fn();
render(
<TabBar
tabs={aiTabs}
activeTabId="ai-tab-1"
theme={mockTheme}
onTabSelect={vi.fn()}
onTabClose={mockOnAiTabClose}
onNewTab={vi.fn()}
onUnifiedTabReorder={mockOnUnifiedTabReorder}
unifiedTabs={unifiedTabs}
activeFileTabId={null}
onFileTabSelect={mockOnFileTabSelect}
onFileTabClose={mockOnFileTabClose}
/>
);
const aiTabElement = screen.getByText('AI Tab 1').closest('[data-tab-id]')!;
// Middle-click on AI tab
fireEvent.mouseDown(aiTabElement, { button: 1 });
// Should call the AI tab close handler, not file tab close handler
expect(mockOnAiTabClose).toHaveBeenCalledWith('ai-tab-1');
expect(mockOnFileTabClose).not.toHaveBeenCalled();
});
});
describe('Unified active tab styling consistency', () => {
const mockOnTabSelect = vi.fn();
const mockOnTabClose = vi.fn();
const mockOnNewTab = vi.fn();
const mockOnFileTabSelect = vi.fn();
const mockOnFileTabClose = vi.fn();
beforeEach(() => {
vi.clearAllMocks();
});
it('applies same active styling to both AI tabs and file tabs', () => {
const aiTab = createTab({ id: 'ai-tab-1', name: 'AI Tab' });
const fileTab: FilePreviewTab = {
id: 'file-tab-1',
path: '/test/example.tsx',
name: 'example',
extension: '.tsx',
content: 'const Example = () => {};',
scrollTop: 0,
searchQuery: '',
editMode: false,
editContent: undefined,
createdAt: Date.now(),
lastModified: Date.now(),
};
const unifiedTabs = [
{ type: 'ai' as const, id: 'ai-tab-1', data: aiTab },
{ type: 'file' as const, id: 'file-tab-1', data: fileTab },
];
// Test 1: Active AI tab styling
const { rerender } = render(
<TabBar
tabs={[aiTab]}
activeTabId="ai-tab-1"
theme={mockTheme}
onTabSelect={mockOnTabSelect}
onTabClose={mockOnTabClose}
onNewTab={mockOnNewTab}
unifiedTabs={unifiedTabs}
activeFileTabId={null}
onFileTabSelect={mockOnFileTabSelect}
onFileTabClose={mockOnFileTabClose}
/>
);
const activeAiTab = screen.getByText('AI Tab').closest('[data-tab-id]')!;
expect(activeAiTab).toHaveStyle({ backgroundColor: mockTheme.colors.bgMain });
expect(activeAiTab).toHaveStyle({ borderTopLeftRadius: '6px' });
expect(activeAiTab).toHaveStyle({ borderTopRightRadius: '6px' });
expect(activeAiTab).toHaveStyle({ marginBottom: '-1px' });
expect(activeAiTab).toHaveStyle({ zIndex: '1' });
// Test 2: Active file tab styling - switch active tab
rerender(
<TabBar
tabs={[aiTab]}
activeTabId="ai-tab-1"
theme={mockTheme}
onTabSelect={mockOnTabSelect}
onTabClose={mockOnTabClose}
onNewTab={mockOnNewTab}
unifiedTabs={unifiedTabs}
activeFileTabId="file-tab-1"
onFileTabSelect={mockOnFileTabSelect}
onFileTabClose={mockOnFileTabClose}
/>
);
const activeFileTab = screen.getByText('example').closest('[data-tab-id]')!;
// File tabs should have the same active styling as AI tabs
expect(activeFileTab).toHaveStyle({ backgroundColor: mockTheme.colors.bgMain });
expect(activeFileTab).toHaveStyle({ borderTopLeftRadius: '6px' });
expect(activeFileTab).toHaveStyle({ borderTopRightRadius: '6px' });
expect(activeFileTab).toHaveStyle({ marginBottom: '-1px' });
expect(activeFileTab).toHaveStyle({ zIndex: '1' });
});
it('applies same inactive styling to both AI tabs and file tabs', () => {
const aiTab = createTab({ id: 'ai-tab-1', name: 'AI Tab' });
const fileTab: FilePreviewTab = {
id: 'file-tab-1',
path: '/test/example.tsx',
name: 'example',
extension: '.tsx',
content: 'const Example = () => {};',
scrollTop: 0,
searchQuery: '',
editMode: false,
editContent: undefined,
createdAt: Date.now(),
lastModified: Date.now(),
};
const unifiedTabs = [
{ type: 'ai' as const, id: 'ai-tab-1', data: aiTab },
{ type: 'file' as const, id: 'file-tab-1', data: fileTab },
];
// Render with AI tab active (file tab inactive)
render(
<TabBar
tabs={[aiTab]}
activeTabId="ai-tab-1"
theme={mockTheme}
onTabSelect={mockOnTabSelect}
onTabClose={mockOnTabClose}
onNewTab={mockOnNewTab}
unifiedTabs={unifiedTabs}
activeFileTabId={null}
onFileTabSelect={mockOnFileTabSelect}
onFileTabClose={mockOnFileTabClose}
/>
);
const inactiveFileTab = screen.getByText('example').closest('[data-tab-id]') as HTMLElement;
// Inactive file tab should NOT have the active background color (bright background)
// It may be transparent or empty depending on how JSDOM handles it
const bgColor = inactiveFileTab.style.backgroundColor;
expect(bgColor === 'transparent' || bgColor === '').toBe(true);
expect(inactiveFileTab).toHaveStyle({ marginBottom: '0' });
expect(inactiveFileTab).toHaveStyle({ zIndex: '0' });
});
it('file tab displays extension badge with file extension text', () => {
const aiTab = createTab({ id: 'ai-tab-1', name: 'AI Tab' });
const fileTab: FilePreviewTab = {
id: 'file-tab-1',
path: '/test/example.tsx',
name: 'example',
extension: '.tsx',
content: 'const Example = () => {};',
scrollTop: 0,
searchQuery: '',
editMode: false,
editContent: undefined,
createdAt: Date.now(),
lastModified: Date.now(),
};
const unifiedTabs = [
{ type: 'ai' as const, id: 'ai-tab-1', data: aiTab },
{ type: 'file' as const, id: 'file-tab-1', data: fileTab },
];
render(
<TabBar
tabs={[aiTab]}
activeTabId="ai-tab-1"
theme={mockTheme}
onTabSelect={mockOnTabSelect}
onTabClose={mockOnTabClose}
onNewTab={mockOnNewTab}
unifiedTabs={unifiedTabs}
activeFileTabId="file-tab-1"
onFileTabSelect={mockOnFileTabSelect}
onFileTabClose={mockOnFileTabClose}
/>
);
// File tab should show extension badge (uppercase, without leading dot)
const extensionBadge = screen.getByText('TSX');
expect(extensionBadge).toBeInTheDocument();
// Verify it has the uppercase and small badge styling
expect(extensionBadge.className).toContain('uppercase');
});
});
describe('File tab content and SSH support', () => {
const mockOnTabSelect = vi.fn();
const mockOnTabClose = vi.fn();
const mockOnNewTab = vi.fn();
const mockOnFileTabSelect = vi.fn();
const mockOnFileTabClose = vi.fn();
beforeEach(() => {
vi.clearAllMocks();
});
it('file tab stores content field', () => {
const aiTab = createTab({ id: 'ai-tab-1', name: 'AI Tab' });
const fileContent = '# Test Content\n\nThis is the file content stored on the tab.';
const fileTab: FilePreviewTab = {
id: 'file-tab-1',
path: '/test/readme.md',
name: 'readme',
extension: '.md',
content: fileContent, // Content is stored on the tab
scrollTop: 0,
searchQuery: '',
editMode: false,
editContent: undefined,
createdAt: Date.now(),
lastModified: Date.now(),
};
const unifiedTabs = [
{ type: 'ai' as const, id: 'ai-tab-1', data: aiTab },
{ type: 'file' as const, id: 'file-tab-1', data: fileTab },
];
render(
<TabBar
tabs={[aiTab]}
activeTabId="ai-tab-1"
theme={mockTheme}
onTabSelect={mockOnTabSelect}
onTabClose={mockOnTabClose}
onNewTab={mockOnNewTab}
unifiedTabs={unifiedTabs}
activeFileTabId="file-tab-1"
onFileTabSelect={mockOnFileTabSelect}
onFileTabClose={mockOnFileTabClose}
/>
);
// Verify the file tab renders (content is used by MainPanel, not TabBar)
expect(screen.getByText('readme')).toBeInTheDocument();
// Verify the content is stored on the tab data
expect(fileTab.content).toBe(fileContent);
});
it('file tab supports SSH remote ID', () => {
const aiTab = createTab({ id: 'ai-tab-1', name: 'AI Tab' });
const fileTab: FilePreviewTab = {
id: 'file-tab-1',
path: '/remote/project/src/main.ts',
name: 'main',
extension: '.ts',
content: 'export const main = () => {}',
scrollTop: 0,
searchQuery: '',
editMode: false,
editContent: undefined,
createdAt: Date.now(),
lastModified: Date.now(),
sshRemoteId: 'ssh-remote-123', // SSH remote ID for re-fetching
isLoading: false,
};
const unifiedTabs = [
{ type: 'ai' as const, id: 'ai-tab-1', data: aiTab },
{ type: 'file' as const, id: 'file-tab-1', data: fileTab },
];
render(
<TabBar
tabs={[aiTab]}
activeTabId="ai-tab-1"
theme={mockTheme}
onTabSelect={mockOnTabSelect}
onTabClose={mockOnTabClose}
onNewTab={mockOnNewTab}
unifiedTabs={unifiedTabs}
activeFileTabId="file-tab-1"
onFileTabSelect={mockOnFileTabSelect}
onFileTabClose={mockOnFileTabClose}
/>
);
// Verify the file tab renders
expect(screen.getByText('main')).toBeInTheDocument();
// Verify SSH remote ID is stored
expect(fileTab.sshRemoteId).toBe('ssh-remote-123');
expect(fileTab.isLoading).toBe(false);
});
it('file tab can be in loading state for SSH files', () => {
const aiTab = createTab({ id: 'ai-tab-1', name: 'AI Tab' });
const fileTab: FilePreviewTab = {
id: 'file-tab-1',
path: '/remote/project/loading.ts',
name: 'loading',
extension: '.ts',
content: '', // Empty while loading
scrollTop: 0,
searchQuery: '',
editMode: false,
editContent: undefined,
createdAt: Date.now(),
lastModified: 0, // Not yet loaded
sshRemoteId: 'ssh-remote-456',
isLoading: true, // Currently loading content
};
const unifiedTabs = [
{ type: 'ai' as const, id: 'ai-tab-1', data: aiTab },
{ type: 'file' as const, id: 'file-tab-1', data: fileTab },
];
render(
<TabBar
tabs={[aiTab]}
activeTabId="ai-tab-1"
theme={mockTheme}
onTabSelect={mockOnTabSelect}
onTabClose={mockOnTabClose}
onNewTab={mockOnNewTab}
unifiedTabs={unifiedTabs}
activeFileTabId="file-tab-1"
onFileTabSelect={mockOnFileTabSelect}
onFileTabClose={mockOnFileTabClose}
/>
);
// Tab still renders while loading
expect(screen.getByText('loading')).toBeInTheDocument();
// Verify loading state
expect(fileTab.isLoading).toBe(true);
expect(fileTab.content).toBe('');
});
it('file tab editContent takes precedence over content when set', () => {
const aiTab = createTab({ id: 'ai-tab-1', name: 'AI Tab' });
const originalContent = 'Original file content';
const editedContent = 'Edited content not yet saved';
const fileTab: FilePreviewTab = {
id: 'file-tab-1',
path: '/test/edited.md',
name: 'edited',
extension: '.md',
content: originalContent,
scrollTop: 100,
searchQuery: 'search',
editMode: true,
editContent: editedContent, // Has unsaved edits
createdAt: Date.now(),
lastModified: Date.now(),
};
const unifiedTabs = [
{ type: 'ai' as const, id: 'ai-tab-1', data: aiTab },
{ type: 'file' as const, id: 'file-tab-1', data: fileTab },
];
render(
<TabBar
tabs={[aiTab]}
activeTabId="ai-tab-1"
theme={mockTheme}
onTabSelect={mockOnTabSelect}
onTabClose={mockOnTabClose}
onNewTab={mockOnNewTab}
unifiedTabs={unifiedTabs}
activeFileTabId="file-tab-1"
onFileTabSelect={mockOnFileTabSelect}
onFileTabClose={mockOnFileTabClose}
/>
);
// Tab renders
expect(screen.getByText('edited')).toBeInTheDocument();
// Verify both content fields exist (MainPanel uses editContent ?? content)
expect(fileTab.content).toBe(originalContent);
expect(fileTab.editContent).toBe(editedContent);
expect(fileTab.editMode).toBe(true);
});
});
// Extension badge styling tests for visual polish across themes
describe('Extension badge styling across themes', () => {
const mockOnTabSelect = vi.fn();
const mockOnTabClose = vi.fn();
const mockOnNewTab = vi.fn();
const mockOnFileTabSelect = vi.fn();
const mockOnFileTabClose = vi.fn();
beforeEach(() => {
vi.useFakeTimers();
vi.clearAllMocks();
Element.prototype.scrollTo = vi.fn();
});
afterEach(() => {
vi.useRealTimers();
});
// Light theme for testing contrast
const lightTheme: Theme = {
id: 'github-light',
name: 'GitHub Light',
mode: 'light',
colors: {
bgMain: '#ffffff',
bgSidebar: '#f6f8fa',
bgActivity: '#eff2f5',
textMain: '#24292f',
textDim: '#57606a',
accent: '#0969da',
border: '#d0d7de',
error: '#cf222e',
success: '#1a7f37',
warning: '#9a6700',
},
};
// Dark theme for comparison
const darkTheme: Theme = {
id: 'dracula',
name: 'Dracula',
mode: 'dark',
colors: {
bgMain: '#282a36',
bgSidebar: '#21222c',
bgActivity: '#343746',
textMain: '#f8f8f2',
textDim: '#6272a4',
accent: '#bd93f9',
border: '#44475a',
error: '#ff5555',
success: '#50fa7b',
warning: '#ffb86c',
},
};
const createFileTab = (extension: string): FilePreviewTab => ({
id: `file-tab-${extension}`,
path: `/test/file${extension}`,
name: 'file',
extension: extension,
content: 'test content',
scrollTop: 0,
searchQuery: '',
editMode: false,
editContent: undefined,
createdAt: Date.now(),
lastModified: Date.now(),
});
it('renders extension badges for TypeScript files with appropriate styling', () => {
const aiTab = createTab({ id: 'ai-tab-1', name: 'AI Tab' });
const fileTab = createFileTab('.ts');
const unifiedTabs = [
{ type: 'ai' as const, id: 'ai-tab-1', data: aiTab },
{ type: 'file' as const, id: fileTab.id, data: fileTab },
];
render(
<TabBar
tabs={[aiTab]}
activeTabId="ai-tab-1"
theme={darkTheme}
onTabSelect={mockOnTabSelect}
onTabClose={mockOnTabClose}
onNewTab={mockOnNewTab}
unifiedTabs={unifiedTabs}
activeFileTabId={null}
onFileTabSelect={mockOnFileTabSelect}
onFileTabClose={mockOnFileTabClose}
/>
);
// Extension badge should be rendered
const badge = screen.getByText('TS');
expect(badge).toBeInTheDocument();
// Badge should have blue-ish background for TypeScript
expect(badge).toHaveStyle({ backgroundColor: 'rgba(59, 130, 246, 0.3)' });
});
it('renders extension badges for TypeScript files with light theme appropriate styling', () => {
const aiTab = createTab({ id: 'ai-tab-1', name: 'AI Tab' });
const fileTab = createFileTab('.tsx');
const unifiedTabs = [
{ type: 'ai' as const, id: 'ai-tab-1', data: aiTab },
{ type: 'file' as const, id: fileTab.id, data: fileTab },
];
render(
<TabBar
tabs={[aiTab]}
activeTabId="ai-tab-1"
theme={lightTheme}
onTabSelect={mockOnTabSelect}
onTabClose={mockOnTabClose}
onNewTab={mockOnNewTab}
unifiedTabs={unifiedTabs}
activeFileTabId={null}
onFileTabSelect={mockOnFileTabSelect}
onFileTabClose={mockOnFileTabClose}
/>
);
// Extension badge should be rendered with light theme colors
const badge = screen.getByText('TSX');
expect(badge).toBeInTheDocument();
// Badge should have darker blue for better contrast on light backgrounds
expect(badge).toHaveStyle({ backgroundColor: 'rgba(37, 99, 235, 0.15)' });
});
it('renders extension badges for Markdown files with dark theme styling', () => {
const aiTab = createTab({ id: 'ai-tab-1', name: 'AI Tab' });
const fileTab = createFileTab('.md');
const unifiedTabs = [
{ type: 'ai' as const, id: 'ai-tab-1', data: aiTab },
{ type: 'file' as const, id: fileTab.id, data: fileTab },
];
render(
<TabBar
tabs={[aiTab]}
activeTabId="ai-tab-1"
theme={darkTheme}
onTabSelect={mockOnTabSelect}
onTabClose={mockOnTabClose}
onNewTab={mockOnNewTab}
unifiedTabs={unifiedTabs}
activeFileTabId={null}
onFileTabSelect={mockOnFileTabSelect}
onFileTabClose={mockOnFileTabClose}
/>
);
const badge = screen.getByText('MD');
expect(badge).toBeInTheDocument();
// Green tones for Markdown/Docs
expect(badge).toHaveStyle({ backgroundColor: 'rgba(34, 197, 94, 0.3)' });
});
it('renders extension badges for JSON files with dark theme styling', () => {
const aiTab = createTab({ id: 'ai-tab-1', name: 'AI Tab' });
const fileTab = createFileTab('.json');
const unifiedTabs = [
{ type: 'ai' as const, id: 'ai-tab-1', data: aiTab },
{ type: 'file' as const, id: fileTab.id, data: fileTab },
];
render(
<TabBar
tabs={[aiTab]}
activeTabId="ai-tab-1"
theme={darkTheme}
onTabSelect={mockOnTabSelect}
onTabClose={mockOnTabClose}
onNewTab={mockOnNewTab}
unifiedTabs={unifiedTabs}
activeFileTabId={null}
onFileTabSelect={mockOnFileTabSelect}
onFileTabClose={mockOnFileTabClose}
/>
);
const badge = screen.getByText('JSON');
expect(badge).toBeInTheDocument();
// Yellow tones for JSON/Config
expect(badge).toHaveStyle({ backgroundColor: 'rgba(234, 179, 8, 0.3)' });
});
it('renders extension badges for CSS files with dark theme styling', () => {
const aiTab = createTab({ id: 'ai-tab-1', name: 'AI Tab' });
const fileTab = createFileTab('.css');
const unifiedTabs = [
{ type: 'ai' as const, id: 'ai-tab-1', data: aiTab },
{ type: 'file' as const, id: fileTab.id, data: fileTab },
];
render(
<TabBar
tabs={[aiTab]}
activeTabId="ai-tab-1"
theme={darkTheme}
onTabSelect={mockOnTabSelect}
onTabClose={mockOnTabClose}
onNewTab={mockOnNewTab}
unifiedTabs={unifiedTabs}
activeFileTabId={null}
onFileTabSelect={mockOnFileTabSelect}
onFileTabClose={mockOnFileTabClose}
/>
);
const badge = screen.getByText('CSS');
expect(badge).toBeInTheDocument();
// Purple tones for CSS/Styles
expect(badge).toHaveStyle({ backgroundColor: 'rgba(168, 85, 247, 0.3)' });
});
it('renders extension badges for HTML files with dark theme styling', () => {
const aiTab = createTab({ id: 'ai-tab-1', name: 'AI Tab' });
const fileTab = createFileTab('.html');
const unifiedTabs = [
{ type: 'ai' as const, id: 'ai-tab-1', data: aiTab },
{ type: 'file' as const, id: fileTab.id, data: fileTab },
];
render(
<TabBar
tabs={[aiTab]}
activeTabId="ai-tab-1"
theme={darkTheme}
onTabSelect={mockOnTabSelect}
onTabClose={mockOnTabClose}
onNewTab={mockOnNewTab}
unifiedTabs={unifiedTabs}
activeFileTabId={null}
onFileTabSelect={mockOnFileTabSelect}
onFileTabClose={mockOnFileTabClose}
/>
);
const badge = screen.getByText('HTML');
expect(badge).toBeInTheDocument();
// Orange tones for HTML/Templates
expect(badge).toHaveStyle({ backgroundColor: 'rgba(249, 115, 22, 0.3)' });
});
it('renders extension badges for Python files with dark theme styling', () => {
const aiTab = createTab({ id: 'ai-tab-1', name: 'AI Tab' });
const fileTab = createFileTab('.py');
const unifiedTabs = [
{ type: 'ai' as const, id: 'ai-tab-1', data: aiTab },
{ type: 'file' as const, id: fileTab.id, data: fileTab },
];
render(
<TabBar
tabs={[aiTab]}
activeTabId="ai-tab-1"
theme={darkTheme}
onTabSelect={mockOnTabSelect}
onTabClose={mockOnTabClose}
onNewTab={mockOnNewTab}
unifiedTabs={unifiedTabs}
activeFileTabId={null}
onFileTabSelect={mockOnFileTabSelect}
onFileTabClose={mockOnFileTabClose}
/>
);
const badge = screen.getByText('PY');
expect(badge).toBeInTheDocument();
// Teal/cyan tones for Python
expect(badge).toHaveStyle({ backgroundColor: 'rgba(20, 184, 166, 0.3)' });
});
it('renders extension badges for Rust files with dark theme styling', () => {
const aiTab = createTab({ id: 'ai-tab-1', name: 'AI Tab' });
const fileTab = createFileTab('.rs');
const unifiedTabs = [
{ type: 'ai' as const, id: 'ai-tab-1', data: aiTab },
{ type: 'file' as const, id: fileTab.id, data: fileTab },
];
render(
<TabBar
tabs={[aiTab]}
activeTabId="ai-tab-1"
theme={darkTheme}
onTabSelect={mockOnTabSelect}
onTabClose={mockOnTabClose}
onNewTab={mockOnNewTab}
unifiedTabs={unifiedTabs}
activeFileTabId={null}
onFileTabSelect={mockOnFileTabSelect}
onFileTabClose={mockOnFileTabClose}
/>
);
const badge = screen.getByText('RS');
expect(badge).toBeInTheDocument();
// Rust/red-orange tones for Rust
expect(badge).toHaveStyle({ backgroundColor: 'rgba(239, 68, 68, 0.3)' });
});
it('renders extension badges for unknown files using theme border color', () => {
const aiTab = createTab({ id: 'ai-tab-1', name: 'AI Tab' });
const fileTab = createFileTab('.xyz');
const unifiedTabs = [
{ type: 'ai' as const, id: 'ai-tab-1', data: aiTab },
{ type: 'file' as const, id: fileTab.id, data: fileTab },
];
render(
<TabBar
tabs={[aiTab]}
activeTabId="ai-tab-1"
theme={darkTheme}
onTabSelect={mockOnTabSelect}
onTabClose={mockOnTabClose}
onNewTab={mockOnNewTab}
unifiedTabs={unifiedTabs}
activeFileTabId={null}
onFileTabSelect={mockOnFileTabSelect}
onFileTabClose={mockOnFileTabClose}
/>
);
const badge = screen.getByText('XYZ');
expect(badge).toBeInTheDocument();
// Uses theme border color for unknown extensions
expect(badge).toHaveStyle({ backgroundColor: darkTheme.colors.border });
});
it('renders consistent tab name truncation for file tabs (max-w-[120px])', () => {
const aiTab = createTab({ id: 'ai-tab-1', name: 'AI Tab' });
const fileTab: FilePreviewTab = {
id: 'file-tab-1',
path: '/test/very-long-filename-that-should-be-truncated.ts',
name: 'very-long-filename-that-should-be-truncated',
extension: '.ts',
content: 'test',
scrollTop: 0,
searchQuery: '',
editMode: false,
editContent: undefined,
createdAt: Date.now(),
lastModified: Date.now(),
};
const unifiedTabs = [
{ type: 'ai' as const, id: 'ai-tab-1', data: aiTab },
{ type: 'file' as const, id: fileTab.id, data: fileTab },
];
render(
<TabBar
tabs={[aiTab]}
activeTabId="ai-tab-1" // AI tab active, file tab inactive
theme={darkTheme}
onTabSelect={mockOnTabSelect}
onTabClose={mockOnTabClose}
onNewTab={mockOnNewTab}
unifiedTabs={unifiedTabs}
activeFileTabId={null}
onFileTabSelect={mockOnFileTabSelect}
onFileTabClose={mockOnFileTabClose}
/>
);
// File tab name span should have truncation class
const fileNameSpan = screen.getByText('very-long-filename-that-should-be-truncated');
expect(fileNameSpan).toHaveClass('truncate');
expect(fileNameSpan).toHaveClass('max-w-[120px]');
});
});
describe('File tab extension badge colorblind mode', () => {
const mockOnTabSelect = vi.fn();
const mockOnTabClose = vi.fn();
const mockOnNewTab = vi.fn();
const mockOnFileTabSelect = vi.fn();
const mockOnFileTabClose = vi.fn();
beforeEach(() => {
vi.clearAllMocks();
});
// Light theme for testing contrast
const lightTheme: Theme = {
id: 'github-light',
name: 'GitHub Light',
mode: 'light',
colors: {
bgMain: '#ffffff',
bgSidebar: '#f6f8fa',
bgActivity: '#eff2f5',
textMain: '#24292f',
textDim: '#57606a',
accent: '#0969da',
border: '#d0d7de',
error: '#cf222e',
success: '#1a7f37',
warning: '#9a6700',
},
};
// Dark theme for comparison
const darkTheme: Theme = {
id: 'dracula',
name: 'Dracula',
mode: 'dark',
colors: {
bgMain: '#282a36',
bgSidebar: '#21222c',
bgActivity: '#343746',
textMain: '#f8f8f2',
textDim: '#6272a4',
accent: '#bd93f9',
border: '#44475a',
error: '#ff5555',
success: '#50fa7b',
warning: '#ffb86c',
},
};
const createTab = (overrides: Partial<AITab> = {}): AITab => ({
id: 'test-tab',
name: '',
agentSessionId: 'abc12345-def6-7890',
logs: [],
...overrides,
});
const createFileTab = (extension: string): FilePreviewTab => ({
id: `file-tab-${extension}`,
path: `/test/example${extension}`,
name: 'example',
extension: extension,
content: 'test content',
scrollTop: 0,
searchQuery: '',
editMode: false,
editContent: undefined,
createdAt: Date.now(),
lastModified: Date.now(),
});
it('renders colorblind-safe colors for TypeScript files in dark mode', () => {
const aiTab = createTab({ id: 'ai-tab-1', name: 'AI Tab' });
const fileTab = createFileTab('.ts');
const unifiedTabs = [
{ type: 'ai' as const, id: 'ai-tab-1', data: aiTab },
{ type: 'file' as const, id: fileTab.id, data: fileTab },
];
render(
<TabBar
tabs={[aiTab]}
activeTabId="ai-tab-1"
theme={darkTheme}
onTabSelect={mockOnTabSelect}
onTabClose={mockOnTabClose}
onNewTab={mockOnNewTab}
unifiedTabs={unifiedTabs}
activeFileTabId={null}
onFileTabSelect={mockOnFileTabSelect}
onFileTabClose={mockOnFileTabClose}
colorBlindMode={true}
/>
);
const badge = screen.getByText('TS');
expect(badge).toBeInTheDocument();
// Strong Blue (#0077BB) from Wong's colorblind-safe palette
expect(badge).toHaveStyle({ backgroundColor: 'rgba(0, 119, 187, 0.35)' });
});
it('renders colorblind-safe colors for TypeScript files in light mode', () => {
const aiTab = createTab({ id: 'ai-tab-1', name: 'AI Tab' });
const fileTab = createFileTab('.tsx');
const unifiedTabs = [
{ type: 'ai' as const, id: 'ai-tab-1', data: aiTab },
{ type: 'file' as const, id: fileTab.id, data: fileTab },
];
render(
<TabBar
tabs={[aiTab]}
activeTabId="ai-tab-1"
theme={lightTheme}
onTabSelect={mockOnTabSelect}
onTabClose={mockOnTabClose}
onNewTab={mockOnNewTab}
unifiedTabs={unifiedTabs}
activeFileTabId={null}
onFileTabSelect={mockOnFileTabSelect}
onFileTabClose={mockOnFileTabClose}
colorBlindMode={true}
/>
);
const badge = screen.getByText('TSX');
expect(badge).toBeInTheDocument();
// Strong Blue (#0077BB) lighter for light theme
expect(badge).toHaveStyle({ backgroundColor: 'rgba(0, 119, 187, 0.18)' });
});
it('renders colorblind-safe colors for Markdown files (teal)', () => {
const aiTab = createTab({ id: 'ai-tab-1', name: 'AI Tab' });
const fileTab = createFileTab('.md');
const unifiedTabs = [
{ type: 'ai' as const, id: 'ai-tab-1', data: aiTab },
{ type: 'file' as const, id: fileTab.id, data: fileTab },
];
render(
<TabBar
tabs={[aiTab]}
activeTabId="ai-tab-1"
theme={darkTheme}
onTabSelect={mockOnTabSelect}
onTabClose={mockOnTabClose}
onNewTab={mockOnNewTab}
unifiedTabs={unifiedTabs}
activeFileTabId={null}
onFileTabSelect={mockOnFileTabSelect}
onFileTabClose={mockOnFileTabClose}
colorBlindMode={true}
/>
);
const badge = screen.getByText('MD');
expect(badge).toBeInTheDocument();
// Teal (#009988) from Wong's colorblind-safe palette
expect(badge).toHaveStyle({ backgroundColor: 'rgba(0, 153, 136, 0.35)' });
});
it('renders colorblind-safe colors for JSON/Config files (orange)', () => {
const aiTab = createTab({ id: 'ai-tab-1', name: 'AI Tab' });
const fileTab = createFileTab('.json');
const unifiedTabs = [
{ type: 'ai' as const, id: 'ai-tab-1', data: aiTab },
{ type: 'file' as const, id: fileTab.id, data: fileTab },
];
render(
<TabBar
tabs={[aiTab]}
activeTabId="ai-tab-1"
theme={darkTheme}
onTabSelect={mockOnTabSelect}
onTabClose={mockOnTabClose}
onNewTab={mockOnNewTab}
unifiedTabs={unifiedTabs}
activeFileTabId={null}
onFileTabSelect={mockOnFileTabSelect}
onFileTabClose={mockOnFileTabClose}
colorBlindMode={true}
/>
);
const badge = screen.getByText('JSON');
expect(badge).toBeInTheDocument();
// Orange (#EE7733) from Wong's colorblind-safe palette
expect(badge).toHaveStyle({ backgroundColor: 'rgba(238, 119, 51, 0.35)' });
});
it('renders colorblind-safe colors for CSS files (purple)', () => {
const aiTab = createTab({ id: 'ai-tab-1', name: 'AI Tab' });
const fileTab = createFileTab('.css');
const unifiedTabs = [
{ type: 'ai' as const, id: 'ai-tab-1', data: aiTab },
{ type: 'file' as const, id: fileTab.id, data: fileTab },
];
render(
<TabBar
tabs={[aiTab]}
activeTabId="ai-tab-1"
theme={darkTheme}
onTabSelect={mockOnTabSelect}
onTabClose={mockOnTabClose}
onNewTab={mockOnNewTab}
unifiedTabs={unifiedTabs}
activeFileTabId={null}
onFileTabSelect={mockOnFileTabSelect}
onFileTabClose={mockOnFileTabClose}
colorBlindMode={true}
/>
);
const badge = screen.getByText('CSS');
expect(badge).toBeInTheDocument();
// Purple (#AA4499) from Wong's colorblind-safe palette
expect(badge).toHaveStyle({ backgroundColor: 'rgba(170, 68, 153, 0.35)' });
});
it('renders colorblind-safe colors for HTML files (vermillion)', () => {
const aiTab = createTab({ id: 'ai-tab-1', name: 'AI Tab' });
const fileTab = createFileTab('.html');
const unifiedTabs = [
{ type: 'ai' as const, id: 'ai-tab-1', data: aiTab },
{ type: 'file' as const, id: fileTab.id, data: fileTab },
];
render(
<TabBar
tabs={[aiTab]}
activeTabId="ai-tab-1"
theme={darkTheme}
onTabSelect={mockOnTabSelect}
onTabClose={mockOnTabClose}
onNewTab={mockOnNewTab}
unifiedTabs={unifiedTabs}
activeFileTabId={null}
onFileTabSelect={mockOnFileTabSelect}
onFileTabClose={mockOnFileTabClose}
colorBlindMode={true}
/>
);
const badge = screen.getByText('HTML');
expect(badge).toBeInTheDocument();
// Vermillion (#CC3311) from Wong's colorblind-safe palette
expect(badge).toHaveStyle({ backgroundColor: 'rgba(204, 51, 17, 0.35)' });
});
it('renders colorblind-safe colors for Python files (cyan)', () => {
const aiTab = createTab({ id: 'ai-tab-1', name: 'AI Tab' });
const fileTab = createFileTab('.py');
const unifiedTabs = [
{ type: 'ai' as const, id: 'ai-tab-1', data: aiTab },
{ type: 'file' as const, id: fileTab.id, data: fileTab },
];
render(
<TabBar
tabs={[aiTab]}
activeTabId="ai-tab-1"
theme={darkTheme}
onTabSelect={mockOnTabSelect}
onTabClose={mockOnTabClose}
onNewTab={mockOnNewTab}
unifiedTabs={unifiedTabs}
activeFileTabId={null}
onFileTabSelect={mockOnFileTabSelect}
onFileTabClose={mockOnFileTabClose}
colorBlindMode={true}
/>
);
const badge = screen.getByText('PY');
expect(badge).toBeInTheDocument();
// Cyan (#33BBEE) from Wong's colorblind-safe palette
expect(badge).toHaveStyle({ backgroundColor: 'rgba(51, 187, 238, 0.35)' });
});
it('renders colorblind-safe colors for Rust files (magenta)', () => {
const aiTab = createTab({ id: 'ai-tab-1', name: 'AI Tab' });
const fileTab = createFileTab('.rs');
const unifiedTabs = [
{ type: 'ai' as const, id: 'ai-tab-1', data: aiTab },
{ type: 'file' as const, id: fileTab.id, data: fileTab },
];
render(
<TabBar
tabs={[aiTab]}
activeTabId="ai-tab-1"
theme={darkTheme}
onTabSelect={mockOnTabSelect}
onTabClose={mockOnTabClose}
onNewTab={mockOnNewTab}
unifiedTabs={unifiedTabs}
activeFileTabId={null}
onFileTabSelect={mockOnFileTabSelect}
onFileTabClose={mockOnFileTabClose}
colorBlindMode={true}
/>
);
const badge = screen.getByText('RS');
expect(badge).toBeInTheDocument();
// Magenta (#EE3377) from Wong's colorblind-safe palette
expect(badge).toHaveStyle({ backgroundColor: 'rgba(238, 51, 119, 0.35)' });
});
it('renders colorblind-safe colors for Go files (blue-green)', () => {
const aiTab = createTab({ id: 'ai-tab-1', name: 'AI Tab' });
const fileTab = createFileTab('.go');
const unifiedTabs = [
{ type: 'ai' as const, id: 'ai-tab-1', data: aiTab },
{ type: 'file' as const, id: fileTab.id, data: fileTab },
];
render(
<TabBar
tabs={[aiTab]}
activeTabId="ai-tab-1"
theme={darkTheme}
onTabSelect={mockOnTabSelect}
onTabClose={mockOnTabClose}
onNewTab={mockOnNewTab}
unifiedTabs={unifiedTabs}
activeFileTabId={null}
onFileTabSelect={mockOnFileTabSelect}
onFileTabClose={mockOnFileTabClose}
colorBlindMode={true}
/>
);
const badge = screen.getByText('GO');
expect(badge).toBeInTheDocument();
// Blue-Green (#44AA99) from Wong's colorblind-safe palette
expect(badge).toHaveStyle({ backgroundColor: 'rgba(68, 170, 153, 0.35)' });
});
it('renders colorblind-safe colors for Shell scripts (gray)', () => {
const aiTab = createTab({ id: 'ai-tab-1', name: 'AI Tab' });
const fileTab = createFileTab('.sh');
const unifiedTabs = [
{ type: 'ai' as const, id: 'ai-tab-1', data: aiTab },
{ type: 'file' as const, id: fileTab.id, data: fileTab },
];
render(
<TabBar
tabs={[aiTab]}
activeTabId="ai-tab-1"
theme={darkTheme}
onTabSelect={mockOnTabSelect}
onTabClose={mockOnTabClose}
onNewTab={mockOnNewTab}
unifiedTabs={unifiedTabs}
activeFileTabId={null}
onFileTabSelect={mockOnFileTabSelect}
onFileTabClose={mockOnFileTabClose}
colorBlindMode={true}
/>
);
const badge = screen.getByText('SH');
expect(badge).toBeInTheDocument();
// Gray for shell scripts (distinguishable by luminance)
expect(badge).toHaveStyle({ backgroundColor: 'rgba(150, 150, 150, 0.35)' });
});
it('falls back to theme colors for unknown extensions in colorblind mode', () => {
const aiTab = createTab({ id: 'ai-tab-1', name: 'AI Tab' });
const fileTab = createFileTab('.xyz');
const unifiedTabs = [
{ type: 'ai' as const, id: 'ai-tab-1', data: aiTab },
{ type: 'file' as const, id: fileTab.id, data: fileTab },
];
render(
<TabBar
tabs={[aiTab]}
activeTabId="ai-tab-1"
theme={darkTheme}
onTabSelect={mockOnTabSelect}
onTabClose={mockOnTabClose}
onNewTab={mockOnNewTab}
unifiedTabs={unifiedTabs}
activeFileTabId={null}
onFileTabSelect={mockOnFileTabSelect}
onFileTabClose={mockOnFileTabClose}
colorBlindMode={true}
/>
);
const badge = screen.getByText('XYZ');
expect(badge).toBeInTheDocument();
// Falls back to theme border color for unknown extensions
expect(badge).toHaveStyle({ backgroundColor: darkTheme.colors.border });
});
});
describe('Performance: Many file tabs (10+)', () => {
const mockOnTabSelect = vi.fn();
const mockOnTabClose = vi.fn();
const mockOnNewTab = vi.fn();
const mockOnFileTabSelect = vi.fn();
const mockOnFileTabClose = vi.fn();
const mockOnUnifiedTabReorder = vi.fn();
beforeEach(() => {
vi.useFakeTimers();
vi.clearAllMocks();
Element.prototype.scrollTo = vi.fn();
});
afterEach(() => {
vi.useRealTimers();
});
// Helper to create many file tabs
const createManyFileTabs = (count: number): FilePreviewTab[] =>
Array.from({ length: count }, (_, i) => ({
id: `file-tab-${i}`,
path: `/path/to/files/file-${i}.ts`,
name: `file-${i}`,
extension: '.ts',
content: `// Content for file ${i}\nconst x${i} = ${i};`,
scrollTop: 0,
searchQuery: '',
editMode: false,
editContent: undefined,
createdAt: Date.now() + i,
lastModified: Date.now() + i,
}));
// Helper to create unified tabs from file tabs
const createUnifiedTabsFromFiles = (
fileTabs: FilePreviewTab[],
aiTab: AITab
): Array<{ type: 'ai' | 'file'; id: string; data: AITab | FilePreviewTab }> => [
{ type: 'ai' as const, id: aiTab.id, data: aiTab },
...fileTabs.map((ft) => ({ type: 'file' as const, id: ft.id, data: ft })),
];
it('renders 15 file tabs without performance issues', () => {
const aiTab = createTab({ id: 'ai-tab-1', name: 'AI Tab', agentSessionId: 'sess-1' });
const fileTabs = createManyFileTabs(15);
const unifiedTabs = createUnifiedTabsFromFiles(fileTabs, aiTab);
render(
<TabBar
tabs={[aiTab]}
activeTabId="ai-tab-1"
theme={mockTheme}
onTabSelect={mockOnTabSelect}
onTabClose={mockOnTabClose}
onNewTab={mockOnNewTab}
unifiedTabs={unifiedTabs}
activeFileTabId={null}
onFileTabSelect={mockOnFileTabSelect}
onFileTabClose={mockOnFileTabClose}
onUnifiedTabReorder={mockOnUnifiedTabReorder}
/>
);
// All 15 file tabs should be rendered
expect(screen.getByText('file-0')).toBeInTheDocument();
expect(screen.getByText('file-7')).toBeInTheDocument();
expect(screen.getByText('file-14')).toBeInTheDocument();
// All extension badges should be present (uppercase, no leading dot)
const tsBadges = screen.getAllByText('TS');
expect(tsBadges.length).toBe(15);
});
it('renders 30 file tabs with mixed AI tabs', () => {
const aiTab1 = createTab({ id: 'ai-tab-1', name: 'AI Tab 1', agentSessionId: 'sess-1' });
const aiTab2 = createTab({ id: 'ai-tab-2', name: 'AI Tab 2', agentSessionId: 'sess-2' });
const fileTabs = createManyFileTabs(30);
// Interleave AI tabs with file tabs
const unifiedTabs = [
{ type: 'ai' as const, id: aiTab1.id, data: aiTab1 },
...fileTabs.slice(0, 15).map((ft) => ({ type: 'file' as const, id: ft.id, data: ft })),
{ type: 'ai' as const, id: aiTab2.id, data: aiTab2 },
...fileTabs.slice(15).map((ft) => ({ type: 'file' as const, id: ft.id, data: ft })),
];
render(
<TabBar
tabs={[aiTab1, aiTab2]}
activeTabId="ai-tab-1"
theme={mockTheme}
onTabSelect={mockOnTabSelect}
onTabClose={mockOnTabClose}
onNewTab={mockOnNewTab}
unifiedTabs={unifiedTabs}
activeFileTabId={null}
onFileTabSelect={mockOnFileTabSelect}
onFileTabClose={mockOnFileTabClose}
onUnifiedTabReorder={mockOnUnifiedTabReorder}
/>
);
// AI tabs should be present
expect(screen.getByText('AI Tab 1')).toBeInTheDocument();
expect(screen.getByText('AI Tab 2')).toBeInTheDocument();
// File tabs from both groups should be present
expect(screen.getByText('file-0')).toBeInTheDocument();
expect(screen.getByText('file-14')).toBeInTheDocument();
expect(screen.getByText('file-15')).toBeInTheDocument();
expect(screen.getByText('file-29')).toBeInTheDocument();
});
it('selects file tab correctly among many tabs', () => {
const aiTab = createTab({ id: 'ai-tab-1', name: 'AI Tab', agentSessionId: 'sess-1' });
const fileTabs = createManyFileTabs(20);
const unifiedTabs = createUnifiedTabsFromFiles(fileTabs, aiTab);
render(
<TabBar
tabs={[aiTab]}
activeTabId="ai-tab-1"
theme={mockTheme}
onTabSelect={mockOnTabSelect}
onTabClose={mockOnTabClose}
onNewTab={mockOnNewTab}
unifiedTabs={unifiedTabs}
activeFileTabId={null}
onFileTabSelect={mockOnFileTabSelect}
onFileTabClose={mockOnFileTabClose}
onUnifiedTabReorder={mockOnUnifiedTabReorder}
/>
);
// Click on file-10
const fileTab10 = screen.getByText('file-10').closest('[data-tab-id]')!;
fireEvent.click(fileTab10);
expect(mockOnFileTabSelect).toHaveBeenCalledWith('file-tab-10');
});
it('closes file tab correctly among many tabs', () => {
const aiTab = createTab({ id: 'ai-tab-1', name: 'AI Tab', agentSessionId: 'sess-1' });
const fileTabs = createManyFileTabs(20);
const unifiedTabs = createUnifiedTabsFromFiles(fileTabs, aiTab);
render(
<TabBar
tabs={[aiTab]}
activeTabId="ai-tab-1"
theme={mockTheme}
onTabSelect={mockOnTabSelect}
onTabClose={mockOnTabClose}
onNewTab={mockOnNewTab}
unifiedTabs={unifiedTabs}
activeFileTabId="file-tab-5" // Make file-5 active to show close button
onFileTabSelect={mockOnFileTabSelect}
onFileTabClose={mockOnFileTabClose}
onUnifiedTabReorder={mockOnUnifiedTabReorder}
/>
);
// The close button should be visible on the active file tab
const fileTab5 = screen.getByText('file-5').closest('[data-tab-id]')!;
const closeButton = fileTab5.querySelector('button[title="Close tab"]');
expect(closeButton).toBeInTheDocument();
fireEvent.click(closeButton!);
expect(mockOnFileTabClose).toHaveBeenCalledWith('file-tab-5');
});
it('supports drag and drop reorder with many file tabs', () => {
const aiTab = createTab({ id: 'ai-tab-1', name: 'AI Tab', agentSessionId: 'sess-1' });
const fileTabs = createManyFileTabs(15);
const unifiedTabs = createUnifiedTabsFromFiles(fileTabs, aiTab);
render(
<TabBar
tabs={[aiTab]}
activeTabId="ai-tab-1"
theme={mockTheme}
onTabSelect={mockOnTabSelect}
onTabClose={mockOnTabClose}
onNewTab={mockOnNewTab}
unifiedTabs={unifiedTabs}
activeFileTabId={null}
onFileTabSelect={mockOnFileTabSelect}
onFileTabClose={mockOnFileTabClose}
onUnifiedTabReorder={mockOnUnifiedTabReorder}
/>
);
const fileTab2 = screen.getByText('file-2').closest('[data-tab-id]')!;
const fileTab10 = screen.getByText('file-10').closest('[data-tab-id]')!;
// Start dragging file-tab-2 (index 3 in unified tabs: AI tab is at 0)
fireEvent.dragStart(fileTab2, {
dataTransfer: {
effectAllowed: '',
setData: vi.fn(),
getData: vi.fn().mockReturnValue('file-tab-2'),
},
});
// Drop on file-tab-10 (index 11 in unified tabs)
fireEvent.drop(fileTab10, {
dataTransfer: {
getData: vi.fn().mockReturnValue('file-tab-2'),
},
});
// Should call onUnifiedTabReorder with correct indices
expect(mockOnUnifiedTabReorder).toHaveBeenCalledWith(3, 11);
});
it('renders file tabs with different extensions correctly', () => {
const aiTab = createTab({ id: 'ai-tab-1', name: 'AI Tab', agentSessionId: 'sess-1' });
const extensions = ['.ts', '.tsx', '.js', '.json', '.md', '.css', '.html', '.py', '.rs', '.go', '.sh'];
const fileTabs: FilePreviewTab[] = extensions.map((ext, i) => ({
id: `file-tab-${i}`,
path: `/path/to/files/file-${i}${ext}`,
name: `file-${i}`,
extension: ext,
content: `// Content`,
scrollTop: 0,
searchQuery: '',
editMode: false,
editContent: undefined,
createdAt: Date.now() + i,
lastModified: Date.now() + i,
}));
const unifiedTabs = createUnifiedTabsFromFiles(fileTabs, aiTab);
render(
<TabBar
tabs={[aiTab]}
activeTabId="ai-tab-1"
theme={mockTheme}
onTabSelect={mockOnTabSelect}
onTabClose={mockOnTabClose}
onNewTab={mockOnNewTab}
unifiedTabs={unifiedTabs}
activeFileTabId={null}
onFileTabSelect={mockOnFileTabSelect}
onFileTabClose={mockOnFileTabClose}
onUnifiedTabReorder={mockOnUnifiedTabReorder}
/>
);
// All extension badges should be rendered (uppercase, no leading dot)
extensions.forEach((ext) => {
// Strip leading dot and convert to uppercase (e.g., '.ts' -> 'TS')
const badgeText = ext.replace(/^\./, '').toUpperCase();
expect(screen.getByText(badgeText)).toBeInTheDocument();
});
});
it('maintains active tab styling among many tabs', () => {
const aiTab = createTab({ id: 'ai-tab-1', name: 'AI Tab', agentSessionId: 'sess-1' });
const fileTabs = createManyFileTabs(20);
const unifiedTabs = createUnifiedTabsFromFiles(fileTabs, aiTab);
render(
<TabBar
tabs={[aiTab]}
activeTabId="ai-tab-1"
theme={mockTheme}
onTabSelect={mockOnTabSelect}
onTabClose={mockOnTabClose}
onNewTab={mockOnNewTab}
unifiedTabs={unifiedTabs}
activeFileTabId="file-tab-10" // file-10 is active
onFileTabSelect={mockOnFileTabSelect}
onFileTabClose={mockOnFileTabClose}
onUnifiedTabReorder={mockOnUnifiedTabReorder}
/>
);
// Active file tab should have main background color (non-transparent)
const activeFileTab = screen.getByText('file-10').closest('[data-tab-id]')!;
expect(activeFileTab).toHaveStyle({ backgroundColor: mockTheme.colors.bgMain });
// Active file tab should also have the bottom margin adjustment (active styling)
expect(activeFileTab).toHaveStyle({ marginBottom: '-1px' });
// Inactive file tab should NOT have the active margin adjustment
const inactiveFileTab = screen.getByText('file-5').closest('[data-tab-id]')!;
expect(inactiveFileTab).toHaveStyle({ marginBottom: '0' });
});
});