---
title: Blank-screen recovery button (survives a full React unmount)
---

# Blank-screen recovery button (survives a full React unmount)

## What it is

A visible **"Reload Omniscio"** button that appears in the centre of your screen when the Omniscio chat UI has vanished. It exists for the case where the React app silently unmounts and `#root` goes empty — the window stays open, the Minimise / Maximise / X controls still draw because Windows owns them natively, but the entire app surface (sidebar, chat, toolbar) is gone. Without this button your only recovery is closing the window and relaunching, which loses the forensic trail of what just happened.

The button is **not a hotkey** — that was a deliberate choice after a prior round where hotkey-based recovery couldn't reach the user. It's a literal DOM element you can click with the mouse. Same idea as the OS-drawn titlebar: a small chrome surface that survives whatever broke the rest of the window.

### What you see

When the React app blanks for at least **1.5 seconds**, a small dark card slides into view, centred on the screen:

```
┌─────────────────────────────────────┐
│  The window went blank. Click to    │
│  reload.                            │
│                                     │
│        ┌─────────────────┐          │
│        │   Reload Omniscio    │          │
│        └─────────────────┘          │
└─────────────────────────────────────┘
```

The card sits on top of everything else (z-index `2147483647` — the max signed-32-bit integer, used so no app stylesheet can accidentally cover it). The button is a rounded indigo pill, 44 px tall (the standard tap-target minimum), with a hover shade that darkens it slightly. Clicking it disables the button, changes its label to **"Reloading…"**, and kicks off the recovery flow.

If React recovers on its own (the unmount turned out to be transient — e.g. a hot-reload re-mount succeeded), the card disappears the moment `#root` regains a child element. The card never stays up after the app has healed itself.

## Where to find it

Nothing to open. The recovery card is not in any menu and has no Settings row of its own — it
appears by itself, centred on the screen, once the app surface has been blank long enough. There is
nothing to enable first. Switching it off is a per-window developer action rather than a setting;
see **Kill switches** below.

## How it behaves

### When it appears

Two trigger conditions, both monitored every second by a `MutationObserver` + interval backstop:

1. **`#root` is empty.** No child elements at all. This is what a `React.lazy()` chunk failure looks like after the suspense boundary gives up — the whole tree is detached. Grace period before the card appears: **1500 ms**, so a normal cold mount (where React paints in a few hundred ms) never trips it.
2. **A chunk-load error fired AND the inline `#app-loader` is still visible inside `#root`.** This is the "stuck on the loading dots forever" failure: a Vite content-hashed chunk 404'd, React never mounted past the inline pre-paint loader, and you'd otherwise sit there indefinitely. Grace period in this mode: **250 ms** — once we've seen the chunk error, there's no point waiting.

Chunk-load errors are recognised from two sources: the capture-phase `error` listener (catches `<script>`-element load failures) and the `unhandledrejection` listener (catches dynamic-import promise rejections — `ChunkLoadError`, "Loading chunk N failed", "Failed to fetch dynamically imported module", "Importing a module script failed").

### What it does on click

When you press the button, the in-renderer IIFE invokes the `app:recover-blank-screen` IPC with a small structured payload (`trigger`, `blankMs`, `hadChunkError`, `rootChildCount`). The main process handler then:

1. **Records `[MANUAL-RECOVERY]` into `heartbeat.log`** with the current `lastOp` slot and the payload fields. This is the forensic marker that pairs with the existing `[RENDER-GONE]` / `[STALL-CAUGHT]` / `[POST-MORTEM]` family — see [main-heartbeat.md](main-heartbeat.md) for the full vocabulary.
2. **Dumps the heartbeat ring buffer to `heartbeat-final.log`** with the reason `manual-recovery-blank-screen`. That snapshots the last 10 ticks (≈ 50 seconds of pre-blank main-process state — lag P50/P99, RSS, IPC names) for the next-launch investigator.
3. **Clears the WebContents HTTP cache** so a stale Vite chunk that caused the blank can't be served again from cache.
4. **Reloads the WebContents** with `reloadIgnoringCache()`. The renderer process tears down and restarts; the guardian IIFE re-runs against the fresh page and the card disappears once React mounts.

