From 0676c194e84b2da77c94d3992eadbf6e5eb9d7cb 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: Tue, 15 Sep 2026 20:49:57 +0800 Subject: [PATCH] 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. --- desktop/src/components/layout/TabBar.test.tsx | 34 +++++++++++++++++++ desktop/src/components/layout/TabBar.tsx | 13 ++++++- .../workbench/WorkspaceSurface.test.tsx | 3 ++ .../workbench/WorkspaceTabStrip.test.tsx | 17 ++++++++++ .../workbench/WorkspaceTabStrip.tsx | 7 ++-- desktop/src/theme/globals.css | 4 ++- desktop/src/theme/globals.test.ts | 11 ++++++ 7 files changed, 84 insertions(+), 5 deletions(-) diff --git a/desktop/src/components/layout/TabBar.test.tsx b/desktop/src/components/layout/TabBar.test.tsx index 7f5ead59..2990d91a 100644 --- a/desktop/src/components/layout/TabBar.test.tsx +++ b/desktop/src/components/layout/TabBar.test.tsx @@ -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>) + + await act(async () => { + render() + }) + 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') diff --git a/desktop/src/components/layout/TabBar.tsx b/desktop/src/components/layout/TabBar.tsx index 2701d378..812970e7 100644 --- a/desktop/src/components/layout/TabBar.tsx +++ b/desktop/src/components/layout/TabBar.tsx @@ -636,11 +636,22 @@ export function TabBar() { >
- {hasWorkspaceHeader ?
: null} + {hasWorkspaceHeader ? ( +
+ ) : null} {showActivityButton && activeTabId && ( )} diff --git a/desktop/src/components/workbench/WorkspaceSurface.test.tsx b/desktop/src/components/workbench/WorkspaceSurface.test.tsx index 2aaf8818..736c2dc8 100644 --- a/desktop/src/components/workbench/WorkspaceSurface.test.tsx +++ b/desktop/src/components/workbench/WorkspaceSurface.test.tsx @@ -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() diff --git a/desktop/src/components/workbench/WorkspaceTabStrip.test.tsx b/desktop/src/components/workbench/WorkspaceTabStrip.test.tsx index 97cb1616..693d20bf 100644 --- a/desktop/src/components/workbench/WorkspaceTabStrip.test.tsx +++ b/desktop/src/components/workbench/WorkspaceTabStrip.test.tsx @@ -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] }) diff --git a/desktop/src/components/workbench/WorkspaceTabStrip.tsx b/desktop/src/components/workbench/WorkspaceTabStrip.tsx index a4bbf4c0..e0bd3585 100644 --- a/desktop/src/components/workbench/WorkspaceTabStrip.tsx +++ b/desktop/src/components/workbench/WorkspaceTabStrip.tsx @@ -209,6 +209,7 @@ export function WorkspaceTabStrip({ return (
@@ -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({ })}
- + } label={t('workspace.tabAdd')} @@ -369,7 +370,7 @@ export function WorkspaceTabStrip({
{activeTerminalTab ? ( - + } label={t('workspace.tabMenu')} diff --git a/desktop/src/theme/globals.css b/desktop/src/theme/globals.css index a2b42a71..4fa6735c 100644 --- a/desktop/src/theme/globals.css +++ b/desktop/src/theme/globals.css @@ -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"] { diff --git a/desktop/src/theme/globals.test.ts b/desktop/src/theme/globals.test.ts index 1cdcde66..26193391 100644 --- a/desktop/src/theme/globals.test.ts +++ b/desktop/src/theme/globals.test.ts @@ -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')