Omniscio documentation
Browse all documentation
  1. Getting Started13
  2. Sessions & Agents115
  3. Inbox & Notifications59
  4. Projects & Tasks95
  5. Automation & Scheduling75
  6. Knowledge & Memory26
  7. AI Features60
  8. Integrations100
  9. Plugins & Marketplace33
  10. Cloud & Teams56
  11. Settings & Customization58
  12. Account & Billing28
  13. Troubleshooting84
  14. CLI & API Reference22
  15. Legal & Policies4
  16. Uncategorised22

Blank-screen recovery button (survives a full React unmount)

The Reload Omniscio button that appears in the middle of the screen when the app's interface has vanished, so you can recover without closing the window and losing the forensic trail. Covers when it appears, what it does when you click it, why it survives a React unmount, and how to switch it off.

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 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.

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 — single ~245-line IIFE; no imports, no build step, no bundler involvement.
  • HTML wiring: 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.
  • IPC channel: app:recover-blank-screen (constant IPC.APP_RECOVER_BLANK_SCREEN in src/shared/ipc-channels/system.ts). Zod schema recoverBlankScreenSchema in src/shared/ipc-schemas.ts.
  • Main handler: in 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 — 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 (13 jsdom tests, install + visibility + click + fallback + chunk-error grace) and 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 explains the heartbeat log and the marker vocabulary the button writes into it; crash-recovery.md covers how sessions themselves come back after a crash or a restart; and app-already-running-or-frozen.md is the neighbouring problem of a copy that is running but has stopped responding.

Last verified 2026-09-28