fix: correct windows tab bar overflow alignment

This commit is contained in:
Relakkes Yang 2026-04-19 17:56:42 +08:00
parent d3c62a8bc5
commit fb011588f9
4 changed files with 142 additions and 24 deletions

View File

@ -0,0 +1,136 @@
import { act, fireEvent, render, screen, waitFor } from '@testing-library/react'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import '@testing-library/jest-dom'
vi.mock('../../i18n', () => ({
useTranslation: () => (key: string) => {
const translations: Record<string, string> = {
'tabs.close': 'Close',
'tabs.closeOthers': 'Close Others',
'tabs.closeLeft': 'Close Left',
'tabs.closeRight': 'Close Right',
'tabs.closeAll': 'Close All',
'tabs.closeConfirmTitle': 'Session Running',
'tabs.closeConfirmMessage': 'Still running',
'tabs.closeConfirmKeep': 'Keep Running',
'tabs.closeConfirmStop': 'Stop & Close',
'common.cancel': 'Cancel',
}
return translations[key] ?? key
},
}))
vi.mock('./WindowControls', () => ({
WindowControls: () => <div data-testid="window-controls" />,
showWindowControls: true,
}))
describe('TabBar', () => {
beforeEach(() => {
class ResizeObserverMock {
constructor(_callback: ResizeObserverCallback) {}
observe(_target: Element) {}
disconnect() {}
unobserve() {}
}
Object.defineProperty(window, 'ResizeObserver', {
configurable: true,
value: ResizeObserverMock,
})
vi.resetModules()
})
afterEach(async () => {
const { useTabStore } = await import('../../stores/tabStore')
const { useChatStore } = await import('../../stores/chatStore')
useTabStore.setState({ tabs: [], activeTabId: null })
useChatStore.setState({
sessions: {},
} as Partial<ReturnType<typeof useChatStore.getState>>)
})
it('keeps the overflow button flush against window controls on Windows', async () => {
const { TabBar } = await import('./TabBar')
const { useTabStore } = await import('../../stores/tabStore')
const { useChatStore } = await import('../../stores/chatStore')
useTabStore.setState({
tabs: [
{ sessionId: 'tab-1', title: 'Untitled Session', type: 'session', status: 'idle' },
{ sessionId: 'tab-2', title: 'Settings', type: 'settings', status: 'idle' },
{ sessionId: 'tab-3', title: 'hello', type: 'session', status: 'idle' },
{ sessionId: 'tab-4', title: 'overflow', type: 'session', status: 'idle' },
],
activeTabId: 'tab-1',
})
useChatStore.setState({
sessions: {},
disconnectSession: vi.fn(),
} as Partial<ReturnType<typeof useChatStore.getState>>)
await act(async () => {
render(<TabBar />)
})
const scrollRegion = screen.getByTestId('tab-bar').querySelector('.overflow-x-hidden')
expect(scrollRegion).toBeInTheDocument()
Object.defineProperty(scrollRegion!, 'clientWidth', {
configurable: true,
get: () => 240,
})
Object.defineProperty(scrollRegion!, 'scrollWidth', {
configurable: true,
get: () => 720,
})
Object.defineProperty(scrollRegion!, 'scrollLeft', {
configurable: true,
get: () => 0,
})
Object.defineProperty(scrollRegion!, 'scrollBy', {
configurable: true,
value: vi.fn(),
})
act(() => {
fireEvent.scroll(scrollRegion!)
})
await waitFor(() => {
expect(screen.getByTestId('window-controls')).toBeInTheDocument()
expect(screen.getByText('chevron_right').closest('button')).toBeInTheDocument()
})
const rightButton = screen.getByText('chevron_right').closest('button')
expect(rightButton?.nextElementSibling).toBe(screen.getByTestId('window-controls'))
})
it('marks the tab bar as a native drag region', async () => {
const { TabBar } = await import('./TabBar')
const { useTabStore } = await import('../../stores/tabStore')
const { useChatStore } = await import('../../stores/chatStore')
useTabStore.setState({
tabs: [
{ sessionId: 'tab-1', title: 'Untitled Session', type: 'session', status: 'idle' },
],
activeTabId: 'tab-1',
})
useChatStore.setState({
sessions: {},
disconnectSession: vi.fn(),
} as Partial<ReturnType<typeof useChatStore.getState>>)
await act(async () => {
render(<TabBar />)
})
expect(screen.getByTestId('tab-bar')).toHaveAttribute('data-tauri-drag-region')
})
})

View File

@ -5,7 +5,6 @@ import { useTranslation } from '../../i18n'
import { WindowControls, showWindowControls } from './WindowControls'
const TAB_WIDTH = 180
const isTauri = typeof window !== 'undefined' && ('__TAURI_INTERNALS__' in window || '__TAURI__' in window)
export function TabBar() {
const tabs = useTabStore((s) => s.tabs)
@ -15,8 +14,6 @@ export function TabBar() {
const disconnectSession = useChatStore((s) => s.disconnectSession)
const moveTab = useTabStore((s) => s.moveTab)
const startDraggingRef = useRef<(() => Promise<void>) | null>(null)
const scrollRef = useRef<HTMLDivElement>(null)
const [canScrollLeft, setCanScrollLeft] = useState(false)
const [canScrollRight, setCanScrollRight] = useState(false)
@ -53,16 +50,6 @@ export function TabBar() {
return () => document.removeEventListener('click', close)
}, [contextMenu])
useEffect(() => {
if (!isTauri) return
import(/* @vite-ignore */ '@tauri-apps/api/window')
.then(({ getCurrentWindow }) => {
const win = getCurrentWindow()
startDraggingRef.current = () => win.startDragging()
})
.catch(() => {})
}, [])
const scroll = (direction: 'left' | 'right') => {
const el = scrollRef.current
if (!el) return
@ -158,19 +145,13 @@ export function TabBar() {
setDragOverIndex(null)
}
const handleTabBarDrag = useCallback((e: React.MouseEvent) => {
if ((e.target as HTMLElement).closest('button, input, textarea, select, a, [role="button"], [draggable="true"]')) {
return
}
startDraggingRef.current?.()
}, [])
if (tabs.length === 0 && !showWindowControls) return null
return (
<div
data-testid="tab-bar"
data-tauri-drag-region
className="flex items-stretch bg-[var(--color-surface-container)] min-h-[37px] select-none border-b border-[var(--color-border)]"
onMouseDown={handleTabBarDrag}
>
{canScrollLeft && (
@ -203,8 +184,6 @@ export function TabBar() {
</button>
)}
{/* Windows: drag spacer fills remaining area + custom window controls */}
{showWindowControls && <div className="flex-1" />}
<WindowControls />
{contextMenu && (

View File

@ -43,7 +43,7 @@ export function WindowControls() {
if (!showWindowControls || !win) return null
return (
<div className="flex items-stretch flex-shrink-0 -my-px">
<div data-testid="window-controls" className="flex items-stretch flex-shrink-0 -my-px">
{/* Minimize */}
<button
onClick={() => runWindowAction(() => win.minimize())}

View File

@ -229,6 +229,9 @@ html, body, #root {
button, input, textarea, select, a, [role="button"] {
-webkit-app-region: no-drag;
}
[draggable="true"] {
-webkit-app-region: no-drag;
}
/* Custom shadow from prototype */
.custom-shadow {