lumbda/wasm/repl/repl.js
russell@unturf.com 63808f72a5
repl: fix cancel + immediate-resubmit race that wedged the new eval
Three converging bugs let a cancel-then-send sequence land in a
broken state:

1) cancelAllPendingInActiveTab didn't touch the toolbar — the user
   had to wait for the cancelled promise's .catch.then to drain
   before send re-enabled. fox saw clicks register as noops because
   the button was still disabled.

2) Even after the rejection settled, the stale .then path
   re-enabled sendBtn from inside the FIRST run's closure — but by
   then the user had already submitted a SECOND run that set
   sendBtn disabled. The stale .then clobbered the in-flight state.

3) The OLD worker's last-queued "done" or "error" message could be
   delivered after a fresh run had already taken state.pending[k];
   the OLD handler would then delete the NEW run's pending entry.

Fixes:

* cancel handler now re-enables send / disables cancel + pause
  synchronously and clears tab.activeInput.
* The completion .then re-checks isEvalInFlight(tab) before resetting
  toolbar state, so a stale settle from a cancelled run can't
  override the fresh run's UI.
* evalInTier's done/error handler only deletes state.pending[k] when
  the entry still has the runId we started with.

Verified headlessly: long loop → cancel → submit (+ 1 2) immediately
→ first entry shows error: cancelled, second entry shows result 3,
send re-enabled.
2026-06-15 14:11:55 -04:00

