From bd7a27bb307b952712755fd1828021f43e051ecd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=A8=8B=E5=BA=8F=E5=91=98=E9=98=BF=E6=B1=9F=28Relakkes?= =?UTF-8?q?=29?= Date: Mon, 20 Apr 2026 15:50:35 +0800 Subject: [PATCH] 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 --- desktop/src/components/layout/TabBar.test.tsx | 81 +++++++++++++++++++ desktop/src/components/layout/TabBar.tsx | 26 +++++- 2 files changed, 106 insertions(+), 1 deletion(-) diff --git a/desktop/src/components/layout/TabBar.test.tsx b/desktop/src/components/layout/TabBar.test.tsx index b38d04c0..1ff869c5 100644 --- a/desktop/src/components/layout/TabBar.test.tsx +++ b/desktop/src/components/layout/TabBar.test.tsx @@ -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 = { @@ -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>) + + 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>) + + await act(async () => { + render() + }) + + 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>) + + await act(async () => { + render() + }) + + await waitFor(() => { + expect(getCurrentWindowMock).toHaveBeenCalled() + }) + + fireEvent.mouseDown(screen.getByText('Untitled Session')) + + expect(startDraggingMock).not.toHaveBeenCalled() + }) }) diff --git a/desktop/src/components/layout/TabBar.tsx b/desktop/src/components/layout/TabBar.tsx index 54fb2b68..b4089000 100644 --- a/desktop/src/components/layout/TabBar.tsx +++ b/desktop/src/components/layout/TabBar.tsx @@ -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(null) const [dragOverIndex, setDragOverIndex] = useState(null) const dragIndexRef = useRef(null) + const startDraggingRef = useRef<(() => Promise) | 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) => { + 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() { )} -
e.preventDefault()}> +
e.preventDefault()} + onMouseDown={handleScrollRegionMouseDown} + > {tabs.map((tab, index) => (