fix(desktop): keep a window-state IPC that lands before the first connection publishes

`onWindowStateChanged` merged the fullscreen / traffic-light payload into
`$connection`, but only when a connection was already published; while
`$connection` was null (mid-boot, or between a dropped primary and its
fallback resolving) the payload was dropped. Main snapshots the chrome
state into each descriptor at mint time, so a toggle that fires after the
mint but before the renderer publishes it is newer than the snapshot and
never reached the titlebar cluster until the next toggle — the third
root-cause atom of #108641.

Buffer the last payload while nothing is published and fold it into the
next non-null publish, then clear it: later descriptors carry their own
fresh snapshot.
This commit is contained in:
teknium1
2026-09-16 14:35:43 -07:00
committed by Teknium
parent 0d777e2120
commit a02e1d78a4
2 changed files with 53 additions and 1 deletions

View File

@@ -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<string, unknown>) => void) | null = null
const pending = deferred<Record<string, unknown>>()
const desktop = {
...fakeDesktop(),
getConnection: vi.fn(() => pending.promise),
onWindowStateChanged: vi.fn((callback: (payload: Record<string, unknown>) => void) => {
windowState = callback
return () => undefined
})
}
;(window as { hermesDesktop?: unknown }).hermesDesktop = desktop
render(<Harness />)
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()
})
})

View File

@@ -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
}
})