diff --git a/wasm/repl/repl.js b/wasm/repl/repl.js index 088dd26..da5f681 100644 --- a/wasm/repl/repl.js +++ b/wasm/repl/repl.js @@ -28,6 +28,9 @@ 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 portalChipsEl = document.getElementById("portal-chips"); // ─── State ────────────────────────────────────────────────────────── const state = { @@ -174,7 +177,7 @@ function ensureWorker(tabId, tier) { return state.workers[k]; } -function evalInTier(tabId, tier, src) { +function evalInTier(tabId, tier, src, onChunkText, onChunkEol) { return new Promise((resolve, reject) => { const w = ensureWorker(tabId, tier); const runId = state.nextRunId++; @@ -182,7 +185,11 @@ function evalInTier(tabId, tier, src) { state.pending[pendingKey] = { runId, reject }; const handler = (e) => { if (e.data.runId !== runId) return; - if (e.data.kind === "done") { + 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); delete state.pending[pendingKey]; resolve(e.data.output); @@ -197,6 +204,185 @@ function evalInTier(tabId, 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 's inline default. +function attachStreaming(resultSpan, metaSpan, tier) { + const makeLine = () => { + const d = document.createElement("div"); + d.style.display = "block"; + d.style.whiteSpace = "pre"; + return d; + }; + let pendingLine = makeLine(); + let pendingText = ""; + resultSpan.textContent = ""; + resultSpan.appendChild(pendingLine); + metaSpan.textContent = `${TIER_LABEL[tier] || tier} · running…`; + return { + appendText(t) { + if (!t) return; + pendingText += t; + pendingLine.textContent = pendingText; + }, + appendNewline() { + pendingLine.textContent = pendingText || " "; + pendingText = ""; + pendingLine = makeLine(); + resultSpan.appendChild(pendingLine); + }, + finalize(r) { + const text = r.output != null ? r.output : ""; + const errText = r.error ? "error: " + r.error : ""; + const streamedText = Array.from(resultSpan.children) + .map((d) => d.textContent === " " ? "" : d.textContent) + .join("\n"); + const expected = text.replace(/\n$/, ""); + // If the streamed text doesn't match the full eval output + // (e.g. an error fired without intermediate chunks), nuke + // and rebuild from the full text. Otherwise leave the + // streamed divs as-is — they already match. + if (streamedText !== expected || errText) { + resultSpan.textContent = ""; + const combined = (text + (errText && text ? "\n" : "") + errText).replace(/\n$/, ""); + const lines = combined.split("\n"); + for (const ln of lines) { + const d = makeLine(); + d.textContent = ln || " "; + resultSpan.appendChild(d); + } + } + 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/.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; + + 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(); +} + +async function restoreCheckpoint(name) { + const tab = activeTab(); + if (!tab) return; + const cp = 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 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]) { @@ -287,6 +473,28 @@ function renderAll() { } transcriptEl.appendChild(block); } + + // Portal chips — one per saved checkpoint in the active tab. + portalChipsEl.innerHTML = ""; + const checkpoints = tab.checkpoints || {}; + const names = Object.keys(checkpoints).sort(); + for (const name of names) { + 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 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); + 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 @@ -355,7 +563,18 @@ async function sendInput() { if (!tab) return; tab.tier = tierSelectEl.value; const tiers = tab.tier === "all" ? ["python", "c", "asm"] : [tab.tier]; - const entry = { input: src, results: [], kind: "ok" }; + + // 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 = ""; resetHistory(); @@ -364,20 +583,58 @@ async function sendInput() { 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) => - evalInTier(tab.id, t, src) + 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] }))); - // Append results as they arrive so user sees them in race order. + .catch((e) => ({ tier: t, error: e.message, elapsed: performance.now() - startTimes[t] })); + }); + let remaining = promises.length; for (const p of promises) { p.then((r) => { - entry.results.push(r); - if (r.error) entry.kind = "error"; - renderAll(); + // 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) { @@ -448,6 +705,7 @@ clearLogBtn.addEventListener("click", () => { if (tab) { tab.transcript = []; renderAll(); saveSoon(); } }); cancelBtn.addEventListener("click", cancelAllPendingInActiveTab); +portalSaveBtn.addEventListener("click", saveCheckpoint); lockBtn.addEventListener("click", relock); // Insert heap pressure indicator into the tabbar after the spacer. document.getElementById("tabbar").insertBefore(heapEl, resetTierBtn); diff --git a/www/repl/repl.js b/www/repl/repl.js index 088dd26..da5f681 100644 --- a/www/repl/repl.js +++ b/www/repl/repl.js @@ -28,6 +28,9 @@ 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 portalChipsEl = document.getElementById("portal-chips"); // ─── State ────────────────────────────────────────────────────────── const state = { @@ -174,7 +177,7 @@ function ensureWorker(tabId, tier) { return state.workers[k]; } -function evalInTier(tabId, tier, src) { +function evalInTier(tabId, tier, src, onChunkText, onChunkEol) { return new Promise((resolve, reject) => { const w = ensureWorker(tabId, tier); const runId = state.nextRunId++; @@ -182,7 +185,11 @@ function evalInTier(tabId, tier, src) { state.pending[pendingKey] = { runId, reject }; const handler = (e) => { if (e.data.runId !== runId) return; - if (e.data.kind === "done") { + 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); delete state.pending[pendingKey]; resolve(e.data.output); @@ -197,6 +204,185 @@ function evalInTier(tabId, 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 's inline default. +function attachStreaming(resultSpan, metaSpan, tier) { + const makeLine = () => { + const d = document.createElement("div"); + d.style.display = "block"; + d.style.whiteSpace = "pre"; + return d; + }; + let pendingLine = makeLine(); + let pendingText = ""; + resultSpan.textContent = ""; + resultSpan.appendChild(pendingLine); + metaSpan.textContent = `${TIER_LABEL[tier] || tier} · running…`; + return { + appendText(t) { + if (!t) return; + pendingText += t; + pendingLine.textContent = pendingText; + }, + appendNewline() { + pendingLine.textContent = pendingText || " "; + pendingText = ""; + pendingLine = makeLine(); + resultSpan.appendChild(pendingLine); + }, + finalize(r) { + const text = r.output != null ? r.output : ""; + const errText = r.error ? "error: " + r.error : ""; + const streamedText = Array.from(resultSpan.children) + .map((d) => d.textContent === " " ? "" : d.textContent) + .join("\n"); + const expected = text.replace(/\n$/, ""); + // If the streamed text doesn't match the full eval output + // (e.g. an error fired without intermediate chunks), nuke + // and rebuild from the full text. Otherwise leave the + // streamed divs as-is — they already match. + if (streamedText !== expected || errText) { + resultSpan.textContent = ""; + const combined = (text + (errText && text ? "\n" : "") + errText).replace(/\n$/, ""); + const lines = combined.split("\n"); + for (const ln of lines) { + const d = makeLine(); + d.textContent = ln || " "; + resultSpan.appendChild(d); + } + } + 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/.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; + + 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(); +} + +async function restoreCheckpoint(name) { + const tab = activeTab(); + if (!tab) return; + const cp = 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 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]) { @@ -287,6 +473,28 @@ function renderAll() { } transcriptEl.appendChild(block); } + + // Portal chips — one per saved checkpoint in the active tab. + portalChipsEl.innerHTML = ""; + const checkpoints = tab.checkpoints || {}; + const names = Object.keys(checkpoints).sort(); + for (const name of names) { + 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 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); + 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 @@ -355,7 +563,18 @@ async function sendInput() { if (!tab) return; tab.tier = tierSelectEl.value; const tiers = tab.tier === "all" ? ["python", "c", "asm"] : [tab.tier]; - const entry = { input: src, results: [], kind: "ok" }; + + // 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 = ""; resetHistory(); @@ -364,20 +583,58 @@ async function sendInput() { 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) => - evalInTier(tab.id, t, src) + 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] }))); - // Append results as they arrive so user sees them in race order. + .catch((e) => ({ tier: t, error: e.message, elapsed: performance.now() - startTimes[t] })); + }); + let remaining = promises.length; for (const p of promises) { p.then((r) => { - entry.results.push(r); - if (r.error) entry.kind = "error"; - renderAll(); + // 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) { @@ -448,6 +705,7 @@ clearLogBtn.addEventListener("click", () => { if (tab) { tab.transcript = []; renderAll(); saveSoon(); } }); cancelBtn.addEventListener("click", cancelAllPendingInActiveTab); +portalSaveBtn.addEventListener("click", saveCheckpoint); lockBtn.addEventListener("click", relock); // Insert heap pressure indicator into the tabbar after the spacer. document.getElementById("tabbar").insertBefore(heapEl, resetTierBtn);