diff --git a/packages/electron/main.mjs b/packages/electron/main.mjs index 1ea1100c..66f5d54c 100644 --- a/packages/electron/main.mjs +++ b/packages/electron/main.mjs @@ -182,6 +182,7 @@ const state = { windowCounter: 1, focusedWindowIds: new Set(), windowGeometryRevisions: new Map(), + windowGeometryTimers: new Map(), miniChatWindowsBySession: new Map(), sshStatuses: new Map(), sshLogs: new Map(), @@ -581,8 +582,15 @@ const debounceWindowStatePersist = (browserWindow, immediate = false) => { const revision = (state.windowGeometryRevisions.get(key) || 0) + 1; state.windowGeometryRevisions.set(key, revision); + const existingTimer = state.windowGeometryTimers.get(key); + if (existingTimer) { + clearTimeout(existingTimer); + state.windowGeometryTimers.delete(key); + } + const persist = async () => { if (state.windowGeometryRevisions.get(key) !== revision) return; + state.windowGeometryTimers.delete(key); await writeWindowState(browserWindow); }; @@ -591,9 +599,10 @@ const debounceWindowStatePersist = (browserWindow, immediate = false) => { return; } - setTimeout(() => { + const timer = setTimeout(() => { void persist(); }, 300); + state.windowGeometryTimers.set(key, timer); }; const buildHealthUrl = (url) => { @@ -1848,7 +1857,9 @@ const createBrowserWindow = ({ label, restoreGeometry, url, runtimeConfig = {} } } browserWindow.on('resize', () => { - emitToWindow(browserWindow, 'openchamber:window-resized'); + if (process.platform === 'darwin') { + emitToWindow(browserWindow, 'openchamber:window-resized'); + } debounceWindowStatePersist(browserWindow, false); }); browserWindow.on('maximize', () => { diff --git a/packages/ui/src/components/layout/MainLayout.tsx b/packages/ui/src/components/layout/MainLayout.tsx index b23425a5..cf042962 100644 --- a/packages/ui/src/components/layout/MainLayout.tsx +++ b/packages/ui/src/components/layout/MainLayout.tsx @@ -271,24 +271,24 @@ export const MainLayout: React.FC = () => { return; } - let timeoutId: number | undefined; + let frameId: number | undefined; const handleResize = () => { - if (timeoutId !== undefined) { - window.clearTimeout(timeoutId); + if (frameId !== undefined) { + return; } - - timeoutId = window.setTimeout(() => { + frameId = window.requestAnimationFrame(() => { + frameId = undefined; useUIStore.getState().updateProportionalSidebarWidths(); - }, 150); + }); }; window.addEventListener('resize', handleResize); return () => { window.removeEventListener('resize', handleResize); - if (timeoutId !== undefined) { - window.clearTimeout(timeoutId); + if (frameId !== undefined) { + window.cancelAnimationFrame(frameId); } }; }, []); @@ -298,7 +298,7 @@ export const MainLayout: React.FC = () => { return; } - let timeoutId: number | undefined; + let frameId: number | undefined; const handleResponsivePanels = () => { const state = useUIStore.getState(); @@ -340,13 +340,13 @@ export const MainLayout: React.FC = () => { }; const handleResize = () => { - if (timeoutId !== undefined) { - window.clearTimeout(timeoutId); + if (frameId !== undefined) { + return; } - - timeoutId = window.setTimeout(() => { + frameId = window.requestAnimationFrame(() => { + frameId = undefined; handleResponsivePanels(); - }, 100); + }); }; handleResponsivePanels(); @@ -354,8 +354,8 @@ export const MainLayout: React.FC = () => { return () => { window.removeEventListener('resize', handleResize); - if (timeoutId !== undefined) { - window.clearTimeout(timeoutId); + if (frameId !== undefined) { + window.cancelAnimationFrame(frameId); } }; }, [isMobile, isTablet, setBottomTerminalOpen, setRightSidebarOpen]); diff --git a/packages/ui/src/lib/device.ts b/packages/ui/src/lib/device.ts index 267c8c4b..846ae83c 100644 --- a/packages/ui/src/lib/device.ts +++ b/packages/ui/src/lib/device.ts @@ -29,6 +29,17 @@ export const BREAKPOINTS = { '2xl': 1536, } as const; +const DEFAULT_DEVICE_INFO: DeviceInfo = { + isMobile: false, + isTablet: false, + isDesktop: true, + deviceType: 'desktop', + screenWidth: 1024, + breakpoint: 'lg', + hasTouchInput: false, + hasTouchOnlyPointer: false, +}; + const getNavigatorDeviceHints = (maxTouchPoints: number) => { if (typeof navigator === 'undefined') { return { isExplicitTablet: false }; @@ -146,6 +157,125 @@ export function getDeviceInfo(): DeviceInfo { }; } +const isSameDeviceInfo = (left: DeviceInfo, right: DeviceInfo): boolean => ( + left.isMobile === right.isMobile + && left.isTablet === right.isTablet + && left.isDesktop === right.isDesktop + && left.deviceType === right.deviceType + && left.screenWidth === right.screenWidth + && left.breakpoint === right.breakpoint + && left.hasTouchInput === right.hasTouchInput + && left.hasTouchOnlyPointer === right.hasTouchOnlyPointer +); + +const deviceInfoSubscribers = new Set<() => void>(); +let deviceInfoSnapshot: DeviceInfo | null = null; +let deviceInfoFrameId: number | undefined; +let cleanupDeviceInfoSource: (() => void) | null = null; + +const readDeviceInfoSnapshot = (): DeviceInfo => { + if (typeof window === 'undefined') { + return DEFAULT_DEVICE_INFO; + } + + if (!deviceInfoSnapshot) { + deviceInfoSnapshot = getDeviceInfo(); + } + + return deviceInfoSnapshot; +}; + +const notifyDeviceInfoSubscribers = () => { + for (const listener of deviceInfoSubscribers) { + listener(); + } +}; + +const updateDeviceInfoSnapshot = () => { + deviceInfoFrameId = undefined; + const next = getDeviceInfo(); + if (deviceInfoSnapshot && isSameDeviceInfo(deviceInfoSnapshot, next)) { + return; + } + + deviceInfoSnapshot = next; + notifyDeviceInfoSubscribers(); +}; + +const scheduleDeviceInfoUpdate = () => { + if (typeof window === 'undefined' || deviceInfoFrameId !== undefined) { + return; + } + + deviceInfoFrameId = window.requestAnimationFrame(updateDeviceInfoSnapshot); +}; + +const attachMediaQueryListener = (query: MediaQueryList | null, listener: () => void): (() => void) => { + if (!query) { + return () => {}; + } + + if (typeof query.addEventListener === 'function') { + query.addEventListener('change', listener); + return () => query.removeEventListener('change', listener); + } + + if (typeof query.addListener === 'function') { + query.addListener(listener); + return () => query.removeListener(listener); + } + + return () => {}; +}; + +const startDeviceInfoSource = (): (() => void) => { + if (typeof window === 'undefined') { + return () => {}; + } + + deviceInfoSnapshot = getDeviceInfo(); + window.addEventListener('resize', scheduleDeviceInfoUpdate); + + const pointerQuery = typeof window.matchMedia === 'function' + ? window.matchMedia('(pointer: coarse)') + : null; + const hoverQuery = typeof window.matchMedia === 'function' + ? window.matchMedia('(hover: none)') + : null; + const cleanupPointer = attachMediaQueryListener(pointerQuery, scheduleDeviceInfoUpdate); + const cleanupHover = attachMediaQueryListener(hoverQuery, scheduleDeviceInfoUpdate); + + return () => { + window.removeEventListener('resize', scheduleDeviceInfoUpdate); + cleanupPointer(); + cleanupHover(); + if (deviceInfoFrameId !== undefined) { + window.cancelAnimationFrame(deviceInfoFrameId); + deviceInfoFrameId = undefined; + } + }; +}; + +const subscribeDeviceInfo = (listener: () => void): (() => void) => { + if (typeof window === 'undefined') { + return () => {}; + } + + deviceInfoSubscribers.add(listener); + if (!cleanupDeviceInfoSource) { + cleanupDeviceInfoSource = startDeviceInfoSource(); + } + + return () => { + deviceInfoSubscribers.delete(listener); + if (deviceInfoSubscribers.size === 0 && cleanupDeviceInfoSource) { + cleanupDeviceInfoSource(); + cleanupDeviceInfoSource = null; + deviceInfoSnapshot = null; + } + }; +}; + export function isMobileDeviceViaCSS(): boolean { if (typeof window === 'undefined') return false; @@ -224,87 +354,9 @@ export function useTabletStandalonePwaRuntime(): boolean { } export function useDeviceInfo(): DeviceInfo { - const [deviceInfo, setDeviceInfo] = React.useState(() => { - if (typeof window === 'undefined') { - return { - isMobile: false, - isTablet: false, - isDesktop: true, - deviceType: 'desktop', - screenWidth: 1024, - breakpoint: 'lg', - hasTouchInput: false, - hasTouchOnlyPointer: false, - }; - } - return getDeviceInfo(); - }); - - React.useEffect(() => { - if (typeof window === 'undefined') return; - - let debounceTimer: ReturnType; - const handleResize = () => { - clearTimeout(debounceTimer); - debounceTimer = setTimeout(() => { - setDeviceInfo(getDeviceInfo()); - }, 150); - }; - - window.addEventListener('resize', handleResize); - return () => { - clearTimeout(debounceTimer); - window.removeEventListener('resize', handleResize); - }; - }, []); - - React.useEffect(() => { - if (typeof window === 'undefined' || typeof window.matchMedia !== 'function') { - return; - } - - const pointerQuery = window.matchMedia('(pointer: coarse)'); - const hoverQuery = window.matchMedia('(hover: none)'); - - const handlePointerChange = () => { - setDeviceInfo(getDeviceInfo()); - }; - - const cleanups: Array<() => void> = []; - - const attachListener = (query: MediaQueryList | null) => { - if (!query) { - return; - } - if (typeof query.addEventListener === 'function') { - query.addEventListener('change', handlePointerChange); - cleanups.push(() => query.removeEventListener('change', handlePointerChange)); - } else if (typeof query.addListener === 'function') { - query.addListener(handlePointerChange); - cleanups.push(() => query.removeListener(handlePointerChange)); - } - }; - - attachListener(pointerQuery); - attachListener(hoverQuery); - - return () => { - cleanups.forEach((cleanup) => cleanup()); - }; - }, []); - - React.useEffect(() => { - if (typeof window === 'undefined') return; - const isDesktopShellRuntime = isDesktopShell(); - const supportsMatchMedia = typeof window.matchMedia === 'function'; - const pointerQuery = supportsMatchMedia ? window.matchMedia('(pointer: coarse)') : null; - const hoverQuery = supportsMatchMedia ? window.matchMedia('(hover: none)') : null; - const prefersCoarsePointer = pointerQuery?.matches ?? false; - const noHover = hoverQuery?.matches ?? false; - const maxTouchPoints = typeof navigator !== 'undefined' ? navigator.maxTouchPoints ?? 0 : 0; - const hasTouchInput = prefersCoarsePointer || noHover || maxTouchPoints > 0; - setRootDeviceAttributes(isDesktopShellRuntime, deviceInfo.deviceType, hasTouchInput); - }, [deviceInfo.deviceType, deviceInfo.hasTouchInput]); - - return deviceInfo; + return React.useSyncExternalStore( + subscribeDeviceInfo, + readDeviceInfoSnapshot, + () => DEFAULT_DEVICE_INFO, + ); } diff --git a/packages/ui/src/stores/useUIStore.ts b/packages/ui/src/stores/useUIStore.ts index bc33f12c..e2c4d672 100644 --- a/packages/ui/src/stores/useUIStore.ts +++ b/packages/ui/src/stores/useUIStore.ts @@ -1854,10 +1854,13 @@ export const useUIStore = create()( const updates: Partial = {}; if (state.isBottomTerminalOpen && !state.hasManuallyResizedBottomTerminal) { - updates.bottomTerminalHeight = Math.floor(window.innerHeight * 0.32); + const nextHeight = Math.floor(window.innerHeight * 0.32); + if (state.bottomTerminalHeight !== nextHeight) { + updates.bottomTerminalHeight = nextHeight; + } } - return updates; + return Object.keys(updates).length > 0 ? updates : state; }); },