If for any reason the IPC channel itself fails (the preload bridge missing, the main process unresponsive, a desktop-vs-mobile build mismatch), the guardian falls back to `window.location.reload()`. The user still gets a reload; the forensic trail is the only thing that's lost on that path.

### Kill switches

- **Per-window opt-out**: set `window.__amcDisableBlankScreenGuardian = true` in DevTools **before** the IIFE runs, or remove the `<script>` tag from `index.html`. The IIFE early-returns if the flag is set at install time. There is no runtime "turn it off after it's installed" — the install path is the gate.
- **No config flag.** The guardian is unconditional in production builds. The cost is negligible (a `MutationObserver` on one element + one 1 s `setInterval` + two passive event listeners), and the failure mode it protects against — invisible app, no recovery affordance — is severe enough that opt-out from the user's perspective is not appropriate.

### What it does NOT do

- It does **not** restart the main process. A `WebContents.reloadIgnoringCache()` only reloads the renderer. If the underlying problem is a main-process hang (a `[STALL-CAUGHT]` in `heartbeat.log` with `inFlightCount > 0`), the reload may complete but the same hang can recur. The fix in that case is to close the Omniscio window entirely and relaunch — at which point the next-launch `[POST-MORTEM]` analyser surfaces the prior `[MANUAL-RECOVERY]` line so you know what triggered the close.
- It does **not** preserve unsent draft messages, scroll position, or any volatile renderer state. The reload is a hard reset of the renderer; everything that wasn't already in SQLite (drafts auto-save every few seconds, so most of the time you lose at most a few words) is gone.
- It does **not** appear on a slow cold-mount where React paints in 1500 ms+ but is still working. The 1500 ms grace is calibrated so a normal launch — even one slowed by parallel-worktree contention — never trips it. The chunk-error grace (250 ms) only fires AFTER a chunk-load error was actually observed.

### Related layer: the in-React stale-chunk card

This DOM guardian is the LAST line of defence — it only fires once the whole React tree has detached (`#root` empty) or React never mounted past the inline loader. A _subtree_ chunk failure — e.g. the inbox approval pane (`React.lazy`) failing to load after a `npm run dev` rebuild while the rest of the app is fine — is caught one layer earlier by the app's React `ErrorBoundary`: when the thrown error matches `isStaleChunkError`, the boundary renders a friendly **"A new version loaded — Reload"** card in place of the failed subtree, and the rest of the app stays interactive. So the two recovery surfaces layer cleanly:

- **Subtree fails, app still mounted** → the in-React ErrorBoundary card (no full reload needed unless you click Reload).
- **Whole app blanks / never mounts** → this DOM guardian's "Reload Omniscio" card.

The boundary card's Reload first calls `resetReloadGuardForManualReload()` so a user-initiated reload isn't suppressed by the automatic crash-loop cap (the same `preload-reload-guard` caps the guardian respects). Critically, `retry-import` itself no longer does its own once-per-session `location.reload()` — that redundant third reload used to stick (`suppressed-by-session-flag`) and leave later staleness with NO recovery (the "regularly black/blank" reports on the desktop dev app + mobile). Recovery is now owned by `preload-reload-guard` (reload + cap + intent-restore) and these two cards. See [inbox-approval-load-resilience-contract.md](../../.claude/memory/contracts/inbox-approval-load-resilience-contract.md).

### Reading order after a blank-screen incident

1. **Open `heartbeat.log`** (Settings → Diagnostics → Main-Process Heartbeat → Reveal). Search for the most recent `[MANUAL-RECOVERY]` line. That marks the click that triggered recovery, including the `blankMs` (how long the screen was blank before you clicked) and `hadChunkError` (whether a chunk-load failure preceded the blank).
2. **Open `heartbeat-final.log`** next to it. If the ring dump reason is `manual-recovery-blank-screen`, the file holds the last ≈ 50 seconds of main-process ticks leading up to the blank — useful for spotting a `[STALL]` or rising `lastOp` repeats just before the click.
3. **Open `startup.log`** for the launch that came AFTER the click. If the chunk-error grace fired (`hadChunkError=true` in the marker line), the next launch's startup trace is where you'll see whether the rebuild fixed it or whether the same chunk is still 404'ing.
4. If the renderer reload didn't help and you closed the window manually, the **next** launch's `[POST-MORTEM]` block will reference the manual-recovery ring dump.

