fix(desktop): restore window dragging on the sidebar and workspace headers

The sidebar shell's permanent compositing layer dropped macOS app-region
hits around the traffic lights. The workspace header slot also marked its
leftover titlebar space as no-drag, so the empty strip could not move the
window on either platform.
This commit is contained in:
程序员阿江(Relakkes)
2026-09-15 20:49:57 +08:00
parent 8a9e3800a7
commit 0676c194e8
7 changed files with 84 additions and 5 deletions
@@ -1634,6 +1634,40 @@ describe('TabBar', () => {
expect(frame).toContainElement(gutter)
})
it('keeps the open workspace header as window chrome instead of a no-drag tab strip', async () => {
const { TabBar } = await import('./TabBar')
const { useTabStore } = await import('../../stores/tabStore')
const { useChatStore } = await import('../../stores/chatStore')
const { useWorkspaceStore } = await import('../../stores/workspaceStore')
const { WorkspaceHeaderProvider } = await import('./WorkspaceHeaderContext')
const sessionId = 'header-drag-session'
useTabStore.setState({
tabs: [{ sessionId, title: 'Header drag session', type: 'session', status: 'idle' }],
activeTabId: sessionId,
})
useChatStore.setState({
sessions: {},
disconnectSession: vi.fn(),
} as Partial<ReturnType<typeof useChatStore.getState>>)
await act(async () => {
render(<WorkspaceHeaderProvider><TabBar /></WorkspaceHeaderProvider>)
})
await act(async () => {
useWorkspaceStore.getState().toggleWorkspace(sessionId)
})
const slot = screen.getByTestId('workspace-header-slot')
const header = screen.getByTestId('workspace-window-header')
// `tab-bar-interactive` marks the node *and every descendant* as no-drag.
// The slot hosts the resource strip's leftover flex space, so that class
// would make the circled empty titlebar undraggable on every platform.
expect(slot).toHaveAttribute('data-desktop-drag-region')
expect(slot.className).not.toMatch(/\btab-bar-interactive\b/)
expect(header).toHaveAttribute('data-desktop-drag-region')
})
it('lifts the active tab onto the paper ground without turning it into a pill', async () => {
const { TabBar } = await import('./TabBar')
const { useTabStore } = await import('../../stores/tabStore')
+12 -1
View File
@@ -636,11 +636,22 @@ export function TabBar() {
>
<div
data-testid="workspace-window-header"
data-desktop-drag-region={hasWorkspaceHeader && isDesktopRuntime ? true : undefined}
className={hasWorkspaceHeader
? 'flex min-w-0 flex-1 items-center gap-1 pr-2'
: 'relative flex shrink-0 items-center gap-1 px-2 before:absolute before:left-0 before:top-1/2 before:h-4 before:w-px before:-translate-y-1/2 before:bg-[var(--color-tab-separator)]'}
>
{hasWorkspaceHeader ? <div ref={workspaceHeader.ref} data-testid="workspace-header-slot" className="tab-bar-interactive flex h-[52px] min-w-0 flex-1" /> : null}
{hasWorkspaceHeader ? (
<div
ref={workspaceHeader.ref}
data-testid="workspace-header-slot"
data-desktop-drag-region={isDesktopRuntime ? true : undefined}
// The slot is titlebar chrome: empty space must drag the window.
// `tab-bar-interactive` here would also mark every descendant as
// no-drag, including the resource strip's leftover flex space.
className="flex h-[52px] min-w-0 flex-1"
/>
) : null}
{showActivityButton && activeTabId && (
<SessionActivityButton sessionId={activeTabId} />
)}
@@ -450,6 +450,9 @@ describe('WorkspaceSurface', () => {
expect(sessionHeader).toHaveClass('min-w-0', 'overflow-hidden')
expect(sessionHeader).not.toContainElement(screen.getByTestId('workspace-header-frame'))
expect(within(header).getByTestId('workspace-tab-strip-side')).toBeInTheDocument()
expect(screen.getByTestId('workspace-header-slot').className).not.toMatch(/\btab-bar-interactive\b/)
expect(within(header).getByTestId('workspace-tab-strip-side')).toHaveAttribute('data-desktop-drag-region')
expect(within(header).getByTestId(`workspace-tab-wrap-${sideId}`)).toHaveClass('tab-bar-interactive')
expect(within(header).getByTestId('workspace-toggle-side')).toBeInTheDocument()
expect(within(screen.getByTestId('workspace-surface-side')).queryByRole('tablist')).toBeNull()
expect(within(screen.getByTestId('workspace-surface-bottom')).getByRole('tablist')).toBeInTheDocument()
@@ -97,6 +97,23 @@ describe('WorkspaceTabStrip', () => {
expect(screen.getAllByRole('tab')).toHaveLength(1)
})
it('lets leftover window-header space drag the window without capturing tab controls', () => {
renderStrip({ placement: 'window' })
const strip = screen.getByTestId('workspace-tab-strip-side')
const tab = screen.getByTestId('workspace-tab-wrap-tab-file')
const add = screen.getByTestId('workspace-add-tab-side').closest('span')
expect(strip).toHaveAttribute('data-desktop-drag-region')
expect(tab).toHaveClass('tab-bar-interactive')
expect(add).toHaveClass('tab-bar-interactive')
expect(tab).not.toHaveAttribute('data-desktop-drag-region')
})
it('does not turn a dock strip into window chrome', () => {
renderStrip({ placement: 'dock' })
expect(screen.getByTestId('workspace-tab-strip-side')).not.toHaveAttribute('data-desktop-drag-region')
})
it('titles each kind from its own identity', () => {
renderStrip({ tabs: [FILE_TAB, BROWSER_TAB, REVIEW_TAB, TERMINAL_TAB] })
@@ -209,6 +209,7 @@ export function WorkspaceTabStrip({
return (
<div
data-testid={`workspace-tab-strip-${dock}`}
data-desktop-drag-region={placement === 'window' ? true : undefined}
className={`flex min-w-0 shrink-0 items-stretch gap-1 bg-[var(--color-surface)] pl-2 pr-1 ${placement === 'window' ? 'h-[52px] flex-1' : 'h-10 border-b border-[var(--color-border)]'}`}
>
<div className="flex min-w-0 flex-1 items-stretch gap-1">
@@ -251,7 +252,7 @@ export function WorkspaceTabStrip({
onDoubleClick={() => onPin(tab.id)}
onContextMenu={(event) => openMenuAt(event, tab.id)}
className={[
`group ${placement === 'window' ? 'my-2.5' : 'my-1'} flex min-w-[112px] max-w-[200px] cursor-default items-center gap-0.5 rounded-[var(--radius-md)] pl-2 pr-1 transition-colors`,
`tab-bar-interactive group ${placement === 'window' ? 'my-2.5' : 'my-1'} flex min-w-[112px] max-w-[200px] cursor-default items-center gap-0.5 rounded-[var(--radius-md)] pl-2 pr-1 transition-colors`,
isActive
? 'bg-[var(--color-surface-selected)] text-[var(--color-text-primary)]'
: 'text-[var(--color-text-secondary)] hover:bg-[var(--color-surface-hover)]',
@@ -346,7 +347,7 @@ export function WorkspaceTabStrip({
})}
</div>
<span className="flex shrink-0 items-center">
<span className="tab-bar-interactive flex shrink-0 items-center">
<IconButton
icon={<Plus size={15} strokeWidth={2} />}
label={t('workspace.tabAdd')}
@@ -369,7 +370,7 @@ export function WorkspaceTabStrip({
</div>
{activeTerminalTab ? (
<span className="flex shrink-0 items-center">
<span className="tab-bar-interactive flex shrink-0 items-center">
<IconButton
icon={<MoreHorizontal size={16} />}
label={t('workspace.tabMenu')}
+3 -1
View File
@@ -1830,7 +1830,9 @@ button, input, textarea, select, a, [role="button"] {
flex-shrink: 0;
overflow: hidden;
transition: width var(--motion-sidebar-duration) var(--motion-sidebar-easing);
will-change: width;
/* Do not promote this shell with will-change. A permanent compositing
layer makes Electron drop -webkit-app-region hits on macOS, so the
title padding around the traffic lights cannot drag the window. */
}
.sidebar-shell[data-state="closed"] {
+11
View File
@@ -397,6 +397,17 @@ describe('overlay opacity contract', () => {
})
})
describe('desktop window drag', () => {
it('does not promote the sidebar shell into a compositing layer', () => {
// Electron drops -webkit-app-region hits on a macOS compositing layer, so
// a permanent will-change on this shell makes the traffic-light padding
// undraggable. The comment is allowed to mention the property; a real
// declaration is not.
const shellCss = getCssBetween('.sidebar-shell {', '.sidebar-shell[data-state="closed"] {')
expect(shellCss).not.toMatch(/will-change\s*:/)
})
})
describe('layering scale', () => {
const scale = (() => {
const start = normalizedCss.indexOf('/* ─── Layering scale')