fix(desktop): keep composer usable during reconnect (#45488)

* feat(cli): add --safe-mode troubleshooting flag

Inspired by Claude Code v2.1.169 (June 2026): run Hermes with all
customizations disabled to isolate setup problems from product bugs.

--safe-mode implies --ignore-user-config and --ignore-rules, and
additionally skips plugin discovery (hermes_cli/plugins.py) and MCP
server loading (tools/mcp_tool.py) via the internal HERMES_SAFE_MODE
env bridge.

* fix(desktop): keep composer usable during reconnect
This commit is contained in:
Teknium
2026-06-13 02:36:09 -07:00
committed by GitHub
parent b62e57b2f4
commit 8cf9d8689d
11 changed files with 246 additions and 26 deletions
@@ -3,23 +3,23 @@ import { afterEach, beforeEach, describe, expect, it } from 'vitest'
import { $desktopBoot } from '@/store/boot'
import { $desktopOnboarding } from '@/store/onboarding'
import { $gatewayState, setGatewayState } from '@/store/session'
import { setGatewayState } from '@/store/session'
import { BootFailureOverlay } from './boot-failure-overlay'
import { GatewayConnectingOverlay } from './gateway-connecting-overlay'
// Repro for the "remote gateway → stuck on CONNECTING, no way to settings"
// report. The connecting overlay (z-1200, full-screen, pointer-events on) is
// shown whenever `gatewayState !== 'open' && !boot.error`. The ONLY escape
// report. The connecting overlay (z-1200, full-screen, pointer-events on) used
// to be shown whenever `gatewayState !== 'open' && !boot.error`. The ONLY escape
// hatch — BootFailureOverlay, which has "Use local gateway" / "Sign in" /
// "Retry" — only renders when `boot.error` is set.
//
// useGatewayBoot only calls failDesktopBoot() (which sets boot.error) when the
// INITIAL boot() throws. After the first successful connect (bootCompleted),
// any later socket drop goes through scheduleReconnect(), which loops FOREVER
// against the dead remote and never sets boot.error. So gatewayState sits at
// 'closed'/'error' with boot.error null → CONNECTING forever, recovery overlay
// never appears, settings unreachable.
// against the dead remote. So gatewayState sits at 'closed'/'error' with
// boot.error null. The fix keeps the initial-boot overlay out of post-boot
// reconnects, leaving chat/settings usable while the reconnect loop runs.
function resetStores() {
setGatewayState('idle')
@@ -75,7 +75,7 @@ describe('connecting overlay vs recovery surface', () => {
expect(isConnectingShown()).toBe(false)
})
it('REPRO: remote socket drops AFTER a successful boot → stuck on CONNECTING, no recovery, no settings', () => {
it('post-boot socket drops do not re-cover the app with the initial CONNECTING overlay', () => {
// 1. Initial boot succeeded: gateway opened, boot completed (no error).
setGatewayState('open')
const { rerender } = render(
@@ -97,14 +97,14 @@ describe('connecting overlay vs recovery surface', () => {
</>
)
// The connecting overlay reappears and latches...
expect(isConnectingShown()).toBe(true)
// ...with NO recovery surface, because boot.error was never set.
// The initial-boot connecting overlay stays out of the way, so settings and
// the composer remain reachable during the reconnect loop.
expect(isConnectingShown()).toBe(false)
expect(isRecoveryShown()).toBe(false)
// 3. Reconnect loops forever against the dead remote: gatewayState bounces
// closed → error → closed, boot.error never gets set. The user is
// pinned on CONNECTING with no path to Settings indefinitely.
// 3. Reconnect loops against the dead remote: gatewayState bounces closed
// → error → closed. Until the escalation path sets boot.error, the app
// remains usable instead of modal-blocked.
setGatewayState('error')
rerender(
<>
@@ -113,7 +113,7 @@ describe('connecting overlay vs recovery surface', () => {
</>
)
expect($desktopBoot.get().error).toBeNull()
expect(isConnectingShown()).toBe(true)
expect(isConnectingShown()).toBe(false)
expect(isRecoveryShown()).toBe(false)
})
@@ -52,7 +52,13 @@ export function GatewayConnectingOverlay() {
const [tail, setTail] = useState(TAIL)
const [phase, setPhase] = useState<Phase>('live')
const connecting = gatewayState !== 'open' && !boot.error
// The full-screen connecting overlay is for initial boot only. After a
// healthy boot, flaky networks / sleep-wake can drop the socket and flip the
// gateway state back to closed/error while the app reconnects. Do not cover
// the chat then — users should still be able to type drafts, open settings,
// and recover instead of staring at a modal CONNECTING screen.
const initialBootActive = boot.visible || boot.running || boot.progress < 100
const connecting = gatewayState !== 'open' && !boot.error && initialBootActive
// Latches once we've actually shown the overlay, so the brief frame where
// gatewayState flips to "open" (connecting -> false) before the exit phase
// kicks in doesn't unmount us and cause a flash.