fix: keep the desktop window draggable when session tabs fill the title bar

The macOS desktop build could lose its draggable gutter on the right side of
the tab bar once multiple session tabs consumed the native overlay title area.
This adds a Tauri window-drag fallback for clicks on the tab strip's empty
space while preserving tab and control interactions, and locks that behavior
with focused regression tests.

Constraint: Must preserve tab clicks, close buttons, and overflow controls while restoring drag behavior
Rejected: Rework the title bar layout around a dedicated spacer | larger UI diff for a narrow hit-testing bug
Confidence: high
Scope-risk: narrow
Reversibility: clean
Directive: Keep drag fallback limited to true empty gutter hits; do not trigger it from interactive tab descendants
Tested: bun run test src/components/layout/TabBar.test.tsx; bun run lint; bun run build; manual macOS app verification
Not-tested: Windows runtime drag behavior was not manually exercised after this change
Related: #92
This commit is contained in:
程序员阿江(Relakkes) 2026-04-20 15:50:35 +08:00
parent efcf7af988
commit bd7a27bb30
2 changed files with 106 additions and 1 deletions

View File

@ -2,6 +2,15 @@ import { act, fireEvent, render, screen, waitFor } from '@testing-library/react'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import '@testing-library/jest-dom'
const startDraggingMock = vi.hoisted(() => vi.fn(() => Promise.resolve()))
const getCurrentWindowMock = vi.hoisted(() => vi.fn(() => ({
startDragging: startDraggingMock,
})))
vi.mock('@tauri-apps/api/window', () => ({
getCurrentWindow: getCurrentWindowMock,
}))
vi.mock('../../i18n', () => ({
useTranslation: () => (key: string) => {
const translations: Record<string, string> = {
@ -42,6 +51,13 @@ describe('TabBar', () => {
value: ResizeObserverMock,
})
Object.defineProperty(window, '__TAURI__', {
configurable: true,
value: {},
})
startDraggingMock.mockClear()
getCurrentWindowMock.mockClear()
vi.resetModules()
})
@ -53,6 +69,8 @@ describe('TabBar', () => {
useChatStore.setState({
sessions: {},
} as Partial<ReturnType<typeof useChatStore.getState>>)
delete (window as typeof window & { __TAURI__?: unknown }).__TAURI__
})
it('keeps the overflow button flush against window controls on Windows', async () => {
@ -133,4 +151,67 @@ describe('TabBar', () => {
expect(screen.getByTestId('tab-bar')).toHaveAttribute('data-tauri-drag-region')
})
it('starts dragging when clicking the empty tab-bar gutter', 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 />)
})
await waitFor(() => {
expect(getCurrentWindowMock).toHaveBeenCalled()
})
const scrollRegion = screen.getByTestId('tab-bar').querySelector('.overflow-x-hidden')
expect(scrollRegion).toBeInTheDocument()
fireEvent.mouseDown(scrollRegion!)
await waitFor(() => {
expect(startDraggingMock).toHaveBeenCalledTimes(1)
})
})
it('does not start dragging when clicking a tab', 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 />)
})
await waitFor(() => {
expect(getCurrentWindowMock).toHaveBeenCalled()
})
fireEvent.mouseDown(screen.getByText('Untitled Session'))
expect(startDraggingMock).not.toHaveBeenCalled()
})
})

View File

@ -5,6 +5,7 @@ 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)
@ -21,8 +22,19 @@ export function TabBar() {
const [closingTabId, setClosingTabId] = useState<string | null>(null)
const [dragOverIndex, setDragOverIndex] = useState<number | null>(null)
const dragIndexRef = useRef<number | null>(null)
const startDraggingRef = useRef<(() => Promise<void>) | null>(null)
const t = useTranslation()
useEffect(() => {
if (!isTauri) return
import(/* @vite-ignore */ '@tauri-apps/api/window')
.then(({ getCurrentWindow }) => {
const win = getCurrentWindow()
startDraggingRef.current = () => win.startDragging()
})
.catch(() => {})
}, [])
const updateScrollState = useCallback(() => {
const el = scrollRef.current
if (!el) return
@ -145,6 +157,13 @@ export function TabBar() {
setDragOverIndex(null)
}
const handleScrollRegionMouseDown = useCallback((event: React.MouseEvent<HTMLDivElement>) => {
if (event.button !== 0 || event.target !== scrollRef.current) return
const startDragging = startDraggingRef.current
if (!startDragging) return
void startDragging().catch(() => {})
}, [])
if (tabs.length === 0 && !showWindowControls) return null
return (
@ -160,7 +179,12 @@ export function TabBar() {
</button>
)}
<div ref={scrollRef} className="flex-1 flex items-stretch overflow-x-hidden" onDragOver={(e) => e.preventDefault()}>
<div
ref={scrollRef}
className="flex-1 flex items-stretch overflow-x-hidden"
onDragOver={(e) => e.preventDefault()}
onMouseDown={handleScrollRegionMouseDown}
>
{tabs.map((tab, index) => (
<TabItem
key={tab.sessionId}