diff --git a/web/standalone/src/components/WasmTool.tsx b/web/standalone/src/components/WasmTool.tsx
index 95db000..4eb4cf9 100644
--- a/web/standalone/src/components/WasmTool.tsx
+++ b/web/standalone/src/components/WasmTool.tsx
@@ -13,7 +13,7 @@ import { memfsProjectDir } from "@/wasm/constants";
import { driveProjectIntoTool, type ToolFile } from "@/wasm/kicad-runner";
import type { CollabWindow } from "@/wasm/collab";
import { clog, cwarn } from "@/wasm/collab/debug";
-import { createOomWatch } from "@/recovery/oom-watch";
+import { createOomWatch, respawnInNewTab } from "@/recovery/oom-watch";
import { MemoryExhaustedDialog } from "@/recovery/MemoryExhaustedDialog";
// Tools with a working collab bridge (kicadCollabSnapshot/Apply embind exports).
@@ -349,7 +349,10 @@ export function WasmTool({
{oomExhausted && (
- window.location.reload()} />
+ respawnInNewTab()}
+ onReload={() => window.location.reload()}
+ />
)}
{status && (
diff --git a/web/standalone/src/recovery/MemoryExhaustedDialog.tsx b/web/standalone/src/recovery/MemoryExhaustedDialog.tsx
index 5fcab23..954d30b 100644
--- a/web/standalone/src/recovery/MemoryExhaustedDialog.tsx
+++ b/web/standalone/src/recovery/MemoryExhaustedDialog.tsx
@@ -4,20 +4,38 @@ import { BlockingDialog } from "@/preflight/BlockingDialog";
* Terminal "not enough memory" UI for feature 0002 — shown after the OOM retry
* chain hits MAX_RETRIES. Reuses 0001's blocking dialog for visual consistency.
*
- * The copy is honest about the two limitations the spec calls out: a respawn
- * re-opens the same file but loses in-tab edits not yet pushed to the backend /
- * Y.Doc, and `window.close()` may not close the very first (user-opened) tab.
+ * Offers two manual actions:
+ * - "Open in a new tab" (primary): a user-gesture respawn in a fresh browsing
+ * context. This sometimes succeeds where the automatic in-place reload did
+ * not, because a brand-new tab is the most reliable way to actually release
+ * the OOM'd wasm heap (an in-place reload doesn't, reliably, in every
+ * browser — notably Firefox).
+ * - "Reload this tab" (secondary): the simple in-place retry.
+ *
+ * Copy is honest about lost unsaved edits (a respawn re-opens the same file but
+ * loses in-tab edits not yet synced to the backend / Y.Doc).
*/
-export function MemoryExhaustedDialog({ onReload }: { onReload?: () => void }) {
+export function MemoryExhaustedDialog({
+ onOpenNewTab,
+ onReload,
+}: {
+ onOpenNewTab?: () => void;
+ onReload?: () => void;
+}) {
return (
void }) {
},
]}
primary={
- onReload
- ? { label: "Reload and try again", onClick: onReload }
+ onOpenNewTab
+ ? { label: "Open in a new tab", onClick: onOpenNewTab }
: undefined
}
+ secondary={
+ onReload ? { label: "Reload this tab", onClick: onReload } : undefined
+ }
/>
);
}
diff --git a/web/standalone/src/recovery/oom-watch.ts b/web/standalone/src/recovery/oom-watch.ts
index 0a17195..1316a60 100644
--- a/web/standalone/src/recovery/oom-watch.ts
+++ b/web/standalone/src/recovery/oom-watch.ts
@@ -308,6 +308,33 @@ export function createOomWatch(opts: OomWatchOptions): OomWatch {
return { start, stop, onAbort };
}
+/**
+ * User-gesture escape hatch for the terminal dialog: open the editor in a brand
+ * new tab with a clean retry chain, then close this one.
+ *
+ * This is deliberately NOT the automatic recovery path: it runs from a real
+ * click, so `window.open` is not popup-blocked, and a fresh top-level browsing
+ * context is the most reliable way to actually drop the OOM'd wasm heap — an
+ * in-place `location.replace` reload does not reliably release it in every
+ * browser (notably Firefox). `window.close()` may still be refused on the
+ * original, user-opened tab; that's fine — the new tab is the working one and
+ * the user can close the old one. Returns whether a new tab was opened.
+ */
+export function respawnInNewTab(win: Window = window): boolean {
+ const url = new URL(win.location.href);
+ url.searchParams.delete(RETRY_PARAM); // fresh chain — this is a manual retry
+ const opened = win.open(url.toString(), "_blank");
+ if (opened) {
+ try {
+ win.close();
+ } catch {
+ /* original tab may refuse to close — new tab is the survivor anyway */
+ }
+ return true;
+ }
+ return false;
+}
+
function safeLocalStorage(win: Window): Storage | null {
try {
return win.localStorage;