diff --git a/apps/desktop/src/app/gateway/hooks/use-gateway-boot.test.tsx b/apps/desktop/src/app/gateway/hooks/use-gateway-boot.test.tsx index 1a9c6b284c..5c26ae05ae 100644 --- a/apps/desktop/src/app/gateway/hooks/use-gateway-boot.test.tsx +++ b/apps/desktop/src/app/gateway/hooks/use-gateway-boot.test.tsx @@ -2248,3 +2248,40 @@ describe('useGatewayBoot remote reconnect loop (real hook, fake socket)', () => expect($gatewayState.get()).toBe('open') }) }) + +describe('window-state IPC before the first connection publishes (#108641)', () => { + it('a fullscreen toggle that lands while getConnection is still pending reaches the published connection', async () => { + // Main snapshots chrome state into the descriptor at mint time; a toggle + // that fires after the mint but before the renderer publishes it is newer + // than the snapshot and used to be dropped because $connection was null. + let windowState: ((payload: Record) => void) | null = null + const pending = deferred>() + + const desktop = { + ...fakeDesktop(), + getConnection: vi.fn(() => pending.promise), + onWindowStateChanged: vi.fn((callback: (payload: Record) => void) => { + windowState = callback + + return () => undefined + }) + } + + ;(window as { hermesDesktop?: unknown }).hermesDesktop = desktop + + render() + await flushAsync() + expect($connection.get()).toBeNull() + + act(() => windowState?.({ isFullscreen: true, nativeOverlayWidth: 0, windowButtonPosition: null })) + expect($connection.get()).toBeNull() + + await act(async () => { + pending.resolve({ ...primaryConn, isFullscreen: false, windowButtonPosition: { x: 12, y: 16 } }) + await vi.advanceTimersByTimeAsync(0) + }) + + expect($connection.get()?.isFullscreen).toBe(true) + expect($connection.get()?.windowButtonPosition).toBeNull() + }) +}) diff --git a/apps/desktop/src/app/gateway/hooks/use-gateway-boot.ts b/apps/desktop/src/app/gateway/hooks/use-gateway-boot.ts index b510158fe5..0d2005a91d 100644 --- a/apps/desktop/src/app/gateway/hooks/use-gateway-boot.ts +++ b/apps/desktop/src/app/gateway/hooks/use-gateway-boot.ts @@ -10,7 +10,7 @@ import { import { useEffect, useRef } from 'react' import { shouldApplyPostBootProgressError } from '@/components/boot-failure-reauth' -import type { DesktopBootProgress, HermesConnection } from '@/global' +import type { DesktopBootProgress, HermesConnection, HermesWindowState } from '@/global' import { HermesGateway } from '@/hermes' import { translateNow } from '@/i18n' import { desktopDefaultCwd } from '@/lib/desktop-fs' @@ -199,7 +199,20 @@ export function useGatewayBoot({ let cancelled = false const desktop = window.hermesDesktop + // Window-state IPC (fullscreen / traffic-light position) that lands while + // no connection is published — mid-boot, or between a dropped primary and + // its fallback resolving — has nowhere to merge into. Main snapshots the + // chrome state into each descriptor at mint time, so a toggle that happens + // AFTER the mint but BEFORE the renderer publishes it is newer than the + // snapshot and would otherwise be lost until the next toggle (#108641). + let pendingWindowState: HermesWindowState | null = null + const publish = (next: HermesConnection | null) => { + if (next && pendingWindowState) { + next = { ...next, ...pendingWindowState } + pendingWindowState = null + } + callbacksRef.current.onConnectionReady(next) setConnection(next) desktop?.setActiveConnectionRoute?.( @@ -1108,6 +1121,8 @@ export function useGatewayBoot({ if (current) { publish({ ...current, ...payload }) + } else { + pendingWindowState = payload } })