fix(desktop): let the boot-failure overlay be dismissed

The recovery modal rendered with no close control and ignored Escape, so a
latched boot error trapped the user. Add a Close button to both the recovery
card and the embedded Gateway settings view, and route Escape through the
same onOpenChange path. Dismissal hides the modal only: the boot error stays
latched, and the overlay comes back when the error changes, clears and
recurs, or a retry starts and fails again.

Co-authored-by: giggling-ginger <110955495+giggling-ginger@users.noreply.github.com>
This commit is contained in:
Hermes Agent
2026-09-24 18:52:49 -05:00
committed by brooklyn!
parent e726b79803
commit fa58e4ab75
2 changed files with 126 additions and 10 deletions

View File

@@ -1,4 +1,4 @@
import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react'
import { act, cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { en } from '@/i18n/en'
@@ -117,6 +117,66 @@ describe('BootFailureOverlay', () => {
expect(screen.queryByRole('button', { name: /back/i })).toBeNull()
})
it('hides the modal on dismiss without clearing the boot error', () => {
const { rerender } = render(<BootFailureOverlay />)
const error = $desktopBoot.get().error
fireEvent.click(screen.getByRole('button', { name: /^close$/i }))
expect(screen.queryByRole('dialog')).toBeNull()
expect($desktopBoot.get().error).toBe(error)
expect($desktopBoot.get().error).toBeTruthy()
$desktopBoot.set({ ...$desktopBoot.get(), error: 'A different startup failure' })
rerender(<BootFailureOverlay />)
expect(screen.getByRole('dialog', { name: /Hermes couldn't start/i })).toBeTruthy()
fireEvent.click(screen.getByRole('button', { name: /^close$/i }))
expect(screen.queryByRole('dialog')).toBeNull()
$desktopBoot.set({ ...$desktopBoot.get(), error: null, running: false })
rerender(<BootFailureOverlay />)
$desktopBoot.set({ ...$desktopBoot.get(), error, running: false })
rerender(<BootFailureOverlay />)
expect(screen.getByRole('dialog', { name: /Hermes couldn't start/i })).toBeTruthy()
})
it('dismisses on Escape and keeps the boot error latched', () => {
render(<BootFailureOverlay />)
const error = $desktopBoot.get().error
fireEvent.keyDown(screen.getByRole('dialog', { name: /Hermes couldn't start/i }), { key: 'Escape' })
expect(screen.queryByRole('dialog')).toBeNull()
expect($desktopBoot.get().error).toBe(error)
})
it('dismisses from the embedded gateway settings view', async () => {
render(<BootFailureOverlay />)
fireEvent.click(screen.getByRole('button', { name: /gateway settings/i }))
expect(await screen.findByRole('dialog', { name: /gateway settings/i })).toBeTruthy()
fireEvent.click(screen.getByRole('button', { name: /^close$/i }))
expect(screen.queryByRole('dialog')).toBeNull()
expect($desktopBoot.get().error).toBeTruthy()
})
it('re-shows the same error after a retry starts and fails again', () => {
render(<BootFailureOverlay />)
const error = $desktopBoot.get().error
fireEvent.click(screen.getByRole('button', { name: /^close$/i }))
expect(screen.queryByRole('dialog')).toBeNull()
act(() => $desktopBoot.set({ ...$desktopBoot.get(), running: true }))
act(() => $desktopBoot.set({ ...$desktopBoot.get(), error, running: false }))
expect(screen.getByRole('dialog', { name: /Hermes couldn't start/i })).toBeTruthy()
})
it('drops local-only Repair and Use-local-gateway on a local failure', () => {
render(<BootFailureOverlay />)
// No connection config stub → treated as a local failure.

View File

@@ -12,7 +12,17 @@ import { useI18n } from '@/i18n'
import { reestablishCloudAgentSession } from '@/lib/cloud-agent-session'
import { DESKTOP_DOCS_URL } from '@/lib/docs'
import { openExternalLink } from '@/lib/external-link'
import { ChevronLeft, ExternalLink, FileText, Loader2, LogIn, RefreshCw, SlidersHorizontal, Wrench } from '@/lib/icons'
import {
ChevronLeft,
ExternalLink,
FileText,
Loader2,
LogIn,
RefreshCw,
SlidersHorizontal,
Wrench,
X
} from '@/lib/icons'
import { $desktopBoot } from '@/store/boot'
import { notify, notifyError } from '@/store/notifications'
import { $desktopOnboarding } from '@/store/onboarding'
@@ -44,15 +54,49 @@ type RecoveryView = 'connect' | 'recovery'
// re-establish the remote session. The detection + copy helpers live in
// ./boot-failure-reauth so they're unit-testable without a React render.
// Radix Close routes through the modal's onOpenChange, same path as Escape.
function DismissControl() {
const { t } = useI18n()
return (
<DialogPrimitive.Close asChild data-slot="dialog-close-button">
<Button
aria-label={t.common.close}
className="absolute right-2.5 top-2.5 z-20 text-(--ui-text-tertiary) hover:bg-(--chrome-action-hover) hover:text-foreground"
size="icon-xs"
variant="ghost"
>
<X className="size-4" />
<span className="sr-only">{t.common.close}</span>
</Button>
</DialogPrimitive.Close>
)
}
// Recovery surface for a hard boot failure (gateway never came up, backend
// exited during startup, bootstrap latched, …). Without this the app shell
// renders dead — "gateway offline", no composer, only a toast — with no way
// to retry, repair the install, switch the gateway, or find the logs.
function BootFailureModal({ children, title }: { children: ReactNode; title?: string }) {
function BootFailureModal({
children,
onDismiss,
title
}: {
children: ReactNode
onDismiss: () => void
title?: string
}) {
const [contentNode, setContentNode] = useState<HTMLDivElement | null>(null)
return (
<DialogPrimitive.Root open>
<DialogPrimitive.Root
onOpenChange={open => {
if (!open) {
onDismiss()
}
}}
open
>
<DialogPrimitive.Portal>
<DialogPrimitive.Content aria-describedby={undefined} aria-modal="true" asChild ref={setContentNode}>
<div
@@ -92,13 +136,20 @@ export function BootFailureOverlay() {
// to the full Settings page (keeps the user on the recovery surface, no z-index
// juggling, no second connection form to maintain).
const [view, setView] = useState<RecoveryView>('recovery')
// Dismissal hides the modal only. The latched boot error stays so recovery
// can return, and a later clear or a different error shows the overlay again.
const [dismissedError, setDismissedError] = useState<string | null>(null)
const visible = Boolean(boot.error) && !boot.running
const visible = Boolean(boot.error) && boot.error !== dismissedError && !boot.running
// While first-run onboarding owns the picker/flow we let it surface its own
// progress; the recovery overlay is for hard failures, which it covers via a
// higher z-index regardless of onboarding state.
const suppressed = onboarding.flow.status !== 'idle' && onboarding.flow.status !== 'error'
useEffect(() => {
setDismissedError(current => (boot.running || current !== boot.error ? null : current))
}, [boot.error, boot.running])
useEffect(() => {
if (!visible) {
return
@@ -314,6 +365,9 @@ export function BootFailureOverlay() {
}
const openLogs = () => void window.hermesDesktop?.revealLogs().catch(() => undefined)
const dismiss = () => setDismissedError(boot.error)
const copy = t.boot.failure
// SSH failures keep their own gloss; every other local failure is classified
@@ -450,8 +504,9 @@ export function BootFailureOverlay() {
if (view === 'connect') {
return (
<BootFailureModal title={copy.gatewaySettings}>
<div className="flex max-h-[86vh] w-full max-w-[46rem] flex-col overflow-hidden rounded-xl border border-(--stroke-nous) bg-(--ui-chat-bubble-background) shadow-nous">
<BootFailureModal onDismiss={dismiss} title={copy.gatewaySettings}>
<div className="relative flex max-h-[86vh] w-full max-w-[46rem] flex-col overflow-hidden rounded-xl border border-(--stroke-nous) bg-(--ui-chat-bubble-background) shadow-nous">
<DismissControl />
{/* Subtle back affordance (projects/overlay idiom): muted → foreground
on hover, no divider. */}
<button
@@ -473,9 +528,10 @@ export function BootFailureOverlay() {
}
return (
<BootFailureModal>
<div className="w-full max-w-[40rem] overflow-hidden rounded-xl border border-(--stroke-nous) bg-(--ui-chat-bubble-background) shadow-nous">
<div className="flex items-start gap-3 px-5 py-4">
<BootFailureModal onDismiss={dismiss}>
<div className="relative w-full max-w-[40rem] overflow-hidden rounded-xl border border-(--stroke-nous) bg-(--ui-chat-bubble-background) shadow-nous">
<DismissControl />
<div className="flex items-start gap-3 px-5 py-4 pr-12">
<ErrorIcon className="mt-0.5" size="1.25rem" />
<div>
<DialogPrimitive.Title asChild>