repl portal: overwrite-guard, rename, export/import, cross-tab restore

Three UX gaps on the portal-bar closed in one pass plus a defensive
fix on the C tier's heap probe:

* Overwrite guard — saving with an existing name asks 'overwrite?'
  with the existing entry's tier + savedAt. Rename via dbl-click on
  the chip label; same overwrite guard applies on rename.

* Export / import — a ⇣ icon on each chip downloads it as
  <name>.portal.json (opaque blob for c/python, replay-inputs for
  asm). A 📁 import button on the portal-bar accepts a .portal.json
  file via hidden <input type="file">; collisions prompt overwrite,
  decline auto-suffixes (baseName-2, -3, …) so importing a 2nd copy
  always lands somewhere.

* Cross-tab restore — a 📂 this tab / 🌐 all tabs toggle switches
  the chip strip between the active tab's checkpoints and every
  tab's. Global chips render as 'name · tabName' with a dashed
  border; click restores the snapshot into the active tab (the
  saved cp is passed through restoreCheckpoint's new sourceCp
  argument so the chip doesn't need a checkpoints[name] match on
  the active tab). Edit/delete are hidden in global mode — the
  user switches to the owning tab to manage chips.

* heapStats defensive — wasm/c/lumbda-c.loader.js now returns null
  when module.HEAPU8 isn't live yet (caught by the heap poll firing
  during the tiny window between tier reboot and Module init), so a
  restore no longer surfaces 'Cannot read properties of undefined
  (reading byteLength)' as a TypeError.

Smoke-tested headlessly: overwrite confirm fires with the expected
message; rename via dblclick swaps the label; ⇣ produces a download
named '<name>.portal.json'; toggle shows both tabs' chips with the
'· tabName' annotation; import round-trips back into the receiving
tab. Zero page errors across the full flow.
This commit is contained in:
russell@unturf.com 2026-06-15 08:14:53 -04:00
parent ac664325e1
commit 2b46c1b79e
No known key found for this signature in database
13 changed files with 590 additions and 58 deletions

View file

@ -99,7 +99,14 @@ async function _bootstrap() {
// free path right now (LUMBDA_NO_BOEHM), so used = total —
// every malloc accumulates until reload. Documented and
// surfaced in the REPL so the user sees the pressure.
const total = module.HEAPU8.byteLength;
//
// HEAPU8 isn't always live: after a worker reboots, the
// heap poll can fire while the new Module is mid-init and
// HEAPU8 isn't yet wired. Return null so the REPL hides
// the pressure indicator for that tick instead of crashing.
const heap = module.HEAPU8;
if (!heap) return null;
const total = heap.byteLength;
return { used: total, total };
},
};

View file

@ -99,7 +99,14 @@ async function _bootstrap() {
// free path right now (LUMBDA_NO_BOEHM), so used = total —
// every malloc accumulates until reload. Documented and
// surfaced in the REPL so the user sees the pressure.
const total = module.HEAPU8.byteLength;
//
// HEAPU8 isn't always live: after a worker reboots, the
// heap poll can fire while the new Module is mid-init and
// HEAPU8 isn't yet wired. Return null so the REPL hides
// the pressure indicator for that tick instead of crashing.
const heap = module.HEAPU8;
if (!heap) return null;
const total = heap.byteLength;
return { used: total, total };
},
};

View file

@ -90,6 +90,9 @@
<section class="portal-bar" id="portal-bar">
<span class="portal-label" title="save the current tab's tier state to your encrypted vault. comes back exactly the same after a tab close + reopen.">portals:</span>
<button id="portal-save" class="ghost" title="save tier state — names the snapshot by date+time">💾 save</button>
<button id="portal-import" class="ghost" title="import a .portal.json file">📁 import</button>
<button id="portal-scope" class="ghost" title="toggle between this tab's checkpoints and every tab's">📂 this tab</button>
<input id="portal-import-file" type="file" accept=".json,.portal,application/json" hidden>
<div id="portal-chips" class="portal-chips"></div>
</section>
<div id="transcript" class="transcript"></div>

View file