The whole point of the marker is that an LLM (Claude Code, or an outside AI you paste the logs into) can correlate a user report — _"the window went blank and I clicked the button"_ — with a specific tick window and a specific `lastOp`, without having to guess at timestamps.

## For agents

### Why it survives a React unmount

Three architectural choices, all required:

- **The guardian script lives in `src/renderer/public/blank-screen-guardian.js`**, not in the React bundle. Vite copies the `public/*` directory into the bundle root verbatim, so the file ends up at `/blank-screen-guardian.js` and `index.html` references it as `<script src="/blank-screen-guardian.js"></script>`. Because it's not bundled, a content-hash rebuild **cannot make this file go stale** — a stale chunk is precisely the failure mode that motivated the feature.
- **The `<script>` tag is parser-blocking** and placed AFTER `</div>` (the close of `#root`) but BEFORE the `<script type="module" src="./src/main.tsx">` entry. This guarantees the guardian has installed its observer + listeners before React begins mounting, so any first-paint failure is already covered.
- **The button is appended to `document.body`, not into `#root`.** A subsequent React unmount that wipes `#root` cannot remove the recovery card — it lives in a sibling DOM subtree that React never sees.

The CSP `script-src 'self'` policy forbids inline `<script>` bodies, so the only path for "ship a script that survives the React boundary" is a non-bundled file referenced by URL. The same approach was used for `init-pre-paint.js` (theme pre-paint).

### Lifecycle wiring

- **Script source**: [src/renderer/public/blank-screen-guardian.js](../../src/renderer/public/blank-screen-guardian.js) — single ~245-line IIFE; no imports, no build step, no bundler involvement.
- **HTML wiring**: [src/renderer/index.html](../../src/renderer/index.html) — the `<script>` tag's position relative to `#root` and the React entry is load-bearing and pinned by [tests/unit/lint/blank-screen-guardian-wiring.test.ts](../../tests/unit/lint/blank-screen-guardian-wiring.test.ts).
- **IPC channel**: `app:recover-blank-screen` (constant `IPC.APP_RECOVER_BLANK_SCREEN` in [src/shared/ipc-channels/system.ts](../../src/shared/ipc-channels/system.ts)). Zod schema `recoverBlankScreenSchema` in [src/shared/ipc-schemas.ts](../../src/shared/ipc-schemas.ts).
- **Main handler**: in [src/main/ipc/app-data-handlers.ts](../../src/main/ipc/app-data-handlers.ts) — parses input, calls `recordManualRecovery`, clears the WebContents session cache, then `reloadIgnoringCache()`.
- **Heartbeat marker**: `recordManualRecovery(trigger, details)` in [src/main/services/diagnostics/main-heartbeat.ts](../../src/main/services/diagnostics/main-heartbeat.ts) — appends the `[MANUAL-RECOVERY]` line and dumps the ring buffer to `heartbeat-final.log`.
- **Behaviour tests**: [tests/unit/renderer/blank-screen-guardian-behavior.test.ts](../../tests/unit/renderer/blank-screen-guardian-behavior.test.ts) (13 jsdom tests, install + visibility + click + fallback + chunk-error grace) and [tests/e2e/ui/blank-screen-recovery-button.spec.ts](../../tests/e2e/ui/blank-screen-recovery-button.spec.ts) (2 Playwright tests against a real Electron renderer).

## Related

The recovery card is one of a small family of self-healing surfaces. [main-heartbeat.md](main-heartbeat.md)
explains the heartbeat log and the marker vocabulary the button writes into it; [crash-recovery.md](crash-recovery.md)
covers how sessions themselves come back after a crash or a restart; and
[app-already-running-or-frozen.md](app-already-running-or-frozen.md) is the neighbouring problem of a
copy that is running but has stopped responding.
