mirror of
https://github.com/NanmiCoder/cc-haha
synced 2026-07-17 13:13:35 +08:00
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:
parent
efcf7af988
commit
bd7a27bb30
@ -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()
|
||||
})
|
||||
})
|
||||
|
||||
@ -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}
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user