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