From bfdfd3bca3faf26f9228c40fd544dfe3f54e4be2 Mon Sep 17 00:00:00 2001 From: Innei Date: Thu, 11 Jun 2026 22:02:48 +0800 Subject: [PATCH] =?UTF-8?q?=F0=9F=90=9B=20fix(desktop):=20adjust=20mac=20f?= =?UTF-8?q?ullscreen=20titlebar=20spacing=20(#15693)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../src/main/controllers/BrowserWindowsCtr.ts | 7 ++++ .../__tests__/BrowserWindowsCtr.test.ts | 16 ++++++++ apps/desktop/src/main/core/browser/Browser.ts | 13 +++++++ .../src/main/core/browser/BrowserManager.ts | 5 +++ .../core/browser/__tests__/Browser.test.ts | 24 ++++++++++++ .../src/transformation/FlatListBuilder.ts | 11 ++---- packages/editor-runtime/package.json | 2 +- .../electron-client-ipc/src/events/system.ts | 1 + .../Electron/titlebar/NavigationBar.tsx | 39 +++++++++++++++---- src/features/Electron/titlebar/layout.test.ts | 23 ++++++++++- src/features/Electron/titlebar/layout.ts | 7 ++++ src/services/electron/system.ts | 4 ++ 12 files changed, 135 insertions(+), 17 deletions(-) diff --git a/apps/desktop/src/main/controllers/BrowserWindowsCtr.ts b/apps/desktop/src/main/controllers/BrowserWindowsCtr.ts index ec52ab6590..5f2bb0cdec 100644 --- a/apps/desktop/src/main/controllers/BrowserWindowsCtr.ts +++ b/apps/desktop/src/main/controllers/BrowserWindowsCtr.ts @@ -91,6 +91,13 @@ export default class BrowserWindowsCtr extends ControllerModule { }); } + @IpcMethod() + isWindowFullScreen() { + return this.withSenderIdentifier((identifier) => { + return this.app.browserManager.isWindowFullScreen(identifier); + }); + } + @IpcMethod() setWindowAlwaysOnTop(flag: boolean) { this.withSenderIdentifier((identifier) => { diff --git a/apps/desktop/src/main/controllers/__tests__/BrowserWindowsCtr.test.ts b/apps/desktop/src/main/controllers/__tests__/BrowserWindowsCtr.test.ts index f14e40629b..356ffc81aa 100644 --- a/apps/desktop/src/main/controllers/__tests__/BrowserWindowsCtr.test.ts +++ b/apps/desktop/src/main/controllers/__tests__/BrowserWindowsCtr.test.ts @@ -29,6 +29,7 @@ const mockCloseWindow = vi.fn(); const mockMinimizeWindow = vi.fn(); const mockMaximizeWindow = vi.fn(); const mockIsWindowMaximized = vi.fn(); +const mockIsWindowFullScreen = vi.fn(); const mockRetrieveByIdentifier = vi.fn(); const mockStartSession = vi.fn(); const testSenderIdentifierString: string = 'test-window-event-id'; @@ -58,6 +59,7 @@ const mockApp = { minimizeWindow: mockMinimizeWindow, maximizeWindow: mockMaximizeWindow, isWindowMaximized: mockIsWindowMaximized, + isWindowFullScreen: mockIsWindowFullScreen, retrieveByIdentifier: mockRetrieveByIdentifier.mockImplementation( (identifier: AppBrowsersIdentifiers | string) => { if (identifier === 'some-other-window') { @@ -166,6 +168,20 @@ describe('BrowserWindowsCtr', () => { }); }); + describe('isWindowFullScreen', () => { + it('should return fullscreen state for the sender window', () => { + mockIsWindowFullScreen.mockReturnValueOnce(true); + + const sender = {} as any; + const context = { sender, event: { sender } as any } as IpcContext; + const result = runWithIpcContext(context, () => browserWindowsCtr.isWindowFullScreen()); + + expect(mockGetIdentifierByWebContents).toHaveBeenCalledWith(context.sender); + expect(mockIsWindowFullScreen).toHaveBeenCalledWith(testSenderIdentifierString); + expect(result).toBe(true); + }); + }); + describe('interceptRoute', () => { const baseParams = { source: 'link-click' as const }; diff --git a/apps/desktop/src/main/core/browser/Browser.ts b/apps/desktop/src/main/core/browser/Browser.ts index d68bc8f6fe..de718d7d02 100644 --- a/apps/desktop/src/main/core/browser/Browser.ts +++ b/apps/desktop/src/main/core/browser/Browser.ts @@ -219,6 +219,7 @@ export default class Browser { this.setupReadyToShowListener(browserWindow); this.setupCloseListener(browserWindow); this.setupFocusListener(browserWindow); + this.setupFullscreenListener(browserWindow); this.setupTopLevelNavigationListener(browserWindow); this.setupWillPreventUnloadListener(browserWindow); this.setupContextMenu(browserWindow); @@ -309,6 +310,18 @@ export default class Browser { }); } + private setupFullscreenListener(browserWindow: BrowserWindow): void { + logger.debug(`[${this.identifier}] Setting up fullscreen event listeners.`); + + browserWindow.on('enter-full-screen', () => { + this.broadcast('windowFullscreenChanged', { isFullScreen: true }); + }); + + browserWindow.on('leave-full-screen', () => { + this.broadcast('windowFullscreenChanged', { isFullScreen: false }); + }); + } + /** * Setup context menu with platform-specific features * Delegates to MenuManager for consistent platform behavior diff --git a/apps/desktop/src/main/core/browser/BrowserManager.ts b/apps/desktop/src/main/core/browser/BrowserManager.ts index a04a8b6532..5a04029241 100644 --- a/apps/desktop/src/main/core/browser/BrowserManager.ts +++ b/apps/desktop/src/main/core/browser/BrowserManager.ts @@ -368,6 +368,11 @@ export class BrowserManager { return browser?.browserWindow.isMaximized() ?? false; } + isWindowFullScreen(identifier: string) { + const browser = this.browsers.get(identifier); + return browser?.browserWindow.isFullScreen() ?? false; + } + setWindowSize(identifier: string, size: { height?: number; width?: number }) { const browser = this.browsers.get(identifier); browser?.setWindowSize(size); diff --git a/apps/desktop/src/main/core/browser/__tests__/Browser.test.ts b/apps/desktop/src/main/core/browser/__tests__/Browser.test.ts index c85e92187f..a41ba00cb7 100644 --- a/apps/desktop/src/main/core/browser/__tests__/Browser.test.ts +++ b/apps/desktop/src/main/core/browser/__tests__/Browser.test.ts @@ -541,6 +541,30 @@ describe('Browser', () => { }); }); + describe('fullscreen events', () => { + it('should broadcast fullscreen state changes', () => { + const enterHandler = mockBrowserWindow.on.mock.calls.find( + (call) => call[0] === 'enter-full-screen', + )?.[1]; + const leaveHandler = mockBrowserWindow.on.mock.calls.find( + (call) => call[0] === 'leave-full-screen', + )?.[1]; + + expect(enterHandler).toBeDefined(); + expect(leaveHandler).toBeDefined(); + + enterHandler(); + expect(mockBrowserWindow.webContents.send).toHaveBeenCalledWith('windowFullscreenChanged', { + isFullScreen: true, + }); + + leaveHandler(); + expect(mockBrowserWindow.webContents.send).toHaveBeenCalledWith('windowFullscreenChanged', { + isFullScreen: false, + }); + }); + }); + describe('close', () => { it('should close window', () => { browser.close(); diff --git a/packages/conversation-flow/src/transformation/FlatListBuilder.ts b/packages/conversation-flow/src/transformation/FlatListBuilder.ts index c90ab835c3..4c1dc9df21 100644 --- a/packages/conversation-flow/src/transformation/FlatListBuilder.ts +++ b/packages/conversation-flow/src/transformation/FlatListBuilder.ts @@ -527,12 +527,7 @@ export class FlatListBuilder { if (!nextContinuation) return; if (this.shouldDrainParentContinuations(nextContinuation.parentId, processedIds)) { - this.buildFlatListRecursive( - nextContinuation.parentId, - flatList, - processedIds, - allMessages, - ); + this.buildFlatListRecursive(nextContinuation.parentId, flatList, processedIds, allMessages); continue; } @@ -586,7 +581,9 @@ export class FlatListBuilder { if (this.isAgentCouncilMode(parentMessage)) return true; - const taskChildren = children.filter((childId) => this.messageMap.get(childId)?.role === 'task'); + const taskChildren = children.filter( + (childId) => this.messageMap.get(childId)?.role === 'task', + ); return taskChildren.length > 1; } diff --git a/packages/editor-runtime/package.json b/packages/editor-runtime/package.json index 568a66ac8a..ff0e31dd94 100644 --- a/packages/editor-runtime/package.json +++ b/packages/editor-runtime/package.json @@ -13,7 +13,7 @@ "lexical": "^0.42.0" }, "peerDependencies": { - "@lobehub/editor": "^4", + "@lobehub/editor": "^4.17.1", "debug": "*" } } diff --git a/packages/electron-client-ipc/src/events/system.ts b/packages/electron-client-ipc/src/events/system.ts index c207b217f9..f9e046f634 100644 --- a/packages/electron-client-ipc/src/events/system.ts +++ b/packages/electron-client-ipc/src/events/system.ts @@ -5,4 +5,5 @@ export interface SystemBroadcastEvents { systemThemeChanged: (data: { themeMode: ThemeAppearance }) => void; themeChanged: (data: { themeMode: ThemeMode }) => void; windowFocused: () => void; + windowFullscreenChanged: (data: { isFullScreen: boolean }) => void; } diff --git a/src/features/Electron/titlebar/NavigationBar.tsx b/src/features/Electron/titlebar/NavigationBar.tsx index d96332a5f3..c60bb8d856 100644 --- a/src/features/Electron/titlebar/NavigationBar.tsx +++ b/src/features/Electron/titlebar/NavigationBar.tsx @@ -1,5 +1,6 @@ 'use client'; +import { useWatchBroadcast } from '@lobechat/electron-client-ipc'; import { ActionIcon, Flexbox, Popover, Tooltip } from '@lobehub/ui'; import { createStaticStyles } from 'antd-style'; import { ArrowLeft, ArrowRight, Clock } from 'lucide-react'; @@ -7,6 +8,7 @@ import { memo, useCallback, useEffect, useState } from 'react'; import { useTranslation } from 'react-i18next'; import ToggleLeftPanelButton from '@/features/NavPanel/ToggleLeftPanelButton'; +import { electronSystemService } from '@/services/electron/system'; import { useGlobalStore } from '@/store/global'; import type { GlobalState } from '@/store/global/initialState'; import { systemStatusSelectors } from '@/store/global/selectors'; @@ -14,17 +16,11 @@ import { electronStylish } from '@/styles/electron'; import { isMacOS } from '@/utils/platform'; import { useNavigationHistory } from '../navigation/useNavigationHistory'; -import { TITLE_BAR_HORIZONTAL_PADDING } from './layout'; +import { getMacTrafficLightPadding } from './layout'; import RecentlyViewed from './RecentlyViewed'; const isMac = isMacOS(); -// Reserve space for macOS traffic lights so the toggle sits to their right. -// Matches the popup TitleBar's MAC_TRAFFIC_LIGHT_WIDTH (80) minus the titlebar's -// own horizontal padding, which already offsets the left edge. -const MAC_TRAFFIC_LIGHT_WIDTH = 80; -const macTrafficLightPadding = MAC_TRAFFIC_LIGHT_WIDTH - TITLE_BAR_HORIZONTAL_PADDING; - // A persistent titlebar toggle must not share the sidebar toggle's id, or it // would create a duplicate DOM id and get caught by NavPanelDraggable's hover CSS. const NAV_TOGGLE_ID = 'titlebar_toggle_left_panel_button'; @@ -52,9 +48,35 @@ const NavigationBar = memo(() => { const { t } = useTranslation('electron'); const { canGoBack, canGoForward, goBack, goForward } = useNavigationHistory(); const [historyOpen, setHistoryOpen] = useState(false); + const [isWindowFullScreen, setIsWindowFullScreen] = useState(false); const leftPanelWidth = useNavPanelWidth(); + useWatchBroadcast('windowFullscreenChanged', ({ isFullScreen }) => { + if (isMac) setIsWindowFullScreen(isFullScreen); + }); + + useEffect(() => { + if (!isMac) return; + + let disposed = false; + + const syncFullScreenState = async () => { + try { + const isFullScreen = await electronSystemService.isWindowFullScreen(); + if (!disposed) setIsWindowFullScreen(isFullScreen); + } catch { + if (!disposed) setIsWindowFullScreen(false); + } + }; + + void syncFullScreenState(); + + return () => { + disposed = true; + }; + }, []); + // Toggle history popover const toggleHistoryOpen = useCallback(() => { setHistoryOpen((prev) => !prev); @@ -80,6 +102,7 @@ const NavigationBar = memo(() => { const tooltipContent = t('navigation.recentView'); const isLeftPanelVisible = leftPanelWidth > 0; + const macTrafficLightPadding = getMacTrafficLightPadding(isMac, isWindowFullScreen); return ( { gap={8} justify={isMac ? 'space-between' : 'end'} style={{ - paddingLeft: isMac ? macTrafficLightPadding : 0, + paddingLeft: macTrafficLightPadding, paddingRight: 8, // Expanded: span the sidebar width so the right group hugs its right edge. // Collapsed (macOS): shrink to content so the controls cluster at the left edge. diff --git a/src/features/Electron/titlebar/layout.test.ts b/src/features/Electron/titlebar/layout.test.ts index 980997f770..f90f415272 100644 --- a/src/features/Electron/titlebar/layout.test.ts +++ b/src/features/Electron/titlebar/layout.test.ts @@ -1,6 +1,11 @@ import { describe, expect, it } from 'vitest'; -import { getTitleBarLayoutConfig, TITLE_BAR_HORIZONTAL_PADDING } from './layout'; +import { + getMacTrafficLightPadding, + getTitleBarLayoutConfig, + MAC_TRAFFIC_LIGHT_WIDTH, + TITLE_BAR_HORIZONTAL_PADDING, +} from './layout'; describe('getTitleBarLayoutConfig', () => { it('reserves right-side space for native Windows controls', () => { @@ -27,3 +32,19 @@ describe('getTitleBarLayoutConfig', () => { }); }); }); + +describe('getMacTrafficLightPadding', () => { + it('reserves traffic-light space for regular macOS windows', () => { + expect(getMacTrafficLightPadding(true, false)).toBe( + MAC_TRAFFIC_LIGHT_WIDTH - TITLE_BAR_HORIZONTAL_PADDING, + ); + }); + + it('removes traffic-light space for macOS fullscreen windows', () => { + expect(getMacTrafficLightPadding(true, true)).toBe(0); + }); + + it('does not reserve traffic-light space outside macOS', () => { + expect(getMacTrafficLightPadding(false, false)).toBe(0); + }); +}); diff --git a/src/features/Electron/titlebar/layout.ts b/src/features/Electron/titlebar/layout.ts index a836dcd745..e02d0bf903 100644 --- a/src/features/Electron/titlebar/layout.ts +++ b/src/features/Electron/titlebar/layout.ts @@ -1,5 +1,12 @@ export const TITLE_BAR_HORIZONTAL_PADDING = 12; export const WINDOWS_NATIVE_CONTROL_WIDTH = 150; +export const MAC_TRAFFIC_LIGHT_WIDTH = 80; + +export const getMacTrafficLightPadding = (isMac: boolean, isFullScreen: boolean): number => { + if (!isMac || isFullScreen) return 0; + + return MAC_TRAFFIC_LIGHT_WIDTH - TITLE_BAR_HORIZONTAL_PADDING; +}; export interface TitleBarLayoutConfig { padding: string; diff --git a/src/services/electron/system.ts b/src/services/electron/system.ts index 4bbcbb74f4..7a395fa4eb 100644 --- a/src/services/electron/system.ts +++ b/src/services/electron/system.ts @@ -36,6 +36,10 @@ class ElectronSystemService { return this.ipc.windows.isWindowMaximized(); } + async isWindowFullScreen(): Promise { + return this.ipc.windows.isWindowFullScreen(); + } + async minimizeWindow(): Promise { return this.ipc.windows.minimizeWindow(); }