fix(desktop): restore tab bar window dragging

Electron handles custom draggable chrome through CSS app-region rules, not the old runtime startDragging path. Mark the tab strip and empty scroll gutter as native drag regions while keeping tab items and controls explicitly no-drag so tab reordering and close/tool buttons keep receiving pointer events.

Constraint: Electron drag regions swallow pointer events unless interactive children are marked no-drag
Rejected: Keep calling startDragging from the empty gutter | Electron desktopHost does not expose that as the active migration path
Confidence: high
Scope-risk: narrow
Directive: Do not mark tab items themselves as drag regions without revalidating tab reorder behavior
Tested: cd desktop && bun run test src/components/layout/TabBar.test.tsx --run
Tested: cd desktop && bun run lint
Tested: bun run check:desktop
This commit is contained in:
程序员阿江(Relakkes) 2026-06-03 12:42:03 +08:00
parent 349955dbf2
commit 7e737cb4aa
3 changed files with 25 additions and 32 deletions

View File

@ -307,8 +307,10 @@ describe('TabBar', () => {
render(<TabBar />)
})
expect(screen.getByTestId('tab-bar')).not.toHaveAttribute('data-desktop-drag-region')
expect(screen.getByTestId('tab-bar')).toHaveAttribute('data-desktop-drag-region')
expect(screen.getByTestId('tab-bar-scroll-region')).toHaveAttribute('data-desktop-drag-region')
expect(screen.getByTestId('tab-bar-drag-gutter')).toHaveAttribute('data-desktop-drag-region')
expect(screen.getByText('Untitled Session').closest('.tab-bar-interactive')).toBeInTheDocument()
})
it('keeps the desktop tab strip at a roomier titlebar height', async () => {
@ -332,7 +334,7 @@ describe('TabBar', () => {
})
const tabBar = screen.getByTestId('tab-bar')
const tab = screen.getByText('Untitled Session').closest('.tab-bar-hit-area')
const tab = screen.getByText('Untitled Session').closest('.tab-bar-interactive')
expect(tabBar).toHaveClass('min-h-11')
expect(tab).toHaveClass('min-h-11')
@ -506,7 +508,7 @@ describe('TabBar', () => {
expect(screen.queryByTestId('open-project-menu')).not.toBeInTheDocument()
})
it('starts dragging when clicking the empty tab-bar gutter', async () => {
it('marks the empty tab-bar gutter as a native drag region without runtime dragging', async () => {
const { TabBar } = await import('./TabBar')
const { useTabStore } = await import('../../stores/tabStore')
const { useChatStore } = await import('../../stores/chatStore')
@ -526,14 +528,13 @@ describe('TabBar', () => {
render(<TabBar />)
})
const scrollRegion = screen.getByTestId('tab-bar').querySelector('.overflow-x-hidden')
const scrollRegion = screen.getByTestId('tab-bar-scroll-region')
expect(scrollRegion).toBeInTheDocument()
expect(scrollRegion).toHaveAttribute('data-desktop-drag-region')
fireEvent.mouseDown(scrollRegion!)
fireEvent.mouseDown(scrollRegion)
await waitFor(() => {
expect(startDraggingMock).toHaveBeenCalledTimes(1)
})
expect(startDraggingMock).not.toHaveBeenCalled()
})
it('does not start dragging when clicking a tab', async () => {
@ -582,10 +583,10 @@ describe('TabBar', () => {
render(<TabBar />)
})
expect(screen.getByTestId('tab-bar').querySelector('.tab-bar-hit-area')).toBeInTheDocument()
expect(screen.getByTestId('tab-bar').querySelector('.tab-bar-interactive')).toBeInTheDocument()
const firstTab = screen.getByText('First Session').closest('.tab-bar-hit-area')
const secondTab = screen.getByText('Second Session').closest('.tab-bar-hit-area')
const firstTab = screen.getByText('First Session').closest('.tab-bar-interactive')
const secondTab = screen.getByText('Second Session').closest('.tab-bar-interactive')
expect(firstTab).toBeTruthy()
expect(secondTab).toBeTruthy()
@ -630,7 +631,7 @@ describe('TabBar', () => {
render(<TabBar />)
})
const firstTab = screen.getByText('First Session').closest('.tab-bar-hit-area')
const firstTab = screen.getByText('First Session').closest('.tab-bar-interactive')
expect(firstTab).toBeTruthy()
fireEvent.mouseDown(firstTab!, { button: 0, clientX: 20, clientY: 10 })
@ -664,7 +665,7 @@ describe('TabBar', () => {
render(<TabBar />)
})
const firstTab = screen.getByText('First Session').closest('.tab-bar-hit-area')
const firstTab = screen.getByText('First Session').closest('.tab-bar-interactive')
const closeButton = screen.getByLabelText('Close First Session')
expect(firstTab).toHaveClass('group')

View File

@ -22,7 +22,6 @@ const TAB_WIDTH = 180
const DRAG_START_THRESHOLD = 4
const desktopHost = getDesktopHost()
const isDesktopRuntime = desktopHost.isDesktop
const canStartWindowDragging = desktopHost.capabilities.windowControls
type PendingCloseRequest = {
tabs: Tab[]
@ -92,7 +91,6 @@ export function TabBar() {
const pendingDragRef = useRef<{ index: number; startX: number; startY: number } | null>(null)
const suppressClickRef = useRef(false)
const tabRefs = useRef(new Map<string, HTMLDivElement | null>())
const startDraggingRef = useRef<(() => Promise<void>) | null>(null)
const t = useTranslation()
const runningSessionIds = useMemo(() => {
const ids = new Set<string>()
@ -105,11 +103,6 @@ export function TabBar() {
return ids
}, [activeChatSessionIds, tabs])
useEffect(() => {
if (!canStartWindowDragging) return
startDraggingRef.current = () => getDesktopHost().window.startDragging()
}, [])
const updateScrollState = useCallback(() => {
const el = scrollRef.current
if (!el) return
@ -326,16 +319,10 @@ export function TabBar() {
setActiveTab(sessionId)
}
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(() => {})
}, [])
return (
<div
data-testid="tab-bar"
data-desktop-drag-region={isDesktopRuntime ? true : undefined}
className="flex min-h-11 items-stretch bg-[var(--color-surface-container)] select-none border-b border-[var(--color-border)]"
>
@ -347,9 +334,10 @@ export function TabBar() {
<div
ref={scrollRef}
className="tab-bar-hit-area flex-1 flex items-stretch overflow-x-hidden"
data-testid="tab-bar-scroll-region"
data-desktop-drag-region={isDesktopRuntime ? true : undefined}
className="flex-1 flex items-stretch overflow-x-hidden"
onDragOver={(e) => e.preventDefault()}
onMouseDown={handleScrollRegionMouseDown}
>
{tabs.map((tab, index) => (
<TabItem
@ -522,7 +510,7 @@ const TabItem = forwardRef<HTMLDivElement, {
onMouseDown={onMouseDown}
onContextMenu={onContextMenu}
className={`
tab-bar-hit-area group relative flex min-h-11 flex-shrink-0 items-center gap-1.5 px-3
tab-bar-interactive group relative flex min-h-11 flex-shrink-0 items-center gap-1.5 px-3
${isDragging ? 'z-20 cursor-grabbing' : 'cursor-grab'}
transition-[background-color,box-shadow,opacity,transform] duration-150 ease-out
${isActive

View File

@ -960,16 +960,20 @@ html, body, #root {
/* Desktop drag region */
[data-desktop-drag-region] {
app-region: drag;
-webkit-app-region: drag;
}
button, input, textarea, select, a, [role="button"] {
app-region: no-drag;
-webkit-app-region: no-drag;
}
[draggable="true"] {
app-region: no-drag;
-webkit-app-region: no-drag;
}
.tab-bar-hit-area,
.tab-bar-hit-area * {
.tab-bar-interactive,
.tab-bar-interactive * {
app-region: no-drag;
-webkit-app-region: no-drag;
}