@ -190,7 +190,8 @@ body.repl {
* (the asm-wat tier stays grey). */
.portal-bar {
display: grid;
grid-template-columns: auto auto 1fr;
/* label · save · import · scope · chips-row */
grid-template-columns: auto auto auto auto 1fr;
gap: 0.4rem;
align-items: center;
padding: 0.2rem 0.5rem;
@ -211,7 +212,8 @@ body.repl {
}
.portal-chips .chip {
display: inline-grid;
grid-template-columns: auto auto;
/* label · ⇣ download · × delete */
grid-template-columns: auto auto auto;
gap: 0.3rem;
align-items: center;
background: var(--pane-bg);
@ -223,12 +225,20 @@ body.repl {
white-space: nowrap;
}
.portal-chips .chip:hover { border-color: var(--green); }
.portal-chips .chip-global {
/* Cross-tab snapshots get a muted accent so they read as "borrowed
* from another tab" rather than belonging here. */
border-style: dashed;
opacity: 0.85;
}
.portal-chips .chip .chip-export,
.portal-chips .chip .chip-close {
color: var(--muted);
font-size: 0.85em;
cursor: pointer;
padding: 0 0.15rem;
}
.portal-chips .chip .chip-export:hover { color: var(--green); }
.portal-chips .chip .chip-close:hover { color: var(--err); }
/* ─── Transcript ───────────────────────────────────────────────── */

View file

@ -30,7 +30,14 @@ const tierSelectEl = document.getElementById("tier-select");
const sendBtn = document.getElementById("send");
const portalBarEl = document.getElementById("portal-bar");
const portalSaveBtn = document.getElementById("portal-save");
const portalImportBtn = document.getElementById("portal-import");
const portalImportFileEl = document.getElementById("portal-import-file");
const portalScopeBtn = document.getElementById("portal-scope");
const portalChipsEl = document.getElementById("portal-chips");
// Whether the chip strip shows every tab's checkpoints (true) or just
// the active tab's (false). UI-only — never persisted, so a fresh
// session always starts in the focused per-tab view.
let showGlobalCheckpoints = false;
// ─── State ──────────────────────────────────────────────────────────
const state = {
@ -309,6 +316,14 @@ async function saveCheckpoint() {
`${ts.getSeconds().toString().padStart(2, "0")}`;
const name = safePortalName(prompt("checkpoint name:", defaultName) || "");
if (!name) return;
// Overwrite guard — saving with an existing name silently nuked
// the old blob, which has cost more than one "where did my snapshot
// go?" moment. Ask first.
if (tab.checkpoints && tab.checkpoints[name]) {
const old = tab.checkpoints[name];
const when = new Date(old.savedAt).toLocaleString();
if (!confirm(`'${name}' already exists (${old.tier}, saved ${when}). overwrite?`)) return;
}
let entry;
if (tier === "asm") {
@ -340,10 +355,15 @@ async function saveCheckpoint() {
saveSoon();
}
async function restoreCheckpoint(name) {
// Restore a checkpoint into the active tab. The optional sourceCp
// override lets the global-checkpoints view pass in a cp from a
// different tab — the restore still operates on activeTab() so the
// user lands on the tab they were working in, with that tab's tier
// state reset to the snapshot.
async function restoreCheckpoint(name, sourceCp) {
const tab = activeTab();
if (!tab) return;
const cp = tab.checkpoints && tab.checkpoints[name];
const cp = sourceCp || (tab.checkpoints && tab.checkpoints[name]);
if (!cp) return;
if (!confirm(`restore '${name}'? current ${cp.tier} state is replaced.`)) return;
if (tab.tier !== cp.tier) {
@ -383,6 +403,91 @@ async function restoreCheckpoint(name) {
saveSoon();
}
function renameCheckpoint(oldName) {
const tab = activeTab();
if (!tab || !tab.checkpoints || !tab.checkpoints[oldName]) return;
const raw = prompt(`rename '${oldName}' to:`, oldName);
if (raw === null) return;
const newName = safePortalName(raw);
if (!newName || newName === oldName) return;
if (tab.checkpoints[newName]) {
const old = tab.checkpoints[newName];
const when = new Date(old.savedAt).toLocaleString();
if (!confirm(`'${newName}' already exists (${old.tier}, saved ${when}). overwrite?`)) return;
}
tab.checkpoints[newName] = tab.checkpoints[oldName];
delete tab.checkpoints[oldName];
renderAll();
saveSoon();
}
// Export a checkpoint to a downloadable .portal.json file. The blob
// itself is opaque JSON for c/python (portal_save's wire format) and a
// {tier, mode: "replay", inputs} object for asm. Either way the file
// holds everything importCheckpoint needs to reconstruct the entry; no
// vault-side metadata bleeds out.
function exportCheckpoint(name) {
const tab = activeTab();
if (!tab || !tab.checkpoints || !tab.checkpoints[name]) return;
const entry = tab.checkpoints[name];
const payload = { __portal: 1, name, ...entry };
const data = new Blob([JSON.stringify(payload, null, 2)], { type: "application/json" });
const url = URL.createObjectURL(data);
const a = document.createElement("a");
a.href = url;
a.download = `${name}.portal.json`;
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
setTimeout(() => URL.revokeObjectURL(url), 1000);
}
// Import a checkpoint File (from <input type="file">). Validates the
// shape, prompts on name collision, lands the entry in the active
// tab's checkpoints map. The imported entry lives on whatever tier it
// was saved under — restoring it will switch the active tab to that
// tier automatically (restoreCheckpoint already does the swap).
async function importCheckpoint(file) {
const tab = activeTab();
if (!tab) return;
let payload;
try {
const text = await file.text();
payload = JSON.parse(text);
} catch (e) {
alert("import failed — not valid JSON: " + e.message);
return;
}
if (!payload || payload.__portal !== 1 || !payload.tier || !payload.savedAt) {
alert("import failed — file isn't a portal snapshot (missing __portal/tier/savedAt)");
return;
}
const baseName = safePortalName(payload.name || file.name.replace(/\.portal\.json$/i, "")) || "imported";
let name = baseName;
tab.checkpoints = tab.checkpoints || {};
if (tab.checkpoints[name]) {
const old = tab.checkpoints[name];
const when = new Date(old.savedAt).toLocaleString();
if (!confirm(`'${name}' already exists (${old.tier}, saved ${when}). overwrite?`)) {
// Auto-suffix on decline so the user gets to keep both.
let i = 2;
while (tab.checkpoints[`${baseName}-${i}`]) i++;
name = `${baseName}-${i}`;
}
}
const entry = { tier: payload.tier, savedAt: payload.savedAt };
if (payload.mode === "replay") {
entry.mode = "replay";
entry.inputs = Array.isArray(payload.inputs) ? payload.inputs : [];
} else {
entry.mode = "blob";
entry.blob = payload.blob || "";
}
tab.checkpoints[name] = entry;
renderAll();
saveSoon();
}
function deleteCheckpoint(name) {
const tab = activeTab();
if (!tab || !tab.checkpoints) return;
@ -492,24 +597,63 @@ function renderAll() {
transcriptEl.appendChild(block);
}
// Portal chips — one per saved checkpoint in the active tab.
// Portal chips — when showGlobalCheckpoints is off, one per saved
// checkpoint in the active tab. When on, gather every checkpoint
// across every tab so the user can restore a snapshot saved on
// tab A into tab B. The label gains a "· tabName" suffix in
// global mode so it's clear where each chip came from.
portalChipsEl.innerHTML = "";
const checkpoints = tab.checkpoints || {};
const names = Object.keys(checkpoints).sort();
for (const name of names) {
portalScopeBtn.textContent = showGlobalCheckpoints ? "🌐 all tabs" : "📂 this tab";
portalScopeBtn.title = showGlobalCheckpoints
? "showing checkpoints from every tab — click for this tab only"
: "showing only this tab's checkpoints — click for every tab";
const chipRows = [];
if (showGlobalCheckpoints) {
for (const t of state.tabs) {
const cps = t.checkpoints || {};
for (const name of Object.keys(cps)) {
chipRows.push({ tab: t, name, cp: cps[name] });
}
}
chipRows.sort((a, b) => b.cp.savedAt - a.cp.savedAt);
} else {
const cps = tab.checkpoints || {};
for (const name of Object.keys(cps).sort()) {
chipRows.push({ tab, name, cp: cps[name] });
}
}
for (const { tab: cpTab, name, cp } of chipRows) {
const chip = document.createElement("span");
chip.className = "chip";
chip.title = `${name} · ${checkpoints[name].tier} · saved ${new Date(checkpoints[name].savedAt).toLocaleString()} — click to restore`;
const when = new Date(cp.savedAt).toLocaleString();
chip.title = showGlobalCheckpoints
? `${name} · ${cp.tier} · saved ${when} on tab '${cpTab.name}' — click to restore into THIS tab`
: `${name} · ${cp.tier} · saved ${when} — click to restore, dbl-click to rename`;
const label = document.createElement("span");
label.textContent = name;
label.addEventListener("click", () => restoreCheckpoint(name));
const close = document.createElement("span");
close.className = "chip-close";
close.textContent = "×";
close.title = "delete checkpoint";
close.addEventListener("click", (e) => { e.stopPropagation(); if (confirm(`delete '${name}'?`)) deleteCheckpoint(name); });
chip.appendChild(label);
chip.appendChild(close);
label.textContent = showGlobalCheckpoints ? `${name} · ${cpTab.name}` : name;
// Restore always operates on the active tab; in global mode
// the snapshot's cp object is passed so we don't depend on
// the active tab having a checkpoints[name] match.
label.addEventListener("click", () => restoreCheckpoint(name, cp));
if (!showGlobalCheckpoints) {
label.addEventListener("dblclick", (e) => { e.stopPropagation(); renameCheckpoint(name); });
const exportBtn = document.createElement("span");
exportBtn.className = "chip-export";
exportBtn.textContent = "⇣";
exportBtn.title = "download checkpoint as .portal.json";
exportBtn.addEventListener("click", (e) => { e.stopPropagation(); exportCheckpoint(name); });
const close = document.createElement("span");
close.className = "chip-close";
close.textContent = "×";
close.title = "delete checkpoint";
close.addEventListener("click", (e) => { e.stopPropagation(); if (confirm(`delete '${name}'?`)) deleteCheckpoint(name); });
chip.appendChild(label);
chip.appendChild(exportBtn);
chip.appendChild(close);
} else {
chip.classList.add("chip-global");
chip.appendChild(label);
}
portalChipsEl.appendChild(chip);
}
@ -766,6 +910,17 @@ clearLogBtn.addEventListener("click", () => {
});
cancelBtn.addEventListener("click", cancelAllPendingInActiveTab);
portalSaveBtn.addEventListener("click", saveCheckpoint);
portalImportBtn.addEventListener("click", () => portalImportFileEl.click());
portalImportFileEl.addEventListener("change", async (e) => {
const file = e.target.files && e.target.files[0];
if (!file) return;
await importCheckpoint(file);
e.target.value = "";
});
portalScopeBtn.addEventListener("click", () => {
showGlobalCheckpoints = !showGlobalCheckpoints;
renderAll();
});
lockBtn.addEventListener("click", relock);
// Insert heap pressure indicator into the tabbar after the spacer.
document.getElementById("tabbar").insertBefore(heapEl, resetTierBtn);

View file

@ -90,6 +90,9 @@
<section class="portal-bar" id="portal-bar">
<span class="portal-label" title="save the current tab's tier state to your encrypted vault. comes back exactly the same after a tab close + reopen.">portals:</span>
<button id="portal-save" class="ghost" title="save tier state — names the snapshot by date+time">💾 save</button>
<button id="portal-import" class="ghost" title="import a .portal.json file">📁 import</button>
<button id="portal-scope" class="ghost" title="toggle between this tab's checkpoints and every tab's">📂 this tab</button>
<input id="portal-import-file" type="file" accept=".json,.portal,application/json" hidden>
<div id="portal-chips" class="portal-chips"></div>
</section>
<div id="transcript" class="transcript"></div>

View file

@ -190,7 +190,8 @@ body.repl {
* (the asm-wat tier stays grey). */
.portal-bar {
display: grid;
grid-template-columns: auto auto 1fr;
/* label · save · import · scope · chips-row */
grid-template-columns: auto auto auto auto 1fr;
gap: 0.4rem;
align-items: center;
padding: 0.2rem 0.5rem;
@ -211,7 +212,8 @@ body.repl {
}
.portal-chips .chip {
display: inline-grid;
grid-template-columns: auto auto;
/* label · ⇣ download · × delete */
grid-template-columns: auto auto auto;
gap: 0.3rem;
align-items: center;
background: var(--pane-bg);
@ -223,12 +225,20 @@ body.repl {
white-space: nowrap;
}
.portal-chips .chip:hover { border-color: var(--green); }
.portal-chips .chip-global {
/* Cross-tab snapshots get a muted accent so they read as "borrowed
* from another tab" rather than belonging here. */
border-style: dashed;
opacity: 0.85;
}
.portal-chips .chip .chip-export,
.portal-chips .chip .chip-close {
color: var(--muted);
font-size: 0.85em;
cursor: pointer;
padding: 0 0.15rem;
}
.portal-chips .chip .chip-export:hover { color: var(--green); }
.portal-chips .chip .chip-close:hover { color: var(--err); }
/* ─── Transcript ───────────────────────────────────────────────── */

View file

@ -30,7 +30,14 @@ const tierSelectEl = document.getElementById("tier-select");
const sendBtn = document.getElementById("send");
const portalBarEl = document.getElementById("portal-bar");
const portalSaveBtn = document.getElementById("portal-save");
const portalImportBtn = document.getElementById("portal-import");
const portalImportFileEl = document.getElementById("portal-import-file");
const portalScopeBtn = document.getElementById("portal-scope");
const portalChipsEl = document.getElementById("portal-chips");
// Whether the chip strip shows every tab's checkpoints (true) or just
// the active tab's (false). UI-only — never persisted, so a fresh
// session always starts in the focused per-tab view.
let showGlobalCheckpoints = false;
// ─── State ──────────────────────────────────────────────────────────
const state = {
@ -309,6 +316,14 @@ async function saveCheckpoint() {
`${ts.getSeconds().toString().padStart(2, "0")}`;
const name = safePortalName(prompt("checkpoint name:", defaultName) || "");
if (!name) return;
// Overwrite guard — saving with an existing name silently nuked
// the old blob, which has cost more than one "where did my snapshot
// go?" moment. Ask first.
if (tab.checkpoints && tab.checkpoints[name]) {
const old = tab.checkpoints[name];
const when = new Date(old.savedAt).toLocaleString();
if (!confirm(`'${name}' already exists (${old.tier}, saved ${when}). overwrite?`)) return;
}
let entry;
if (tier === "asm") {
@ -340,10 +355,15 @@ async function saveCheckpoint() {
saveSoon();
}
async function restoreCheckpoint(name) {
// Restore a checkpoint into the active tab. The optional sourceCp
// override lets the global-checkpoints view pass in a cp from a
// different tab — the restore still operates on activeTab() so the
// user lands on the tab they were working in, with that tab's tier
// state reset to the snapshot.
async function restoreCheckpoint(name, sourceCp) {
const tab = activeTab();
if (!tab) return;
const cp = tab.checkpoints && tab.checkpoints[name];
const cp = sourceCp || (tab.checkpoints && tab.checkpoints[name]);
if (!cp) return;
if (!confirm(`restore '${name}'? current ${cp.tier} state is replaced.`)) return;
if (tab.tier !== cp.tier) {
@ -383,6 +403,91 @@ async function restoreCheckpoint(name) {
saveSoon();
}
function renameCheckpoint(oldName) {
const tab = activeTab();
if (!tab || !tab.checkpoints || !tab.checkpoints[oldName]) return;
const raw = prompt(`rename '${oldName}' to:`, oldName);
if (raw === null) return;
const newName = safePortalName(raw);
if (!newName || newName === oldName) return;
if (tab.checkpoints[newName]) {
const old = tab.checkpoints[newName];
const when = new Date(old.savedAt).toLocaleString();
if (!confirm(`'${newName}' already exists (${old.tier}, saved ${when}). overwrite?`)) return;
}
tab.checkpoints[newName] = tab.checkpoints[oldName];
delete tab.checkpoints[oldName];
renderAll();
saveSoon();
}
// Export a checkpoint to a downloadable .portal.json file. The blob
// itself is opaque JSON for c/python (portal_save's wire format) and a
// {tier, mode: "replay", inputs} object for asm. Either way the file
// holds everything importCheckpoint needs to reconstruct the entry; no
// vault-side metadata bleeds out.
function exportCheckpoint(name) {
const tab = activeTab();
if (!tab || !tab.checkpoints || !tab.checkpoints[name]) return;
const entry = tab.checkpoints[name];
const payload = { __portal: 1, name, ...entry };
const data = new Blob([JSON.stringify(payload, null, 2)], { type: "application/json" });
const url = URL.createObjectURL(data);
const a = document.createElement("a");
a.href = url;
a.download = `${name}.portal.json`;
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
setTimeout(() => URL.revokeObjectURL(url), 1000);
}
// Import a checkpoint File (from <input type="file">). Validates the
// shape, prompts on name collision, lands the entry in the active
// tab's checkpoints map. The imported entry lives on whatever tier it
// was saved under — restoring it will switch the active tab to that
// tier automatically (restoreCheckpoint already does the swap).
async function importCheckpoint(file) {
const tab = activeTab();
if (!tab) return;
let payload;
try {
const text = await file.text();
payload = JSON.parse(text);
} catch (e) {
alert("import failed — not valid JSON: " + e.message);
return;
}
if (!payload || payload.__portal !== 1 || !payload.tier || !payload.savedAt) {
alert("import failed — file isn't a portal snapshot (missing __portal/tier/savedAt)");
return;
}
const baseName = safePortalName(payload.name || file.name.replace(/\.portal\.json$/i, "")) || "imported";
let name = baseName;
tab.checkpoints = tab.checkpoints || {};
if (tab.checkpoints[name]) {
const old = tab.checkpoints[name];
const when = new Date(old.savedAt).toLocaleString();
if (!confirm(`'${name}' already exists (${old.tier}, saved ${when}). overwrite?`)) {
// Auto-suffix on decline so the user gets to keep both.
let i = 2;
while (tab.checkpoints[`${baseName}-${i}`]) i++;
name = `${baseName}-${i}`;
}
}
const entry = { tier: payload.tier, savedAt: payload.savedAt };
if (payload.mode === "replay") {
entry.mode = "replay";
entry.inputs = Array.isArray(payload.inputs) ? payload.inputs : [];
} else {
entry.mode = "blob";
entry.blob = payload.blob || "";
}
tab.checkpoints[name] = entry;
renderAll();
saveSoon();
}
function deleteCheckpoint(name) {
const tab = activeTab();
if (!tab || !tab.checkpoints) return;
@ -492,24 +597,63 @@ function renderAll() {
transcriptEl.appendChild(block);
}
// Portal chips — one per saved checkpoint in the active tab.
// Portal chips — when showGlobalCheckpoints is off, one per saved
// checkpoint in the active tab. When on, gather every checkpoint
// across every tab so the user can restore a snapshot saved on
// tab A into tab B. The label gains a "· tabName" suffix in
// global mode so it's clear where each chip came from.
portalChipsEl.innerHTML = "";
const checkpoints = tab.checkpoints || {};
const names = Object.keys(checkpoints).sort();
for (const name of names) {
portalScopeBtn.textContent = showGlobalCheckpoints ? "🌐 all tabs" : "📂 this tab";
portalScopeBtn.title = showGlobalCheckpoints
? "showing checkpoints from every tab — click for this tab only"
: "showing only this tab's checkpoints — click for every tab";
const chipRows = [];
if (showGlobalCheckpoints) {
for (const t of state.tabs) {
const cps = t.checkpoints || {};
for (const name of Object.keys(cps)) {
chipRows.push({ tab: t, name, cp: cps[name] });
}
}
chipRows.sort((a, b) => b.cp.savedAt - a.cp.savedAt);
} else {
const cps = tab.checkpoints || {};
for (const name of Object.keys(cps).sort()) {
chipRows.push({ tab, name, cp: cps[name] });
}
}
for (const { tab: cpTab, name, cp } of chipRows) {
const chip = document.createElement("span");
chip.className = "chip";
chip.title = `${name} · ${checkpoints[name].tier} · saved ${new Date(checkpoints[name].savedAt).toLocaleString()} — click to restore`;
const when = new Date(cp.savedAt).toLocaleString();
chip.title = showGlobalCheckpoints
? `${name} · ${cp.tier} · saved ${when} on tab '${cpTab.name}' — click to restore into THIS tab`
: `${name} · ${cp.tier} · saved ${when} — click to restore, dbl-click to rename`;
const label = document.createElement("span");
label.textContent = name;
label.addEventListener("click", () => restoreCheckpoint(name));
const close = document.createElement("span");
close.className = "chip-close";
close.textContent = "×";
close.title = "delete checkpoint";
close.addEventListener("click", (e) => { e.stopPropagation(); if (confirm(`delete '${name}'?`)) deleteCheckpoint(name); });
chip.appendChild(label);
chip.appendChild(close);
label.textContent = showGlobalCheckpoints ? `${name} · ${cpTab.name}` : name;
// Restore always operates on the active tab; in global mode
// the snapshot's cp object is passed so we don't depend on
// the active tab having a checkpoints[name] match.
label.addEventListener("click", () => restoreCheckpoint(name, cp));
if (!showGlobalCheckpoints) {
label.addEventListener("dblclick", (e) => { e.stopPropagation(); renameCheckpoint(name); });
const exportBtn = document.createElement("span");
exportBtn.className = "chip-export";
exportBtn.textContent = "⇣";
exportBtn.title = "download checkpoint as .portal.json";
exportBtn.addEventListener("click", (e) => { e.stopPropagation(); exportCheckpoint(name); });
const close = document.createElement("span");
close.className = "chip-close";
close.textContent = "×";
close.title = "delete checkpoint";
close.addEventListener("click", (e) => { e.stopPropagation(); if (confirm(`delete '${name}'?`)) deleteCheckpoint(name); });
chip.appendChild(label);
chip.appendChild(exportBtn);
chip.appendChild(close);
} else {
chip.classList.add("chip-global");
chip.appendChild(label);
}
portalChipsEl.appendChild(chip);
}
@ -766,6 +910,17 @@ clearLogBtn.addEventListener("click", () => {
});
cancelBtn.addEventListener("click", cancelAllPendingInActiveTab);
portalSaveBtn.addEventListener("click", saveCheckpoint);
portalImportBtn.addEventListener("click", () => portalImportFileEl.click());
portalImportFileEl.addEventListener("change", async (e) => {
const file = e.target.files && e.target.files[0];
if (!file) return;
await importCheckpoint(file);
e.target.value = "";
});
portalScopeBtn.addEventListener("click", () => {
showGlobalCheckpoints = !showGlobalCheckpoints;
renderAll();
});
lockBtn.addEventListener("click", relock);
// Insert heap pressure indicator into the tabbar after the spacer.
document.getElementById("tabbar").insertBefore(heapEl, resetTierBtn);

View file

@ -99,7 +99,14 @@ async function _bootstrap() {
// free path right now (LUMBDA_NO_BOEHM), so used = total —
// every malloc accumulates until reload. Documented and
// surfaced in the REPL so the user sees the pressure.
const total = module.HEAPU8.byteLength;
//
// HEAPU8 isn't always live: after a worker reboots, the
// heap poll can fire while the new Module is mid-init and
// HEAPU8 isn't yet wired. Return null so the REPL hides
// the pressure indicator for that tick instead of crashing.
const heap = module.HEAPU8;
if (!heap) return null;
const total = heap.byteLength;
return { used: total, total };
},
};

View file

@ -99,7 +99,14 @@ async function _bootstrap() {
// free path right now (LUMBDA_NO_BOEHM), so used = total —
// every malloc accumulates until reload. Documented and
// surfaced in the REPL so the user sees the pressure.
const total = module.HEAPU8.byteLength;
//
// HEAPU8 isn't always live: after a worker reboots, the
// heap poll can fire while the new Module is mid-init and
// HEAPU8 isn't yet wired. Return null so the REPL hides
// the pressure indicator for that tick instead of crashing.
const heap = module.HEAPU8;
if (!heap) return null;
const total = heap.byteLength;
return { used: total, total };
},
};

View file

@ -90,6 +90,9 @@
<section class="portal-bar" id="portal-bar">
<span class="portal-label" title="save the current tab's tier state to your encrypted vault. comes back exactly the same after a tab close + reopen.">portals:</span>
<button id="portal-save" class="ghost" title="save tier state — names the snapshot by date+time">💾 save</button>
<button id="portal-import" class="ghost" title="import a .portal.json file">📁 import</button>
<button id="portal-scope" class="ghost" title="toggle between this tab's checkpoints and every tab's">📂 this tab</button>
<input id="portal-import-file" type="file" accept=".json,.portal,application/json" hidden>
<div id="portal-chips" class="portal-chips"></div>
</section>
<div id="transcript" class="transcript"></div>

View file

@ -190,7 +190,8 @@ body.repl {
* (the asm-wat tier stays grey). */
.portal-bar {
display: grid;
grid-template-columns: auto auto 1fr;
/* label · save · import · scope · chips-row */
grid-template-columns: auto auto auto auto 1fr;
gap: 0.4rem;
align-items: center;
padding: 0.2rem 0.5rem;
@ -211,7 +212,8 @@ body.repl {
}
.portal-chips .chip {
display: inline-grid;
grid-template-columns: auto auto;
/* label · ⇣ download · × delete */
grid-template-columns: auto auto auto;
gap: 0.3rem;
align-items: center;
background: var(--pane-bg);
@ -223,12 +225,20 @@ body.repl {
white-space: nowrap;
}
.portal-chips .chip:hover { border-color: var(--green); }
.portal-chips .chip-global {
/* Cross-tab snapshots get a muted accent so they read as "borrowed
* from another tab" rather than belonging here. */
border-style: dashed;
opacity: 0.85;
}
.portal-chips .chip .chip-export,
.portal-chips .chip .chip-close {
color: var(--muted);
font-size: 0.85em;
cursor: pointer;
padding: 0 0.15rem;
}
.portal-chips .chip .chip-export:hover { color: var(--green); }
.portal-chips .chip .chip-close:hover { color: var(--err); }
/* ─── Transcript ───────────────────────────────────────────────── */

View file

@ -30,7 +30,14 @@ const tierSelectEl = document.getElementById("tier-select");
const sendBtn = document.getElementById("send");
const portalBarEl = document.getElementById("portal-bar");
const portalSaveBtn = document.getElementById("portal-save");
const portalImportBtn = document.getElementById("portal-import");
const portalImportFileEl = document.getElementById("portal-import-file");
const portalScopeBtn = document.getElementById("portal-scope");
const portalChipsEl = document.getElementById("portal-chips");
// Whether the chip strip shows every tab's checkpoints (true) or just
// the active tab's (false). UI-only — never persisted, so a fresh
// session always starts in the focused per-tab view.
let showGlobalCheckpoints = false;
// ─── State ──────────────────────────────────────────────────────────
const state = {
@ -309,6 +316,14 @@ async function saveCheckpoint() {
`${ts.getSeconds().toString().padStart(2, "0")}`;
const name = safePortalName(prompt("checkpoint name:", defaultName) || "");
if (!name) return;
// Overwrite guard — saving with an existing name silently nuked
// the old blob, which has cost more than one "where did my snapshot
// go?" moment. Ask first.
if (tab.checkpoints && tab.checkpoints[name]) {
const old = tab.checkpoints[name];
const when = new Date(old.savedAt).toLocaleString();
if (!confirm(`'${name}' already exists (${old.tier}, saved ${when}). overwrite?`)) return;
}
let entry;
if (tier === "asm") {
@ -340,10 +355,15 @@ async function saveCheckpoint() {
saveSoon();
}
async function restoreCheckpoint(name) {
// Restore a checkpoint into the active tab. The optional sourceCp
// override lets the global-checkpoints view pass in a cp from a
// different tab — the restore still operates on activeTab() so the
// user lands on the tab they were working in, with that tab's tier
// state reset to the snapshot.
async function restoreCheckpoint(name, sourceCp) {
const tab = activeTab();
if (!tab) return;
const cp = tab.checkpoints && tab.checkpoints[name];
const cp = sourceCp || (tab.checkpoints && tab.checkpoints[name]);
if (!cp) return;
if (!confirm(`restore '${name}'? current ${cp.tier} state is replaced.`)) return;
if (tab.tier !== cp.tier) {
@ -383,6 +403,91 @@ async function restoreCheckpoint(name) {
saveSoon();
}
function renameCheckpoint(oldName) {
const tab = activeTab();
if (!tab || !tab.checkpoints || !tab.checkpoints[oldName]) return;
const raw = prompt(`rename '${oldName}' to:`, oldName);
if (raw === null) return;
const newName = safePortalName(raw);
if (!newName || newName === oldName) return;
if (tab.checkpoints[newName]) {
const old = tab.checkpoints[newName];
const when = new Date(old.savedAt).toLocaleString();
if (!confirm(`'${newName}' already exists (${old.tier}, saved ${when}). overwrite?`)) return;
}
tab.checkpoints[newName] = tab.checkpoints[oldName];
delete tab.checkpoints[oldName];
renderAll();
saveSoon();
}
// Export a checkpoint to a downloadable .portal.json file. The blob
// itself is opaque JSON for c/python (portal_save's wire format) and a
// {tier, mode: "replay", inputs} object for asm. Either way the file
// holds everything importCheckpoint needs to reconstruct the entry; no
// vault-side metadata bleeds out.
function exportCheckpoint(name) {
const tab = activeTab();
if (!tab || !tab.checkpoints || !tab.checkpoints[name]) return;
const entry = tab.checkpoints[name];
const payload = { __portal: 1, name, ...entry };
const data = new Blob([JSON.stringify(payload, null, 2)], { type: "application/json" });
const url = URL.createObjectURL(data);
const a = document.createElement("a");
a.href = url;
a.download = `${name}.portal.json`;
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
setTimeout(() => URL.revokeObjectURL(url), 1000);
}
// Import a checkpoint File (from <input type="file">). Validates the
// shape, prompts on name collision, lands the entry in the active
// tab's checkpoints map. The imported entry lives on whatever tier it
// was saved under — restoring it will switch the active tab to that
// tier automatically (restoreCheckpoint already does the swap).
async function importCheckpoint(file) {
const tab = activeTab();
if (!tab) return;
let payload;
try {
const text = await file.text();
payload = JSON.parse(text);
} catch (e) {
alert("import failed — not valid JSON: " + e.message);
return;
}
if (!payload || payload.__portal !== 1 || !payload.tier || !payload.savedAt) {
alert("import failed — file isn't a portal snapshot (missing __portal/tier/savedAt)");
return;
}
const baseName = safePortalName(payload.name || file.name.replace(/\.portal\.json$/i, "")) || "imported";
let name = baseName;
tab.checkpoints = tab.checkpoints || {};
if (tab.checkpoints[name]) {
const old = tab.checkpoints[name];
const when = new Date(old.savedAt).toLocaleString();
if (!confirm(`'${name}' already exists (${old.tier}, saved ${when}). overwrite?`)) {
// Auto-suffix on decline so the user gets to keep both.
let i = 2;
while (tab.checkpoints[`${baseName}-${i}`]) i++;
name = `${baseName}-${i}`;
}
}
const entry = { tier: payload.tier, savedAt: payload.savedAt };
if (payload.mode === "replay") {
entry.mode = "replay";
entry.inputs = Array.isArray(payload.inputs) ? payload.inputs : [];
} else {
entry.mode = "blob";
entry.blob = payload.blob || "";
}
tab.checkpoints[name] = entry;
renderAll();
saveSoon();
}
function deleteCheckpoint(name) {
const tab = activeTab();
if (!tab || !tab.checkpoints) return;
@ -492,24 +597,63 @@ function renderAll() {
transcriptEl.appendChild(block);
}
// Portal chips — one per saved checkpoint in the active tab.
// Portal chips — when showGlobalCheckpoints is off, one per saved
// checkpoint in the active tab. When on, gather every checkpoint
// across every tab so the user can restore a snapshot saved on
// tab A into tab B. The label gains a "· tabName" suffix in
// global mode so it's clear where each chip came from.
portalChipsEl.innerHTML = "";
const checkpoints = tab.checkpoints || {};
const names = Object.keys(checkpoints).sort();
for (const name of names) {
portalScopeBtn.textContent = showGlobalCheckpoints ? "🌐 all tabs" : "📂 this tab";
portalScopeBtn.title = showGlobalCheckpoints
? "showing checkpoints from every tab — click for this tab only"
: "showing only this tab's checkpoints — click for every tab";
const chipRows = [];
if (showGlobalCheckpoints) {
for (const t of state.tabs) {
const cps = t.checkpoints || {};
for (const name of Object.keys(cps)) {
chipRows.push({ tab: t, name, cp: cps[name] });
}
}
chipRows.sort((a, b) => b.cp.savedAt - a.cp.savedAt);
} else {
const cps = tab.checkpoints || {};
for (const name of Object.keys(cps).sort()) {
chipRows.push({ tab, name, cp: cps[name] });
}
}
for (const { tab: cpTab, name, cp } of chipRows) {
const chip = document.createElement("span");
chip.className = "chip";
chip.title = `${name} · ${checkpoints[name].tier} · saved ${new Date(checkpoints[name].savedAt).toLocaleString()} — click to restore`;
const when = new Date(cp.savedAt).toLocaleString();
chip.title = showGlobalCheckpoints
? `${name} · ${cp.tier} · saved ${when} on tab '${cpTab.name}' — click to restore into THIS tab`
: `${name} · ${cp.tier} · saved ${when} — click to restore, dbl-click to rename`;
const label = document.createElement("span");
label.textContent = name;
label.addEventListener("click", () => restoreCheckpoint(name));
const close = document.createElement("span");
close.className = "chip-close";
close.textContent = "×";
close.title = "delete checkpoint";
close.addEventListener("click", (e) => { e.stopPropagation(); if (confirm(`delete '${name}'?`)) deleteCheckpoint(name); });
chip.appendChild(label);
chip.appendChild(close);
label.textContent = showGlobalCheckpoints ? `${name} · ${cpTab.name}` : name;
// Restore always operates on the active tab; in global mode
// the snapshot's cp object is passed so we don't depend on
// the active tab having a checkpoints[name] match.
label.addEventListener("click", () => restoreCheckpoint(name, cp));
if (!showGlobalCheckpoints) {
label.addEventListener("dblclick", (e) => { e.stopPropagation(); renameCheckpoint(name); });
const exportBtn = document.createElement("span");
exportBtn.className = "chip-export";
exportBtn.textContent = "⇣";
exportBtn.title = "download checkpoint as .portal.json";
exportBtn.addEventListener("click", (e) => { e.stopPropagation(); exportCheckpoint(name); });
const close = document.createElement("span");
close.className = "chip-close";
close.textContent = "×";
close.title = "delete checkpoint";
close.addEventListener("click", (e) => { e.stopPropagation(); if (confirm(`delete '${name}'?`)) deleteCheckpoint(name); });
chip.appendChild(label);
chip.appendChild(exportBtn);
chip.appendChild(close);
} else {
chip.classList.add("chip-global");
chip.appendChild(label);
}
portalChipsEl.appendChild(chip);
}
@ -766,6 +910,17 @@ clearLogBtn.addEventListener("click", () => {
});
cancelBtn.addEventListener("click", cancelAllPendingInActiveTab);
portalSaveBtn.addEventListener("click", saveCheckpoint);
portalImportBtn.addEventListener("click", () => portalImportFileEl.click());
portalImportFileEl.addEventListener("change", async (e) => {
const file = e.target.files && e.target.files[0];
if (!file) return;
await importCheckpoint(file);
e.target.value = "";
});
portalScopeBtn.addEventListener("click", () => {
showGlobalCheckpoints = !showGlobalCheckpoints;
renderAll();
});
lockBtn.addEventListener("click", relock);
// Insert heap pressure indicator into the tabbar after the spacer.
document.getElementById("tabbar").insertBefore(heapEl, resetTierBtn);