Desktop app boot overlay and recovery fixes

NousResearch/hermes-agent · cluster 54545 · apps/desktop

Data as of

Nothing open on GitHub. Fix merged in #63030. Check it covers your case.

The short answer

0open issues
0open PRs
5items grouped
Known?
Yes. 5 related items grouped here, 2 issues already closed.
Fix event
A fix was merged to main in PR #63030. Check it covers your case. No release data is tracked.
Tracker
The maintainers' tracker marks this resolved, deciding PRs #63030, #122035. That is separate from the fix event.
Still open
Nothing open on GitHub.

These items relate to the desktop app's handling of startup issues, specifically improving the boot overlay to be dismissible and allowing recovery from a gateway error state.

Open issues0

No open issues.

Open PRs0

No open pull requests.

Fixes on record

Merged to main. A stamp counts the issues a fix is linked to.

  • #63030 fix(desktop): recover a failed gateway from the boot-failure screen

    merged to main

    files: apps/desktop/src/app/settings/gateway-settings.tsx, apps/desktop/src/app/settings/primitives.tsx, apps/desktop/src/components/boot-failure-overlay.test.tsx, apps/desktop/src/components/boot-failure-overlay.tsx, apps/desktop/src/components/boot-failure-reauth.test.ts, apps/desktop/src/components/boot-failure-reauth.ts, apps/desktop/src/components/gateway-connecting-overlay.test.tsx, apps/desktop/src/i18n/en.ts, apps/desktop/src/i18n/ja.ts, apps/desktop/src/i18n/types.ts, apps/desktop/src/i18n/zh-hant.ts, apps/desktop/src/i18n/zh.ts

    Show the diff
    • apps/desktop/src/app/settings/gateway-settings.tsx
      @@ -110,7 +110,11 @@ function ScopeChip({ active, label, onSelect }: { active: boolean; label: string
         )
       }
       
      -export function GatewaySettings() {
      +// `embedded` trims the page chrome for reuse inside the boot-failure recovery
      +// card: the outer title/intro, the "Save for next restart" action, and the
      +// Diagnostics row are redundant there (the card owns its header + a single
      +// reconnect action), so only the connection controls render.
      +export function GatewaySettings({ embedded = false }: { embedded?: boolean } = {}) {
         const { t } = useI18n()
         const g = t.settings.gateway
         const [loading, setLoading] = useState(true)
      @@ -707,17 +711,19 @@ export function GatewaySettings() {
         }
       
         return (
      -    <SettingsContent>
      -      <div className="mb-5">
      -        <div className="flex items-center gap-2 text-[length:var(--conversation-text-font-size)] font-medium">
      -          <Globe className="size-4 text-muted-foreground" />
      -          {g.title}
      -          {state.envOverride ? <Pill tone="primary">{g.envOverride}</Pill> : null}
      +    <SettingsContent bare={embedded}>
      +      {embedded ? null : (
      +        <div className="mb-5">
      +          <div className="flex items-center gap-2 text-[length:var(--conversation-text-font-size)] font-medium">
      +            <Globe className="size-4 text-muted-foreground" />
      +            {g.title}
      +            {state.envOverride ? <Pill tone="primary">{g.envOverride}</Pill> : null}
      +          </div>
      +          <p className="mt-2 max-w-2xl text-[length:var(--conversation-caption-font-size)] leading-(--conversation-caption-line-height) text-(--ui-text-tertiary)">
      +            {g.intro}
      +          </p>
               </div>
      -        <p className="mt-2 max-w-2xl text-[length:var(--conversation-caption-font-size)] leading-(--conversation-caption-line-height) text-(--ui-text-tertiary)">
      -          {g.intro}
      -        </p>
      -      </div>
      +      )}
       
             {namedProfiles.length > 0 ? (
               <div className="mb-5 grid gap-2">
      @@ -1037,14 +1043,16 @@ export function GatewaySettings() {
                     {g.testRemote}
                   </Button>
                 ) : null}
      -          <Button
      -            disabled={state.envOverride || saving}
      -            onClick={() => void save(false)}
      -            size="sm"
      -            variant="textStrong"
      -          >
      -            {g.saveForRestart}
      -          </Button>
      +          {embedded ? null : (
      +            <Button
      +              disabled={state.envOverride || saving}
      +              onClick={() => void save(false)}
      +              size="sm"
      +              variant="textStrong"
      +            >
      +              {g.saveForRestart}
      +            </Button>
      +          )}
                 <Button disabled={state.envOverride || saving} onClick={() => void save(true)} size="sm">
                   {saving ? <Loader2 className="animate-spin" /> : null}
                   {g.saveAndReconnect}
      @@ -1052,18 +1060,20 @@ export function GatewaySettings() {
               </div>
             ) : null}
       
      -      <div className="mt-6 grid gap-1">
      -        <ListRow
      -          action={
      -            <Button onClick={() => void window.hermesDesktop?.revealLogs()} size="sm" variant="textStrong">
      -              <FileText />
      -              {g.openLogs}
      -            </Button>
      -          }
      -          description={g.diagnosticsDesc}
      -          title={g.diagnostics}
      -        />
      -      </div>
      +      {embedded ? null : (
      +        <div className="mt-6 grid gap-1">
      +          <ListRow
      +            action={
      +              <Button onClick={() => void window.hermesDesktop?.revealLogs()} size="sm" variant="textStrong">
      +                <FileText />
      +                {g.openLogs}
      +              </Button>
      +            }
      +            description={g.diagnosticsDesc}
      +            title={g.diagnostics}
      +          />
      +        </div>
      +      )}
           </SettingsContent>
         )
       }
    • apps/desktop/src/app/settings/primitives.tsx
      @@ -8,10 +8,14 @@ import { cn } from '@/lib/utils'
       
       import { PAGE_INSET_X } from '../layout-constants'
       
      -export function SettingsContent({ children }: { children: ReactNode }) {
      +// `bare` drops the page gutters + tall bottom pad for embedding in a tighter
      +// surface (e.g. the boot-failure recovery card owns its own padding).
      +export function SettingsContent({ children, bare = false }: { children: ReactNode; bare?: boolean }) {
         return (
           <section className="min-h-0 overflow-hidden">
      -      <div className={cn('h-full min-h-0 overflow-y-auto pb-20', PAGE_INSET_X)}>{children}</div>
      +      <div className={cn('h-full min-h-0 overflow-y-auto', bare ? 'px-5 pb-6' : cn('pb-20', PAGE_INSET_X))}>
      +        {children}
      +      </div>
           </section>
         )
       }
    • apps/desktop/src/components/boot-failure-overlay.test.tsx
      @@ -0,0 +1,101 @@
      +import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react'
      +import { afterEach, beforeEach, describe, expect, it } from 'vitest'
      +
      +import { $desktopBoot } from '@/store/boot'
      +import { $desktopOnboarding } from '@/store/onboarding'
      +
      +import { BootFailureOverlay } from './boot-failure-overlay'
      +
      +// Remote-backend users hit a hard boot failure that isn't OAuth reauth (token
      +// auth, wrong URL, unreachable host). The recovery screen must let them fix the
      +// remote connection in place — the "Connection settings" action swaps the card
      +// to an in-line connect form — instead of stranding them (the old bug forced a
      +// hand-edit of connection.json).
      +
      +function failBoot() {
      +  $desktopBoot.set({
      +    error: 'Could not connect to Hermes gateway',
      +    fakeMode: false,
      +    message: 'boot failed',
      +    phase: 'renderer.error',
      +    progress: 40,
      +    running: false,
      +    timestamp: Date.now(),
      +    visible: true
      +  })
      +}
      +
      +function stubDesktop(config: Record<string, unknown>) {
      +  const original = window.hermesDesktop
      +  Object.defineProperty(window, 'hermesDesktop', {
      +    configurable: true,
      +    value: { getRecentLogs: async () => ({ lines: [] }), getConnectionConfig: async () => config }
      +  })
      +
      +  return () => Object.defineProperty(window, 'hermesDesktop', { configurable: true, value: original })
      +}
      +
      +const remoteToken = {
      +  envOverride: false,
      +  mode: 'remote',
      +  profile: null,
      +  remoteAuthMode: 'token',
      +  remoteOauthConnected: false,
      +  remoteTokenPreview: null,
      +  remoteTokenSet: true,
      +  remoteUrl: 'http://100.116.104.53:9191',
      +  cloudOrg: ''
      +}
      +
      +beforeEach(() => {
      +  $desktopOnboarding.set({
      +    configured: true,
      +    flow: { status: 'idle' },
      +    mode: 'oauth',
      +    providers: null,
      +    reason: null,
      +    requested: false,
      +    firstRunSkipped: false,
      +    manual: false,
      +    localEndpoint: false
      +  })
      +  failBoot()
      +})
      +
      +afterEach(cleanup)
      +
      +describe('BootFailureOverlay', () => {
      +  it('swaps to the in-place gateway settings view (no route nav) and back', async () => {
      +    render(<BootFailureOverlay />)
      +
      +    fireEvent.click(screen.getByRole('button', { name: /gateway settings/i }))
      +    // Recovery actions give way to the embedded panel (behind a Back control).
      +    expect(await screen.findByRole('button', { name: /back/i })).toBeTruthy()
      +    expect(screen.queryByRole('button', { name: /retry/i })).toBeNull()
      +
      +    fireEvent.click(screen.getByRole('button', { name: /back/i }))
      +    expect(screen.getByRole('button', { name: /retry/i })).toBeTruthy()
      +    expect(screen.queryByRole('button', { name: /back/i })).toBeNull()
      +  })
      +
      +  it('drops local-only Repair and Use-local-gateway on a local failure', () => {
      +    render(<BootFailureOverlay />)
      +    // No connection config stub → treated as a local failure.
      +    expect(screen.getByRole('button', { name: /retry/i })).toBeTruthy()
      +    expect(screen.getByRole('button', { name: /repair/i })).toBeTruthy()
      +    expect(screen.queryByRole('button', { name: /use local gateway/i })).toBeNull()
      +  })
      +
      +  it('leads with Gateway settings and drops Repair for a remote (token) failure', async () => {
      +    const restore = stubDesktop(remoteToken)
      +
      +    try {
      +      render(<BootFailureOverlay />)
      +      await waitFor(() => expect(screen.queryByRole('button', { name: /repair/i })).toBeNull())
      +      expect(screen.getByRole('button', { name: /gateway settings/i })).toBeTruthy()
      +      expect(screen.getByRole('button', { name: /use local gateway/i })).toBeTruthy()
      +    } finally {
      +      restore()
      +    }
      +  })
      +})
    • apps/desktop/src/components/boot-failure-overlay.tsx
      @@ -1,20 +1,29 @@
       import { useStore } from '@nanostores/react'
      -import { useEffect, useState } from 'react'
      +import { type ComponentProps, lazy, type ReactNode, Suspense, useEffect, useState } from 'react'
       
       import { Button } from '@/components/ui/button'
       import { ErrorIcon } from '@/components/ui/error-state'
      +import { Loader } from '@/components/ui/loader'
       import { LogView } from '@/components/ui/log-view'
       import type { DesktopConnectionConfig } from '@/global'
       import { useI18n } from '@/i18n'
      -import { FileText, Loader2, LogIn, RefreshCw, Wrench } from '@/lib/icons'
      +import { ChevronLeft, FileText, Loader2, LogIn, RefreshCw, SlidersHorizontal, Wrench } from '@/lib/icons'
       import { $desktopBoot } from '@/store/boot'
       import { notify, notifyError } from '@/store/notifications'
       import { $desktopOnboarding } from '@/store/onboarding'
       
       import type { RemoteReauth } from './boot-failure-reauth'
      -import { deriveProviderShape, isRemoteReauthFailure, signInLabel } from './boot-failure-reauth'
      +import { deriveProviderShape, isRemoteConfig, isRemoteReauthFailure, signInLabel } from './boot-failure-reauth'
      +
      +// The recovery "Gateway settings" view embeds the real Settings → Gateway panel
      +// (identical URL/auth/test/save controls — no parallel form to drift). Lazy so
      +// it stays out of the always-mounted overlay's bundle until opened.
      +const GatewaySettings = lazy(() =>
      +  import('@/app/settings/gateway-settings').then(module => ({ default: module.GatewaySettings }))
      +)
       
       type BusyAction = 'local' | 'repair' | 'retry' | 'signin' | null
      +type RecoveryView = 'connect' | 'recovery'
       
       // A remote gateway whose access cookie has lapsed (e.g. the dashboard
       // restarted on the remote box) boots into this overlay with a reauth-shaped
      @@ -35,6 +44,13 @@ export function BootFailureOverlay() {
         const [logs, setLogs] = useState<string[]>([])
         const [showLogs, setShowLogs] = useState(false)
         const [remoteReauth, setRemoteReauth] = useState<RemoteReauth | null>(null)
      +  // A remote/cloud backend that failed to boot is fixable from gateway settings,
      +  // so the escape hatch earns emphasis (local failures keep it as a quiet ghost).
      +  const [remoteFailure, setRemoteFailure] = useState(false)
      +  // Swap the card body to the embedded Gateway settings panel in place of routing
      +  // 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')
       
         const visible = Boolean(boot.error) && !boot.running
         // While first-run onboarding owns the picker/flow we let it surface its own
      @@ -51,7 +67,7 @@ export function BootFailureOverlay() {
             ?.getRecentLogs()
             .then(res => setLogs(res.lines ?? []))
             .catch(() => undefined)
      -  }, [visible])
      +  }, [boot.error, visible])
       
         // Resolve whether this boot failure is a remote-gateway reauth so we can
         // offer the actionable "Sign in" path instead of the local-only recovery
      @@ -59,6 +75,8 @@ export function BootFailureOverlay() {
         useEffect(() => {
           if (!visible) {
             setRemoteReauth(null)
      +      setRemoteFailure(false)
      +      setView('recovery')
       
             return
           }
      @@ -80,7 +98,13 @@ export function BootFailureOverlay() {
               return
             }
       
      -      if (cancelled || !isRemoteReauthFailure(config)) {
      +      if (cancelled) {
      +        return
      +      }
      +
      +      setRemoteFailure(isRemoteConfig(config))
      +
      +      if (!isRemoteReauthFailure(config, boot.error)) {
               return
             }
       
      @@ -104,7 +128,7 @@ export function BootFailureOverlay() {
           return () => {
             cancelled = true
           }
      -  }, [visible])
      +  }, [boot.error, visible])
       
         if (!visible || suppressed) {
           return null
      @@ -129,11 +153,12 @@ export function BootFailureOverlay() {
           setBusy(null)
         }
       
      -  // Open the gateway's login window (renders the username/password form for a
      -  // basic gateway, or the OAuth redirect otherwise — the desktop drives both
      -  // through the same window). On a successful sign-in the session cookie is
      -  // re-established in the persistent partition; reload so boot re-runs and the
      -  // reconnect now mints a ticket against a live session.
      +  // Clear the OAuth partition first, then open the gateway's login window
      +  // (username/password form or OAuth redirect — the desktop drives both). A
      +  // partition-wide sign-out drops stale gateway AND identity-provider cookies so
      +  // an expired session can't silently bounce us back into the same state. On a
      +  // successful sign-in the cookie is re-established; reload so boot mints a fresh
      +  // ticket against a live session.
         const signInRemote = async () => {
           if (!remoteReauth) {
             return
      @@ -142,6 +167,7 @@ export function BootFailureOverlay() {
           setBusy('signin')
       
           try {
      +      await window.hermesDesktop?.oauthLogoutConnectionConfig?.()
             const result = await window.hermesDesktop?.oauthLoginConnectionConfig(remoteReauth.url)
       
             if (result?.connected) {
      @@ -172,6 +198,92 @@ export function BootFailureOverlay() {
           withProvider: copy.signInWithProvider
         })
       
      +  // Recovery actions are shaped by the failure kind so the leading (primary)
      +  // button is the one that actually fixes it: Sign in for a lapsed remote
      +  // session, Connection settings for any other remote failure (local Retry /
      +  // Repair can't revive a dead remote — Repair is dropped there), Retry for a
      +  // local backend. Open logs is always appended.
      +  type RecoveryVariant = ComponentProps<typeof Button>['variant']
      +  interface RecoveryAction {
      +    key: string
      +    label: string
      +    onClick: () => void
      +    icon?: ReactNode
      +    variant?: RecoveryVariant
      +    busy?: Exclude<BusyAction, null>
      +  }
      +
      +  const settingsAction: RecoveryAction = {
      +    key: 'settings',
      +    label: copy.gatewaySettings,
      +    onClick: () => setView('connect'),
      +    icon: <SlidersHorizontal />
      +  }
      +
      +  const retryAction: RecoveryAction = {
      +    key: 'retry',
      +    label: copy.retry,
      +    onClick: () => void retry(),
      +    icon: <RefreshCw />,
      +    busy: 'retry'
      +  }
      +
      +  const localAction: RecoveryAction = {
      +    key: 'local',
      +    label: copy.useLocalGateway,
      +    onClick: () => void switchToLocalGateway(),
      +    variant: 'secondary',
      +    busy: 'local'
      +  }
      +
      +  let actions: RecoveryAction[]
      +  let hint: string
      +
      +  if (remoteReauth) {
      +    actions = [
      +      { key: 'signin', label: copy.signOutAndSignIn, onClick: () => void signInRemote(), icon: <LogIn />, busy: 'signin' },
      +      { ...settingsAction, variant: 'secondary' },
      +      localAction
      +    ]
      +    hint = copy.remoteSignInHint(label)
      +  } else if (remoteFailure) {
      +    actions = [settingsAction, { ...retryAction, variant: 'secondary' }, localAction]
      +    hint = copy.remoteFailureHint
      +  } else {
      +    // Local failure: Use-local is redundant with Retry (both re-target local), so
      +    // it's dropped here; keep it for remote failures where it's the fall-back.
      +    actions = [
      +      retryAction,
      +      { key: 'repair', label: copy.repairInstall, onClick: () => void repair(), icon: <Wrench />, variant: 'secondary', busy: 'repair' },
      +      { ...settingsAction, variant: 'ghost' }
      +    ]
      +    hint = copy.repairHint
      +  }
      +
      +  if (view === 'connect') {
      +    return (
      +      <div className="fixed inset-0 z-[1400] flex items-center justify-center bg-(--ui-chat-surface-background) p-6">
      +        <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">
      +          {/* Subtle back affordance (projects/overlay idiom): muted → foreground
      +              on hover, no divider. */}
      +          <button
      +            className="flex w-full items-center gap-1.5 px-4 pt-4 text-left text-xs text-muted-foreground transition-colors hover:text-foreground"
      +            onClick={() => setView('recovery')}
      +            type="button"
      +          >
      +            <ChevronLeft className="size-3.5" />
      +            {copy.back}
      +          </button>
      +          <div className="min-h-0 flex-1 pt-4">
      +            <Suspense fallback={<Loader className="mx-auto my-16 size-6 text-(--ui-text-tertiary)" />}>
      +              <GatewaySettings embedded />
      +            </Suspense>
      +          </div>
      +        </div>
      +      </div>
      +    )
      +  }
      +
         return (
           <div className="fixed inset-0 z-[1400] flex items-center justify-center bg-(--ui-chat-surface-background) p-6">
             <div className="w-full max-w-[40rem] overflow-hidden rounded-xl border border-(--stroke-nous) bg-(--ui-chat-bubble-background) shadow-nous">
      @@ -187,40 +299,25 @@ export function BootFailureOverlay() {
                 </div>
               </div>
       
      -        <div className="grid gap-4 p-5">
      +        <div className="grid gap-4 p-5 pt-0">
                 <div className="rounded-2xl border border-destructive/30 bg-destructive/10 px-4 py-3 text-xs text-destructive">
                   {boot.error}
                 </div>
       
                 <div className="grid gap-2">
                   <div className="flex flex-wrap gap-2">
      -              {remoteReauth ? (
      -                <Button disabled={Boolean(busy)} onClick={() => void signInRemote()}>
      -                  {busy === 'signin' ? <Loader2 className="animate-spin" /> : <LogIn />}
      -                  {label}
      -                </Button>
      -              ) : (
      -                <Button disabled={Boolean(busy)} onClick={() => void retry()}>
      -                  {busy === 'retry' ? <Loader2 className="animate-spin" /> : <RefreshCw />}
      -                  {copy.retry}
      +              {actions.map(action => (
      +                <Button disabled={Boolean(busy)} key={action.key} onClick={action.onClick} variant={action.variant}>
      +                  {action.busy && busy === action.busy ? <Loader2 className="animate-spin" /> : action.icon}
      +                  {action.label}
                       </Button>
      -              )}
      -              {!remoteReauth ? (
      -                <Button disabled={Boolean(busy)} onClick={() => void repair()} variant="secondary">
      -                  {busy === 'repair' ? <Loader2 className="animate-spin" /> : <Wrench />}
      -                  {copy.repairInstall}
      -                </Button>
      -              ) : null}
      -              <Button disabled={Boolean(busy)} onClick={() => void switchToLocalGateway()} variant="secondary">
      -                {busy === 'local' ? <Loader2 className="animate-spin" /> : null}
      -                {copy.useLocalGateway}
      -              </Button>
      +              ))}
                     <Button onClick={openLogs} variant="ghost">
                       <FileText />
                       {copy.openLogs}
                     </Button>
                   </div>
      -            <p className="text-xs text-muted-foreground">{remoteReauth ? copy.remoteSignInHint : copy.repairHint}</p>
      +            <p className="text-xs text-muted-foreground">{hint}</p>
                 </div>
       
                 {logs.length > 0 ? (
    • apps/desktop/src/components/boot-failure-reauth.test.ts
      @@ -2,7 +2,13 @@ import { describe, expect, it } from 'vitest'
       
       import type { DesktopConnectionConfig } from '@/global'
       
      -import { deriveProviderShape, isRemoteReauthFailure, signInLabel } from './boot-failure-reauth'
      +import {
      +  deriveProviderShape,
      +  isRemoteConfig,
      +  isRemoteReauthError,
      +  isRemoteReauthFailure,
      +  signInLabel
      +} from './boot-failure-reauth'
       
       function config(overrides: Partial<DesktopConnectionConfig> = {}): DesktopConnectionConfig {
         return {
      @@ -19,13 +25,32 @@ function config(overrides: Partial<DesktopConnectionConfig> = {}): DesktopConnec
         }
       }
       
      +describe('isRemoteConfig', () => {
      +  it('true for remote/cloud with a URL, regardless of auth mode or connection', () => {
      +    expect(isRemoteConfig(config({ remoteAuthMode: 'token', remoteOauthConnected: false }))).toBe(true)
      +    expect(isRemoteConfig(config({ mode: 'cloud', remoteOauthConnected: true }))).toBe(true)
      +  })
      +
      +  it('false for local, for a remote with no URL, and for nullish', () => {
      +    expect(isRemoteConfig(config({ mode: 'local' }))).toBe(false)
      +    expect(isRemoteConfig(config({ remoteUrl: '' }))).toBe(false)
      +    expect(isRemoteConfig(null)).toBe(false)
      +  })
      +})
      +
       describe('isRemoteReauthFailure', () => {
         it('true for a remote, gated, disconnected gateway with a URL', () => {
           expect(isRemoteReauthFailure(config())).toBe(true)
         })
       
      -  it('false when the oauth session is still connected', () => {
      -    expect(isRemoteReauthFailure(config({ remoteOauthConnected: true }))).toBe(false)
      +  it('false when connected and the boot error is not auth-shaped', () => {
      +    expect(isRemoteReauthFailure(config({ remoteOauthConnected: true }), 'Python exploded')).toBe(false)
      +  })
      +
      +  it('true when the indicator reads connected but the boot error is auth-shaped (expired session)', () => {
      +    expect(
      +      isRemoteReauthFailure(config({ remoteOauthConnected: true }), 'Your remote gateway session has expired.')
      +    ).toBe(true)
         })
       
         it('false for a local gateway', () => {
      @@ -56,6 +81,18 @@ describe('isRemoteReauthFailure', () => {
         })
       })
       
      +describe('isRemoteReauthError', () => {
      +  it('recognizes auth-shaped boot errors', () => {
      +    expect(isRemoteReauthError('Your remote gateway session has expired.')).toBe(true)
      +    expect(isRemoteReauthError('OAuth: please sign in')).toBe(true)
      +  })
      +
      +  it('ignores non-auth boot errors and nullish', () => {
      +    expect(isRemoteReauthError('Hermes background process exited during startup.')).toBe(false)
      +    expect(isRemoteReauthError(null)).toBe(false)
      +  })
      +})
      +
       describe('deriveProviderShape', () => {
         it('generic copy when there are no providers', () => {
           expect(deriveProviderShape([])).toEqual({ isPassword: [redacted:secret-assignment] providerLabel: 'your identity provider' })
    • apps/desktop/src/components/boot-failure-reauth.ts
      @@ -26,24 +26,40 @@ const DEFAULT_SIGN_IN_COPY: SignInCopy = {
         withProvider: provider => `Sign in with ${provider}`
       }
       
      -// A remote, gated (oauth-bucket), not-currently-connected gateway is a
      -// remote-reauth boot failure: the access cookie lapsed (e.g. the remote
      -// dashboard restarted) and the local-recovery buttons (Retry/Repair) can't
      -// fix it — only re-establishing the remote session can. A connected oauth
      -// session, or a token/local gateway, boots for some other reason the
      -// local-recovery buttons address, so those return false here. 'cloud' counts
      -// as remote here — it resolves to a remote oauth backend (cloud-auto-discovery
      -// Q6), so a lapsed cloud session is the same reauth failure.
      -export function isRemoteReauthFailure(config: DesktopConnectionConfig | null | undefined): boolean {
      -  if (!config) {
      -    return false
      -  }
      +// True when the app is pointed at a remote/cloud backend (either resolves to a
      +// remote URL). Any boot failure in this shape is fixable from Settings →
      +// Gateway (edit URL / token / sign in) — the local Retry/Repair buttons target
      +// the bundled backend and can't help. Drives the escape-hatch emphasis.
      +export function isRemoteConfig(config: DesktopConnectionConfig | null | undefined): boolean {
      +  return Boolean(config && (config.mode === 'remote' || config.mode === 'cloud') && config.remoteUrl)
      +}
      +
      +// True when a boot error is auth-shaped — the refresh token was rejected or the
      +// remote couldn't mint a websocket ticket. The Settings indicator can still read
      +// "connected" (a stale RT cookie exists), so the error text is part of the
      +// signal; without it a connected-but-expired session drops into the local-only
      +// recovery buttons for a problem only reauth can fix.
      +export function isRemoteReauthError(error: string | null | undefined): boolean {
      +  const text = String(error || '').toLowerCase()
      +
      +  return (
      +    text.includes('remote gateway session has expired') ||
      +    text.includes('gateway sign-in required') ||
      +    text.includes('needs oauth login') ||
      +    (text.includes('oauth') && (text.includes('not signed in') || text.includes('sign in')))
      +  )
      +}
       
      +// A remote, gated (oauth-bucket) gateway is a remote-reauth boot failure when the
      +// session isn't connected OR the boot error is auth-shaped (connected-but-expired
      +// — see isRemoteReauthError). Only re-establishing the remote session fixes it;
      +// the local Retry/Repair buttons can't. 'cloud' counts as remote (it resolves to
      +// a remote oauth backend), so a lapsed cloud session is the same failure.
      +export function isRemoteReauthFailure(config: DesktopConnectionConfig | null | undefined, error?: string | null): boolean {
         return (
      -    (config.mode === 'remote' || config.mode === 'cloud') &&
      -    config.remoteAuthMode === 'oauth' &&
      -    !config.remoteOauthConnected &&
      -    Boolean(config.remoteUrl)
      +    isRemoteConfig(config) &&
      +    config!.remoteAuthMode === 'oauth' &&
      +    (!config!.remoteOauthConnected || isRemoteReauthError(error))
         )
       }
       
    • apps/desktop/src/components/gateway-connecting-overlay.test.tsx
      @@ -177,7 +177,7 @@ describe('connecting overlay vs recovery surface', () => {
       
           // Escape hatch is now reachable; the connecting overlay bows out.
           expect(isRecoveryShown()).toBe(true)
      -    expect(screen.getByText(/use local gateway/i)).toBeTruthy()
      +    expect(screen.getByRole('button', { name: /gateway settings/i })).toBeTruthy()
           expect(isConnectingShown()).toBe(false)
         })
       })
    • apps/desktop/src/i18n/en.ts
      @@ -90,9 +90,14 @@ export const en: Translations = {
             retry: 'Retry',
             repairInstall: 'Repair install',
             useLocalGateway: 'Use local gateway',
      +      gatewaySettings: 'Gateway settings',
      +      back: 'Back',
             openLogs: 'Open logs',
             repairHint: 'Repair re-runs the installer and can take a few minutes on a fresh machine.',
      -      remoteSignInHint: 'Opens the gateway login window. Use local gateway to switch to the bundled backend instead.',
      +      remoteSignInHint: signInLabel =>
      +        `Signs out of the saved remote browser session, then opens ${signInLabel}. Use local gateway to switch to the bundled backend instead.`,
      +      signOutAndSignIn: 'Sign out & sign in',
      +      remoteFailureHint: 'Check the gateway URL and sign-in under Gateway settings, or switch to the local gateway.',
             hideRecentLogs: 'Hide recent logs',
             showRecentLogs: 'Show recent logs',
             signedInTitle: 'Signed in',
    • apps/desktop/src/i18n/ja.ts
      @@ -90,10 +90,14 @@ export const ja = defineLocale({
             retry: '再試行',
             repairInstall: 'インストールを修復',
             useLocalGateway: 'ローカルゲートウェイを使用',
      +      gatewaySettings: 'ゲートウェイ設定',
      +      back: '戻る',
             openLogs: 'ログを開く',
             repairHint: '修復はインストーラーを再実行します。新しいマシンでは数分かかる場合があります。',
      -      remoteSignInHint:
      -        'ゲートウェイのログインウィンドウを開きます。代わりにバンドルされたバックエンドに切り替えるには「ローカルゲートウェイを使用」を選択してください。',
      +      remoteSignInHint: signInLabel =>
      +        `保存済みのリモートブラウザセッションからサインアウトし、${signInLabel}を開きます。代わりにバンドルされたバックエンドに切り替えるには「ローカルゲートウェイを使用」を選択してください。`,
      +      signOutAndSignIn: 'サインアウトして再サインイン',
      +      remoteFailureHint: '「ゲートウェイ設定」でゲートウェイの URL とサインインを確認するか、ローカルゲートウェイに切り替えてください。',
             hideRecentLogs: '最近のログを非表示',
             showRecentLogs: '最近のログを表示',
             signedInTitle: 'サインインしました',
    • apps/desktop/src/i18n/types.ts
      @@ -133,9 +133,13 @@ export interface Translations {
             retry: string
             repairInstall: string
             useLocalGateway: string
      +      gatewaySettings: string
      +      back: string
             openLogs: string
             repairHint: string
      -      remoteSignInHint: string
      +      remoteSignInHint: (signInLabel: string) => string
      +      signOutAndSignIn: string
      +      remoteFailureHint: string
             hideRecentLogs: string
             showRecentLogs: string
             signedInTitle: string
    • apps/desktop/src/i18n/zh-hant.ts
      @@ -88,9 +88,14 @@ export const zhHant = defineLocale({
             retry: '重試',
             repairInstall: '修復安裝',
             useLocalGateway: '使用本機閘道',
      +      gatewaySettings: '閘道設定',
      +      back: '返回',
             openLogs: '開啟記錄',
             repairHint: '修復會重新執行安裝程式,在新機器上可能需要幾分鐘。',
      -      remoteSignInHint: '開啟閘道登入視窗。使用本機閘道可切換至內建後端。',
      +      remoteSignInHint: signInLabel =>
      +        `先登出已儲存的遠端瀏覽器工作階段,然後開啟${signInLabel}。使用本機閘道可切換至內建後端。`,
      +      signOutAndSignIn: '登出並重新登入',
      +      remoteFailureHint: '在「閘道設定」中檢查閘道 URL 與登入,或切換至本機閘道。',
             hideRecentLogs: '隱藏最近記錄',
             showRecentLogs: '顯示最近記錄',
             signedInTitle: '已登入',
    • apps/desktop/src/i18n/zh.ts
      @@ -88,9 +88,14 @@ export const zh: Translations = {
             retry: '重试',
             repairInstall: '修复安装',
             useLocalGateway: '使用本地网关',
      +      gatewaySettings: '网关设置',
      +      back: '返回',
             openLogs: '打开日志',
             repairHint: '修复会重新运行安装器,在新机器上可能需要几分钟。',
      -      remoteSignInHint: '打开网关登录窗口。也可以使用本地网关切换到随应用提供的后端。',
      +      remoteSignInHint: signInLabel =>
      +        `先退出已保存的远程浏览器会话,然后打开${signInLabel}。也可以使用本地网关切换到随应用提供的后端。`,
      +      signOutAndSignIn: '退出并重新登录',
      +      remoteFailureHint: '在“网关设置”中检查网关 URL 和登录,或切换到本地网关。',
             hideRecentLogs: '隐藏最近日志',
             showRecentLogs: '显示最近日志',
             signedInTitle: '已登录',
  • #122035 fix(desktop): let the boot-failure overlay be dismissed

    merged to main

    files: apps/desktop/src/components/boot-failure-overlay.test.tsx, apps/desktop/src/components/boot-failure-overlay.tsx

1 PR closed without merging

From the close comments: superseded by another PR 1

  • #62559 fix(desktop): make boot failure overlay dismissible · superseded by another PR → #122035
Closed issues · 2
  • Closed#54545[Bug]: Settings unreachable during startup error — no Settings item in the macOS app menu (⌘,), and the recovery screen blocks the only in-app path
  • Closed#62533[Bug]: Desktop App: Hermes couldnt start notification blocks the UI
How this cluster was grouped
  • Link evidence (partly structural links): some members are connected by structural evidence (shared changed files, or a closing/superseded reference); the rest are joined by an automated relevance judgement over their text.
  • Members outside that structural core may belong to a different underlying problem.
  • Pairwise link density 0.34: the share of item pairs that carry a link, counting a structural link in full and a relevance-only link at 0.36. 1.00 means every pair is linked.

Pick up a Lab task

Check whether a fix covers the open issues here, with your own agent. Bring back evidence for maintainers.

Open the Lab

At a glance

Subsystem
apps/desktop
Merged PRs
#63030, #122035
Closed issues
2
Attempts
1 PR closed without merging
Fix link checked
25 Sep 2026

For your agent

The same facts, typed.