1326 lines
56 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// wasm/repl/repl.js
// Lumbda interactive REPL with multi-tab sessions + encrypted persistence.
import { openVault } from "./crypto.js";
const TIER_LABEL = {
python: "python (pyodide)",
c: "c (emcc)",
asm: "asm (wat)",
};
// ─── DOM ────────────────────────────────────────────────────────────
const lockScreen = document.getElementById("lock-screen");
const passwordEl = document.getElementById("password");
const unlockBtn = document.getElementById("unlock");
const freshBtn = document.getElementById("fresh");
const lockNote = document.getElementById("lock-note");
const tabsEl = document.getElementById("tabs");
const newTabBtn = document.getElementById("new-tab");
const resetTierBtn = document.getElementById("reset-tier");
const clearLogBtn = document.getElementById("clear-log");
const cancelBtn = document.getElementById("cancel");
const pauseBtn = document.getElementById("pause");
const lockBtn = document.getElementById("lock");
const replStream = document.getElementById("repl-stream");
const transcriptEl = document.getElementById("transcript");
const inputEl = document.getElementById("input");
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 = {
vault: null, // { read, write, destroy, vaultId } or null (ephemeral)
tabs: [], // [{ id, name, tier, transcript: [{kind, text, results?}] }]
activeTabId: null,
workers: {}, // { [tabId+":"+tier]: Worker }
pending: {}, // same key → { runId, reject }
nextRunId: 1,
nextTabId: 1,
saveTimer: null,
// SharedArrayBuffer the tier's eval loop polls for a cooperative
// pause signal. C tier reads via Atomics in the JS-library import;
// Python via globals + sys.settrace. Browsers refuse the SAB
// constructor without COOP/COEP cross-origin-isolation headers,
// so this stays null on a plain http.server and the ⏸ pause
// button surfaces a helpful tooltip instead of crashing.
pauseFlag: (() => {
try { return new SharedArrayBuffer(4); } catch (e) { return null; }
})(),
pausing: false, // true while a pause-and-snapshot flow is mid-air
};
// Eager view over the pause flag so the button handler doesn't have to
// re-wrap on every click. Null when SAB isn't available.
const pauseFlagView = state.pauseFlag ? new Int32Array(state.pauseFlag) : null;
// ─── Vault ──────────────────────────────────────────────────────────
async function saveSoon() {
if (!state.vault) return;
if (state.saveTimer) clearTimeout(state.saveTimer);
state.saveTimer = setTimeout(async () => {
try {
await state.vault.write({
tabs: state.tabs,
activeTabId: state.activeTabId,
savedAt: Date.now(),
});
} catch (e) {
console.error("vault write failed:", e);
}
}, 400);
}
async function tryUnlock() {
const pw = passwordEl.value;
if (!pw) { lockNote.textContent = "password required (or click ephemeral)"; return; }
lockNote.textContent = "";
try {
state.vault = await openVault(pw);
const data = await state.vault.read();
if (data && data.__decryptionFailed) {
// Wrong password for an existing vault under this id.
lockNote.textContent = "vault exists but password is wrong";
state.vault = null;
return;
}
if (data && Array.isArray(data.tabs) && data.tabs.length > 0) {
state.tabs = data.tabs;
state.activeTabId = data.activeTabId || data.tabs[0].id;
state.nextTabId = Math.max(...state.tabs.map((t) => t.id)) + 1;
} else {
state.tabs = [];
state.activeTabId = null;
state.nextTabId = 1;
}
enterRepl();
} catch (e) {
lockNote.textContent = "unlock failed: " + e.message;
}
}
function enterEphemeral() {
state.vault = null;
state.tabs = [];
state.activeTabId = null;
state.nextTabId = 1;
enterRepl();
}
function enterRepl() {
lockScreen.hidden = true;
replStream.hidden = false;
if (state.tabs.length === 0) newTab();
else renderAll();
inputEl.focus();
}
function relock() {
// Terminate all workers, clear in-memory state, return to lock screen.
for (const k of Object.keys(state.workers)) {
try { state.workers[k].terminate(); } catch {}
}
state.workers = {};
state.pending = {};
state.vault = null;
state.tabs = [];
state.activeTabId = null;
transcriptEl.innerHTML = "";
tabsEl.innerHTML = "";
replStream.hidden = true;
lockScreen.hidden = false;
passwordEl.value = "";
passwordEl.focus();
}
// ─── Tabs ───────────────────────────────────────────────────────────
async function newTab(name) {
const id = state.nextTabId++;
name = name || `session ${id}`;
state.tabs.push({ id, name, tier: "c", transcript: [], inputDraft: "" });
// Route through setActiveTab so the outgoing tab's auto-pause
// fires and the toolbar buttons sync to the freshly-created
// (idle) tab. Without this, opening a new tab during an eval
// leaves the send button disabled and the eval orphaned.
await setActiveTab(id);
}
function closeTab(id) {
const idx = state.tabs.findIndex((t) => t.id === id);
if (idx < 0) return;
// Terminate this tab's workers.
for (const k of Object.keys(state.workers)) {
if (k.startsWith(id + ":")) {
try { state.workers[k].terminate(); } catch {}
delete state.workers[k];
}
}
state.tabs.splice(idx, 1);
if (state.activeTabId === id) {
state.activeTabId = state.tabs.length ? state.tabs[Math.max(0, idx - 1)].id : null;
}
if (state.tabs.length === 0) newTab();
else renderAll();
saveSoon();
}
async function setActiveTab(id) {
if (id === state.activeTabId) return;
// Re-entry guard: rapid clicks on tab labels can fire setActiveTab
// before the previous autoPause/autoResume cycle finishes. Without
// this, two pause flows race on the same outgoing tab and the
// second one captures torn state. Drop the later click silently
// (cheaper than queueing — the user can click again once they
// see the active tab settle).
if (state.switching) return;
state.switching = true;
try {
// Capture the outgoing tab's current input as its draft before
// we swap tabs — otherwise switching away from a half-written
// form and back loses everything between the last input event
// and the next saveSoon flush.
const out = activeTab();
if (out) {
out.inputDraft = inputEl.value;
// Auto-pause + portal-save when the user leaves a tab
// whose tier is mid-eval. Without this, switching tabs
// during a long search abandons the state silently.
// autoPauseTab snapshots the env (SAB-poll path for C/python,
// transcript replay for asm), stashes the input that
// started the eval, and terminates the worker so the
// heap is released. autoResumeTab on the way back
// hydrates and re-fires the input.
if (isEvalInFlight(out)) {
await autoPauseTab(out);
}
}
state.activeTabId = id;
renderAll();
// Sync the global toolbar button states to the incoming tab —
// send/cancel/pause are not tab-scoped DOM elements, so a
// switch-away during one tab's eval would otherwise leave the
// newly-active (idle) tab with send disabled. The active tab
// is idle iff nothing in state.pending matches its id.
const incoming = activeTab();
if (incoming) {
const evalRunning = isEvalInFlight(incoming);
sendBtn.disabled = evalRunning;
cancelBtn.disabled = !evalRunning;
pauseBtn.disabled = !evalRunning || !pauseFlagView;
}
saveSoon();
if (incoming && incoming.autoPause) {
await autoResumeTab(incoming);
}
} finally {
state.switching = false;
}
}
function isEvalInFlight(tab) {
for (const k of Object.keys(state.pending)) {
if (k.startsWith(tab.id + ":")) return true;
}
return false;
}
async function waitForPending(tabId, tier, timeoutMs) {
const key = workerKey(tabId, tier);
const start = performance.now();
while (state.pending[key] && (performance.now() - start) < timeoutMs) {
await new Promise((r) => setTimeout(r, 30));
}
}
// Race a promise against a wall-clock timeout. Returns null when the
// promise didn't settle in time so the caller can move on rather than
// hang. Used in autoPauseTab so a worker that won't yield to the
// pause flag (e.g. stuck in sync XHR, or eval-loop poll never fires
// because we're outside the C poll site) can still be terminated.
function withTimeout(p, ms) {
return Promise.race([
p.catch(() => null),
new Promise((r) => setTimeout(() => r(null), ms)),
]);
}
// Auto-pause hook for tab-switch-away. The tier may be deep inside a
// (let loop ...) — set the SAB pause atomic, wait for the eval-loop
// poll to raise lisp_error("paused"), then snapshot env via the
// regular portal-save path so the state survives until the user
// switches back. Without SAB we still capture the in-flight input
// so the resume can re-eval it cold.
async function autoPauseTab(tab) {
const tier = tab.tier === "all" ? "c" : tab.tier;
const activeInput = tab.activeInput || null;
let blob = null;
if (pauseFlagView && (tier === "c" || tier === "python")) {
// SAB-poll path: C tier's eval loop hooks js_lumbda_pause_requested,
// Python tier's lumbda.py polls _lumbdaPyPauseRequested. Both
// raise their own "paused" error (lisp_error / LispErr) so the
// module-global env survives intact. Asm tier has no in-eval
// poll site — falls through to hard cancel.
Atomics.store(pauseFlagView, 0, 1);
await waitForPending(tab.id, tier, 2000);
Atomics.store(pauseFlagView, 0, 0);
// If the eval is still in flight after 2s, the worker isn't
// yielding to the SAB flag (stuck in sync XHR, native builtin
// without a yield point, etc). Skip the snapshot attempt and
// drop straight to the unconditional terminate below — the
// user gets the cancel-and-replay fallback instead of a hang.
if (!isEvalInFlight(tab)) {
const snapName = `_autopause_t${tab.id}`;
try {
const snapOk = await withTimeout(
evalInTier(tab.id, tier, `(portal-snapshot! "${snapName}")`),
2000,
);
if (snapOk !== null) {
const w = state.workers[workerKey(tab.id, tier)];
if (w) {
const runId = state.nextRunId++;
const reply = await withTimeout(
bridgeWorker(w, { kind: "portal-save", runId, tier, name: snapName }, "portal-save"),
1500,
);
if (reply && reply.blob) blob = reply.blob;
}
}
} catch (e) { /* no blob — resume will re-eval from scratch */ }
}
}
if (isEvalInFlight(tab)) {
// SAB unavailable, asm tier, OR SAB pause timed out without
// the eval unwinding — hard-cancel the in-flight eval(s) so
// the unconditional terminate loop below actually kills them.
for (const k of Object.keys(state.pending)) {
if (!k.startsWith(tab.id + ":")) continue;
try { state.pending[k].reject(new Error("tab paused")); } catch {}
delete state.pending[k];
}
}
if (activeInput) {
// Asm tier has no SAB poll site, so we can't snapshot env mid-eval.
// Fall back to replay mode: every successful prior input gets
// re-eval'd on resume to rebuild the env, then the active input
// is sent fresh. Same pattern the manual asm portal-save uses.
const replayInputs = (tier === "asm")
? tab.transcript.filter((e) => e.kind !== "error" && e.input !== activeInput).map((e) => e.input)
: null;
tab.autoPause = { tier, blob, replayInputs, inputSrc: activeInput, savedAt: Date.now() };
}
// Terminate the worker(s) so the heap is reclaimed while the tab
// is dormant. autoResumeTab spawns a fresh one.
for (const k of Object.keys(state.workers)) {
if (!k.startsWith(tab.id + ":")) continue;
try { state.workers[k].terminate(); } catch {}
delete state.workers[k];
}
}
async function autoResumeTab(tab) {
const ap = tab.autoPause;
if (!ap) return;
tab.autoPause = null; // consume up-front so a re-pause picks fresh
tab.tier = ap.tier;
tierSelectEl.value = ap.tier;
rebootTier(tab.id, ap.tier);
if (ap.replayInputs && ap.replayInputs.length) {
// Asm tier replay path — re-eval every successful prior input
// in order so the env is reconstructed before sendInput fires
// the active one. Errors mid-replay are swallowed; restoring
// from a transcript that defined-on-error is the user's call.
for (const src of ap.replayInputs) {
try { await evalInTier(tab.id, ap.tier, src); } catch (e) { /* keep going */ }
}
tab.transcript.push({
input: `; resumed asm tier — replayed ${ap.replayInputs.length} prior inputs`,
results: [{ tier: ap.tier, output: "#t" }], kind: "ok",
});
} else if (ap.blob) {
// Round-trip through the tier so portal-load! has the blob
// to read. The bare 'init eval forces the tier to bootstrap
// before we push the blob into MEMFS.
try { await evalInTier(tab.id, ap.tier, "'init"); } catch (e) { /* harmless */ }
const w = state.workers[workerKey(tab.id, ap.tier)];
if (w) {
const snapName = `_autopause_t${tab.id}`;
const runId = state.nextRunId++;
await bridgeWorker(w, { kind: "portal-load", runId, tier: ap.tier, name: snapName, blob: ap.blob }, "portal-load");
try { await evalInTier(tab.id, ap.tier, `(portal-load! "${snapName}")`); }
catch (e) { /* surface as resume failure but keep going */ }
}
}
// Re-run the original input. The transcript already shows the
// prior partial run; this appears as a new entry so the user
// sees the resumption explicitly.
inputEl.value = ap.inputSrc;
autosizeInput();
await sendInput();
}
function activeTab() {
return state.tabs.find((t) => t.id === state.activeTabId);
}
// ─── Workers ────────────────────────────────────────────────────────
function workerKey(tabId, tier) { return `${tabId}:${tier}`; }
function spawnWorker(tabId, tier) {
const w = new Worker(new URL("./worker.mjs", import.meta.url), { type: "module" });
state.workers[workerKey(tabId, tier)] = w;
// Hand the worker its pause flag. Worker stashes the SAB so the
// tier loader can install an Int32Array view when it boots. We
// skip bendUrl here so the worker's previous bend setting (if
// any) isn't clobbered to null.
w.postMessage({ kind: "config", pauseFlag: state.pauseFlag });
return w;
}
function ensureWorker(tabId, tier) {
const k = workerKey(tabId, tier);
if (!state.workers[k]) spawnWorker(tabId, tier);
return state.workers[k];
}
function evalInTier(tabId, tier, src, onChunkText, onChunkEol) {
return new Promise((resolve, reject) => {
const w = ensureWorker(tabId, tier);
const runId = state.nextRunId++;
const pendingKey = workerKey(tabId, tier);
state.pending[pendingKey] = { runId, reject };
const handler = (e) => {
if (e.data.runId !== runId) return;
if (e.data.kind === "chunk-batch") {
// Worker now batches chunks into 4KB/64-newline windows
// so a tight (display) loop doesn't drown the main
// thread in postMessage events. Split the batch text
// back into individual text + eol calls so the live
// streaming row sees the same shape as before.
if (!onChunkText && !onChunkEol) return;
const t = e.data.text;
let start = 0;
for (let i = 0; i < t.length; i++) {
if (t.charCodeAt(i) === 10) {
if (i > start && onChunkText) onChunkText(t.slice(start, i));
if (onChunkEol) onChunkEol();
start = i + 1;
}
}
if (start < t.length && onChunkText) onChunkText(t.slice(start));
} else if (e.data.kind === "chunk-text") {
onChunkText && onChunkText(e.data.text);
} else if (e.data.kind === "chunk-eol") {
onChunkEol && onChunkEol();
} else if (e.data.kind === "done") {
w.removeEventListener("message", handler);
// Only clear pending if it's still our runId — a
// cancel-then-resubmit cycle can leave state.pending[k]
// pointing at a fresh eval before our terminated worker
// gets around to having its last-queued "done" delivered
// on the main side.
if (state.pending[pendingKey] && state.pending[pendingKey].runId === runId) {
delete state.pending[pendingKey];
}
resolve(e.data.output);
} else if (e.data.kind === "error") {
w.removeEventListener("message", handler);
if (state.pending[pendingKey] && state.pending[pendingKey].runId === runId) {
delete state.pending[pendingKey];
}
reject(new Error(e.data.message));
}
};
w.addEventListener("message", handler);
w.postMessage({ kind: "eval", runId, tier, src });
});
}
// Streaming live block — attached to a tier-result span after the
// initial renderAll so chunks arrive directly into the DOM without
// re-rendering the whole transcript (which would clobber sibling
// tiers still streaming). Same per-line-div pattern the playground
// uses: each chunk-eol closes a block-level div, sibling lines
// stack vertically regardless of <span>'s inline default.
function attachStreaming(resultSpan, metaSpan, tier) {
// Single Text node accumulator — appending to one Text node's
// nodeValue and letting `white-space: pre` on the parent render
// newlines is roughly 1000x faster than creating one <div> per
// line for high-volume streams. 500K display lines used to choke
// the main thread for ~12s of DOM mutation alone; a single Text
// node renders the same content with near-zero per-line cost
// (browsers don't re-layout a pre-wrap text node line-by-line).
const makeErrLine = (txt) => {
const d = document.createElement("div");
d.style.display = "block";
d.style.whiteSpace = "pre";
d.className = "err-line";
d.textContent = txt;
return d;
};
resultSpan.textContent = "";
resultSpan.style.whiteSpace = "pre";
const textNode = document.createTextNode("");
resultSpan.appendChild(textNode);
metaSpan.textContent = `${TIER_LABEL[tier] || tier} · running…`;
let buffered = "";
let rafPending = false;
function flush() {
rafPending = false;
if (!buffered) return;
textNode.appendData(buffered);
buffered = "";
maybeAutoscroll();
}
function schedule() {
if (rafPending) return;
rafPending = true;
requestAnimationFrame(flush);
}
return {
flush, // exposed so finalize can drain synchronously
appendText(t) {
if (!t) return;
buffered += t;
schedule();
},
appendNewline() {
buffered += "\n";
schedule();
},
finalize(r) {
// Drain any buffered chunks that haven't been flushed yet
// so streamedText below sees the most recent content
// (otherwise the success-with-streaming match check is
// racy against the last RAF batch).
if (rafPending) flush();
const text = r.output != null ? r.output : "";
const errText = r.error ? "error: " + r.error : "";
// textContent here is the live Text node's accumulated
// stream (the per-line-div era used Array.from(children);
// switched to a single Text node for high-volume streaming
// perf, so children is empty now).
const streamedText = textNode.nodeValue.replace(/\n$/, "");
const expected = text.replace(/\n$/, "");
// Preserving partial output on cancel/error: a long-running
// (display ...) loop that fox cancels half-way through, or
// an asm tier that hits "index out of bounds" 5000 lines
// into a sieve, used to lose every line that already
// streamed. Now:
// * success, streamed matches full output → keep DOM.
// * success, no streaming happened → rebuild from
// the final text.
// * error with prior streaming → KEEP the
// streamed divs, append a final divider + error line.
// * error with no streaming → show error.
if (!errText) {
if (streamedText !== expected) {
// Streamed buffer doesn't match final output —
// refresh the text node with the canonical value.
textNode.nodeValue = expected;
}
} else if (streamedText) {
// Append the error AFTER what's already on screen so
// the user keeps every line they were watching scroll.
if (!textNode.nodeValue.endsWith("\n")) {
textNode.appendData("\n");
}
resultSpan.appendChild(makeErrLine(errText));
} else {
resultSpan.textContent = "";
resultSpan.appendChild(makeErrLine(errText));
}
metaSpan.textContent = `${TIER_LABEL[tier] || tier} · ${(r.elapsed | 0)}ms`;
},
};
}
// ─── Portal save / resume ───────────────────────────────────────────
// Cooperative checkpoints: the tier exposes (portal-snapshot! NAME) and
// (portal-load! NAME) which write/read /tmp/NAME.portal inside MEMFS.
// JS reaches into MEMFS via the worker bridge, encrypts the blob into
// the vault, and on restore reverses the flow. Names are alphanumeric;
// the timestamp format we generate ourselves never embeds slashes.
function safePortalName(s) {
return (s || "").replace(/[^A-Za-z0-9_-]/g, "_").slice(0, 40) || "snap";
}
function bridgeWorker(w, msg, matchKind) {
return new Promise((resolve) => {
const handler = (e) => {
if (e.data.kind !== matchKind || e.data.runId !== msg.runId) return;
w.removeEventListener("message", handler);
resolve(e.data);
};
w.addEventListener("message", handler);
w.postMessage(msg);
});
}
// Tier-specific save strategies:
// * c, python — call the tier's (portal-snapshot! NAME) so portal.c /
// lumbda.py's portal_save dumps env+RNG to /tmp/<name>.portal JSON,
// then JS reads the blob out of MEMFS. Faithful reproduction.
// * asm-wat — no portal subsystem in the tier yet (it'd need a
// Cheney-aware serializer). Instead snapshot the transcript and
// replay on restore. Works for the REPL's actual workflow of
// successive defines because every closure rebuilds from source.
async function saveCheckpoint() {
const tab = activeTab();
if (!tab) return;
const tier = tab.tier === "all" ? "c" : tab.tier;
const ts = new Date();
const defaultName = `snap-${ts.getHours().toString().padStart(2, "0")}` +
`${ts.getMinutes().toString().padStart(2, "0")}` +
`${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") {
// Replay mode — capture every successful prior input. Errors
// get dropped because re-running them on restore would just
// crash the tier mid-replay.
const inputs = tab.transcript
.filter((e) => e.kind !== "error")
.map((e) => e.input);
entry = { tier: "asm", mode: "replay", inputs, savedAt: Date.now() };
} else {
// C / Python — portal-snapshot! lands a JSON blob in MEMFS.
try {
await evalInTier(tab.id, tier, `(portal-snapshot! "${name}")`);
} catch (e) {
alert("portal-snapshot! failed: " + e.message);
return;
}
const w = state.workers[workerKey(tab.id, tier)];
if (!w) { alert("worker missing"); return; }
const runId = state.nextRunId++;
const reply = await bridgeWorker(w, { kind: "portal-save", runId, tier, name }, "portal-save");
if (!reply.blob) { alert("portal blob empty — snapshot didn't land in MEMFS"); return; }
entry = { tier, mode: "blob", blob: reply.blob, savedAt: Date.now() };
}
tab.checkpoints = tab.checkpoints || {};
tab.checkpoints[name] = entry;
renderAll();
saveSoon();
}
// 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 = 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) {
tab.tier = cp.tier;
tierSelectEl.value = cp.tier;
renderAll();
}
// Reboot the tier so we start from a clean global env — either
// replay needs it, or portal_resume's "merge into _env" semantics
// would otherwise compound on top of whatever's already defined.
rebootTier(tab.id, cp.tier);
if (cp.mode === "replay") {
// Re-eval every saved input on the fresh worker, in order.
for (const src of (cp.inputs || [])) {
try { await evalInTier(tab.id, cp.tier, src); } catch (e) { /* keep going */ }
}
tab.transcript.push({ input: `; replayed ${cp.inputs.length} inputs from '${name}'`,
results: [{ tier: cp.tier, output: "#t" }], kind: "ok" });
} else {
// Blob mode — hydrate MEMFS, then ask the tier to load it.
try { await evalInTier(tab.id, cp.tier, "'init"); } catch (e) { /* harmless */ }
const w = state.workers[workerKey(tab.id, cp.tier)];
if (!w) { alert("worker missing"); return; }
const runId = state.nextRunId++;
await bridgeWorker(w, { kind: "portal-load", runId, tier: cp.tier, name, blob: cp.blob }, "portal-load");
try {
await evalInTier(tab.id, cp.tier, `(portal-load! "${name}")`);
tab.transcript.push({ input: `(portal-load! "${name}")`,
results: [{ tier: cp.tier, output: "#t" }], kind: "ok" });
} catch (e) {
alert("portal-load! failed: " + e.message);
return;
}
}
renderAll();
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();
}
// Manual pause-and-snapshot — the user wants to inspect or save the
// state of a long-running eval without leaving the tab. Signals the
// SAB pause flag, waits for the in-flight eval to settle, then runs
// saveCheckpoint so the snapshot lands in the visible chip strip
// with a user-chosen name. Workers stay alive so the next input
// runs immediately.
async function pauseAndSnapshot() {
if (!pauseFlagView) {
alert("pause requires COOP/COEP headers — start with `make serve-repl`, or for production set Cross-Origin-Opener-Policy: same-origin + Cross-Origin-Embedder-Policy: require-corp on the deploy");
return;
}
const tab = activeTab();
if (!tab) return;
const tier = tab.tier === "all" ? "c" : tab.tier;
if (tier === "asm") {
alert("manual pause not supported on asm tier yet — use cancel + portal-save when the eval is idle");
return;
}
pauseBtn.disabled = true;
Atomics.store(pauseFlagView, 0, 1);
await waitForPending(tab.id, tier, 2000);
Atomics.store(pauseFlagView, 0, 0);
if (isEvalInFlight(tab)) {
// The worker didn't yield (stuck in sync XHR, native builtin
// without a poll site, etc). Bail out cleanly rather than
// trying to snapshot on a still-busy worker — that would
// queue portal-snapshot! behind the running eval and lock the
// UI until the eval finishes on its own.
alert("pause request timed out — the eval is in code without a yield point. cancel + reboot if you need to stop it.");
return;
}
// Reset activeInput marker so a follow-up tab switch doesn't try
// to "auto-resume" what the user has now explicitly saved.
tab.activeInput = null;
await saveCheckpoint();
}
function deleteCheckpoint(name) {
const tab = activeTab();
if (!tab || !tab.checkpoints) return;
delete tab.checkpoints[name];
renderAll();
saveSoon();
}
function rebootTier(tabId, tier) {
const k = workerKey(tabId, tier);
if (state.workers[k]) {
try { state.workers[k].terminate(); } catch {}
delete state.workers[k];
}
if (state.pending[k]) {
try { state.pending[k].reject(new Error("rebooted")); } catch {}
delete state.pending[k];
}
}
function cancelAllPendingInActiveTab() {
const tab = activeTab();
if (!tab) return;
for (const k of Object.keys(state.pending)) {
if (k.startsWith(tab.id + ":")) {
try { state.pending[k].reject(new Error("cancelled")); } catch {}
try { state.workers[k].terminate(); } catch {}
delete state.workers[k];
delete state.pending[k];
}
}
// Synchronously re-enable send so the user can submit a new job
// immediately, without waiting for the cancelled promise chain's
// microtasks to drain. The .then below also rechecks
// isEvalInFlight so a follow-up sendInput's button state isn't
// clobbered when those microtasks eventually fire.
sendBtn.disabled = false;
cancelBtn.disabled = true;
pauseBtn.disabled = true;
tab.activeInput = null;
}
// ─── Render ─────────────────────────────────────────────────────────
function renderAll() {
// Tabs
tabsEl.innerHTML = "";
for (const t of state.tabs) {
const el = document.createElement("div");
el.className = "tab" + (t.id === state.activeTabId ? " active" : "");
const name = document.createElement("span");
name.textContent = t.name;
name.addEventListener("click", () => setActiveTab(t.id));
name.addEventListener("dblclick", (e) => {
e.stopPropagation();
const newName = prompt("rename tab:", t.name);
if (newName) { t.name = newName.slice(0, 40); renderAll(); saveSoon(); }
});
const close = document.createElement("span");
close.className = "tab-close";
close.textContent = "×";
close.title = "close tab";
close.addEventListener("click", (e) => { e.stopPropagation(); closeTab(t.id); });
el.appendChild(name);
el.appendChild(close);
tabsEl.appendChild(el);
}
// Transcript — terminal-style: prompt line, then output lines underneath
// indented under the prompt with a tier-tag suffix as a Lisp comment.
transcriptEl.innerHTML = "";
const tab = activeTab();
if (!tab) return;
tierSelectEl.value = tab.tier;
// Restore the active tab's saved draft into the textarea so a page
// refresh (or tab switch) lands the user back on the half-written
// form they had before. The guard avoids fighting an active input
// event mid-stream — if the textarea already matches the draft
// there's nothing to do.
const draft = tab.inputDraft || "";
if (inputEl.value !== draft) {
inputEl.value = draft;
autosizeInput();
}
for (const entry of tab.transcript) {
const block = document.createElement("div");
block.className = "entry" + (entry.kind === "error" ? " error" : "");
// Render the input across one or more lines (preserve newlines).
const inputLines = entry.input.split(/\n/);
for (let i = 0; i < inputLines.length; i++) {
const line = document.createElement("div");
line.className = "prompt-line";
const sigil = document.createElement("span");
sigil.className = "sigil";
sigil.textContent = i === 0 ? "λ>" : "..";
line.appendChild(sigil);
line.appendChild(document.createTextNode(inputLines[i]));
block.appendChild(line);
}
for (const r of (entry.results || [])) {
const row = document.createElement("div");
row.className = "tier-output";
const result = document.createElement("span");
result.className = "tier-result";
const text = (r.output != null ? r.output : "");
const errText = r.error ? "error: " + r.error : "";
result.textContent = (text + (errText && text ? "\n" : "") + errText).replace(/\n$/, "");
const meta = document.createElement("span");
meta.className = "tier-meta";
meta.textContent = `${TIER_LABEL[r.tier] || r.tier} · ${(r.elapsed | 0)}ms`;
row.appendChild(result);
row.appendChild(meta);
block.appendChild(row);
}
transcriptEl.appendChild(block);
}
// 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 = "";
// Always lead with "scope:" so the visible text reads as a state
// label rather than a target ("this tab" alone was ambiguous —
// is it telling me the current scope, or what I'd switch to?).
portalScopeBtn.textContent = showGlobalCheckpoints ? "scope: all tabs 🌐" : "scope: this tab 📂";
portalScopeBtn.title = showGlobalCheckpoints
? "showing checkpoints from every tab — click to show only this tab"
: "showing only this tab's checkpoints — click to show 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";
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 = 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);
}
// Body owns the scroll now; jump to the bottom after layout settles.
// requestAnimationFrame lets the just-mounted DOM contribute to
// scrollHeight before we measure — otherwise on first load the page
// sticks at the top because the transcript hasn't been laid out yet.
// renderAll fires on user-initiated actions (send, tab switch, new
// entry) where they expect the most-recent content, so the jump
// here is unconditional and resets scrollPinned to true so streamed
// follow-ups continue to autoscroll.
requestAnimationFrame(() => {
window.scrollTo({ top: document.documentElement.scrollHeight, behavior: "instant" });
scrollPinned = true;
});
}
// ─── Sticky autoscroll ──────────────────────────────────────────────
// During a long streaming eval the page should track the bottom UNLESS
// the user has deliberately scrolled up to read older output. We sample
// "is the viewport at the bottom" on every user scroll, then streamed
// appendText/appendNewline only force the scroll when scrollPinned is
// still true. A 64px tolerance covers fractional-pixel scroll positions
// and short header offsets — anything farther than that is treated as
// "user wanted to look back" and autoscroll stops until they return.
let scrollPinned = true;
const SCROLL_BOTTOM_TOLERANCE = 64;
function isViewportAtBottom() {
return (window.innerHeight + window.scrollY) >=
(document.documentElement.scrollHeight - SCROLL_BOTTOM_TOLERANCE);
}
function maybeAutoscroll() {
if (scrollPinned) {
window.scrollTo({ top: document.documentElement.scrollHeight, behavior: "instant" });
}
}
window.addEventListener("scroll", () => {
scrollPinned = isViewportAtBottom();
}, { passive: true });
// ─── History navigation (readline-style up/down) ────────────────────
// Each tab owns its own history; the active tab's draft is preserved so
// that walking back into history doesn't eat what the user was typing.
const history = { idx: null, draft: "" };
function historyEntries() {
// Transcript's `input` fields, deduplicated against the immediate
// predecessor — pressing up shouldn't make you hit the same line
// twice in a row when you just submitted it.
const tab = activeTab();
if (!tab) return [];
const out = [];
for (const e of tab.transcript) {
if (out.length && out[out.length - 1] === e.input) continue;
out.push(e.input);
}
return out;
}
function historyPrev() {
const entries = historyEntries();
if (entries.length === 0) return;
if (history.idx === null) {
history.draft = inputEl.value;
history.idx = entries.length - 1;
} else if (history.idx > 0) {
history.idx--;
}
inputEl.value = entries[history.idx];
inputEl.setSelectionRange(inputEl.value.length, inputEl.value.length);
autosizeInput();
}
function historyNext() {
const entries = historyEntries();
if (history.idx === null) return;
if (history.idx >= entries.length - 1) {
history.idx = null;
inputEl.value = history.draft;
history.draft = "";
} else {
history.idx++;
inputEl.value = entries[history.idx];
}
inputEl.setSelectionRange(inputEl.value.length, inputEl.value.length);
autosizeInput();
}
function resetHistory() {
history.idx = null;
history.draft = "";
}
// Auto-grow the textarea to fit its content (up to the max-height the
// CSS pins it at, after which the textarea scrolls internally). Paste
// a 40-line program and the prompt-bar expands instead of leaving you
// editing the program through a one-line keyhole. Fires on every input
// event and every programmatic value set (sendInput, history, etc.).
function autosizeInput() {
inputEl.style.height = "auto";
inputEl.style.height = inputEl.scrollHeight + "px";
}
// ─── Input handling ─────────────────────────────────────────────────
async function sendInput() {
const src = inputEl.value.trim();
if (!src) return;
const tab = activeTab();
if (!tab) return;
tab.tier = tierSelectEl.value;
const tiers = tab.tier === "all" ? ["python", "c", "asm"] : [tab.tier];
// Pre-populate placeholder results so the initial renderAll lays
// out the tier-output rows we'll stream into. Each placeholder
// carries output="" and a streaming:true flag so renderAll
// (if invoked later for any reason) renders an empty row rather
// than missing the tier. Real values land via the streaming
// attach below, finalized when each tier's promise resolves.
const entry = {
input: src,
results: tiers.map((t) => ({ tier: t, output: "", streaming: true })),
kind: "ok",
};
tab.transcript.push(entry);
inputEl.value = "";
tab.inputDraft = "";
// Remember the input string while it's evaluating so that auto-
// pause-on-tab-switch can re-fire it on resume. Cleared on completion.
tab.activeInput = src;
autosizeInput();
resetHistory();
sendBtn.disabled = true;
cancelBtn.disabled = false;
pauseBtn.disabled = !pauseFlagView;
renderAll();
saveSoon();
// Grab the DOM rows we just rendered for this entry so streaming
// chunks land directly into them. transcriptEl.lastElementChild
// is the block we just added (entries render in order).
const entryEl = transcriptEl.lastElementChild;
const rowEls = entryEl ? Array.from(entryEl.querySelectorAll(".tier-output")) : [];
const liveRows = {};
for (let i = 0; i < tiers.length && i < rowEls.length; i++) {
const row = rowEls[i];
const resultSpan = row.querySelector(".tier-result");
const metaSpan = row.querySelector(".tier-meta");
if (resultSpan && metaSpan) {
liveRows[tiers[i]] = attachStreaming(resultSpan, metaSpan, tiers[i]);
}
}
const startTimes = {};
for (const t of tiers) startTimes[t] = performance.now();
const promises = tiers.map((t) => {
const live = liveRows[t];
return evalInTier(
tab.id, t, src,
live ? (text) => live.appendText(text) : null,
live ? () => live.appendNewline() : null,
)
.then((output) => ({ tier: t, output, elapsed: performance.now() - startTimes[t] }))
.catch((e) => ({ tier: t, error: e.message, elapsed: performance.now() - startTimes[t] }));
});
let remaining = promises.length;
for (const p of promises) {
p.then((r) => {
// Mutate the placeholder result in place — never push a
// second entry per tier or we'd render twice. Mark
// streaming=false so a later tab switch redraws via the
// normal (full text) path instead of leaving it blank.
const idx = entry.results.findIndex((x) => x.tier === r.tier && x.streaming);
if (idx >= 0) {
entry.results[idx] = {
tier: r.tier,
output: r.output != null ? r.output : "",
error: r.error,
elapsed: r.elapsed,
};
if (r.error) entry.kind = "error";
}
// Update the DOM in place (don't call renderAll — it'd
// clobber sibling tiers still streaming). finalize swaps
// the streaming divs for the canonical text if they
// diverge and writes the elapsed-ms meta.
const live = liveRows[r.tier];
if (live) live.finalize(r);
saveSoon();
remaining--;
if (remaining === 0) {
// Don't clobber the toolbar / activeInput marker if
// another sendInput started while THIS one was still
// unwinding. The cancel-then-submit-immediately race
// used to leave send re-enabled mid-eval because the
// first run's .then fired after the second's sendInput
// had already disabled the buttons. Check the live
// pending state instead of trusting "remaining===0".
if (!isEvalInFlight(tab)) {
sendBtn.disabled = false;
cancelBtn.disabled = true;
pauseBtn.disabled = true;
inputEl.focus();
tab.activeInput = null;
}
}
});
}
}
// ─── Heap pressure indicator ───────────────────────────────────────
// Polls each loaded tier in the active tab and prints a compact
// "py 12M · c 32M · asm 4M" string next to the tabbar buttons.
const heapEl = document.createElement("span");
heapEl.className = "heap-pressure";
heapEl.title = "tier worker memory — \"reboot tier\" reclaims on demand";
function formatBytes(n) {
if (n < 1024) return `${n}B`;
if (n < 1024 * 1024) return `${(n / 1024).toFixed(0)}K`;
return `${(n / (1024 * 1024)).toFixed(1)}M`;
}
async function pollHeap() {
const tab = activeTab();
if (!tab) return;
const tiers = ["python", "c", "asm"];
const parts = [];
for (const t of tiers) {
const k = workerKey(tab.id, t);
const w = state.workers[k];
if (!w) continue;
const runId = ++state.nextRunId;
const stats = await new Promise((resolve) => {
const handler = (e) => {
if (e.data.kind !== "heap" || e.data.runId !== runId) return;
w.removeEventListener("message", handler);
resolve(e.data.stats);
};
w.addEventListener("message", handler);
w.postMessage({ kind: "heap", runId, tier: t });
// Timeout safety — eval-busy workers won't reply.
setTimeout(() => { w.removeEventListener("message", handler); resolve(null); }, 200);
});
if (stats && stats.used != null) {
parts.push(`${t.slice(0, 3)} ${formatBytes(stats.used)}`);
}
}
heapEl.textContent = parts.length ? parts.join(" · ") : "";
}
setInterval(pollHeap, 2000);
// ─── Wire up ────────────────────────────────────────────────────────
unlockBtn.addEventListener("click", tryUnlock);
freshBtn.addEventListener("click", enterEphemeral);
passwordEl.addEventListener("keydown", (e) => {
if (e.key === "Enter") tryUnlock();
});
newTabBtn.addEventListener("click", () => newTab());
resetTierBtn.addEventListener("click", () => {
const tab = activeTab();
if (tab) rebootTier(tab.id, tab.tier);
});
clearLogBtn.addEventListener("click", () => {
const tab = activeTab();
if (tab) { tab.transcript = []; renderAll(); saveSoon(); }
});
cancelBtn.addEventListener("click", cancelAllPendingInActiveTab);
pauseBtn.addEventListener("click", pauseAndSnapshot);
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);
inputEl.addEventListener("keydown", (e) => {
if (e.key === "Enter" && !e.shiftKey) {
e.preventDefault();
sendInput();
return;
}
// Up/Down navigate per-tab history when the caret is in the only
// (or first/last) row — otherwise they belong to the textarea's
// natural multi-line navigation.
if (e.key === "ArrowUp") {
const before = inputEl.value.substring(0, inputEl.selectionStart);
if (!before.includes("\n")) {
e.preventDefault();
historyPrev();
}
return;
}
if (e.key === "ArrowDown") {
const after = inputEl.value.substring(inputEl.selectionStart);
if (!after.includes("\n")) {
e.preventDefault();
historyNext();
}
return;
}
});
inputEl.addEventListener("input", () => {
autosizeInput();
// Persist the in-progress input so a refresh, lock/unlock, or tab
// switch lands the user back where they were. saveSoon debounces
// to 400ms so paste storms don't thrash the vault writer.
const tab = activeTab();
if (tab) {
tab.inputDraft = inputEl.value;
saveSoon();
}
// Any keystroke that isn't an arrow drops history navigation —
// edits are now the user's own draft, not the historical entry.
if (history.idx !== null && document.activeElement === inputEl) {
// We can't reliably distinguish arrow-induced updates here, so
// we only invalidate when the buffer has actually diverged from
// the historical entry the cursor was on.
const entries = historyEntries();
if (entries[history.idx] !== inputEl.value) {
history.draft = inputEl.value;
history.idx = null;
}
}
});
sendBtn.addEventListener("click", sendInput);
tierSelectEl.addEventListener("change", () => {
const tab = activeTab();
if (tab) { tab.tier = tierSelectEl.value; saveSoon(); }
});