From 5de290c662b2ad00eb981572ed7e9af2ad5c5696 Mon Sep 17 00:00:00 2001 From: "russell@unturf.com" Date: Mon, 15 Jun 2026 07:56:37 -0400 Subject: [PATCH] =?UTF-8?q?repl:=20persist=20input=20draft=20per-tab=20?= =?UTF-8?q?=E2=80=94=20recover=20on=20refresh?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds an inputDraft field to each tab and writes the textarea contents through saveSoon on every input event (paste, type, autocomplete). renderAll restores the draft into the textarea when a tab becomes active, so refresh-after-half-typing or switching between tabs and back lands the user back on what they were composing. Plumbing: * newTab() seeds inputDraft: "" * setActiveTab() snapshots the outgoing tab's textarea value before swapping (covers the gap between last input event and next saveSoon flush) * renderAll() writes the active tab's draft back into the textarea (+ autosizeInput so a 40-line recovered draft expands to fit) * sendInput() clears tab.inputDraft alongside inputEl.value so the vault next snapshot doesn't preserve an already-committed entry Smoke-tested: type a 5-line draft, fresh browser context with same vault password reopens to the same 5 lines + same 82px height. Per-tab isolation verified — switching tabs preserves each tab's own draft. Send empties the draft; subsequent reload shows empty input. --- wasm/dist-repl/repl.js | 27 ++++++++++++++++++++++++++- wasm/repl/repl.js | 27 ++++++++++++++++++++++++++- www/repl/repl.js | 27 ++++++++++++++++++++++++++- 3 files changed, 78 insertions(+), 3 deletions(-) diff --git a/wasm/dist-repl/repl.js b/wasm/dist-repl/repl.js index 623a2ce..948ab10 100644 --- a/wasm/dist-repl/repl.js +++ b/wasm/dist-repl/repl.js @@ -127,7 +127,7 @@ function relock() { function newTab(name) { const id = state.nextTabId++; name = name || `session ${id}`; - state.tabs.push({ id, name, tier: "c", transcript: [] }); + state.tabs.push({ id, name, tier: "c", transcript: [], inputDraft: "" }); state.activeTabId = id; renderAll(); saveSoon(); @@ -153,6 +153,12 @@ function closeTab(id) { } function setActiveTab(id) { + // 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; state.activeTabId = id; renderAll(); saveSoon(); @@ -439,6 +445,16 @@ function renderAll() { 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" : ""); @@ -589,6 +605,7 @@ async function sendInput() { }; tab.transcript.push(entry); inputEl.value = ""; + tab.inputDraft = ""; autosizeInput(); resetHistory(); sendBtn.disabled = true; @@ -751,6 +768,14 @@ inputEl.addEventListener("keydown", (e) => { }); 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) { diff --git a/wasm/repl/repl.js b/wasm/repl/repl.js index 623a2ce..948ab10 100644 --- a/wasm/repl/repl.js +++ b/wasm/repl/repl.js @@ -127,7 +127,7 @@ function relock() { function newTab(name) { const id = state.nextTabId++; name = name || `session ${id}`; - state.tabs.push({ id, name, tier: "c", transcript: [] }); + state.tabs.push({ id, name, tier: "c", transcript: [], inputDraft: "" }); state.activeTabId = id; renderAll(); saveSoon(); @@ -153,6 +153,12 @@ function closeTab(id) { } function setActiveTab(id) { + // 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; state.activeTabId = id; renderAll(); saveSoon(); @@ -439,6 +445,16 @@ function renderAll() { 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" : ""); @@ -589,6 +605,7 @@ async function sendInput() { }; tab.transcript.push(entry); inputEl.value = ""; + tab.inputDraft = ""; autosizeInput(); resetHistory(); sendBtn.disabled = true; @@ -751,6 +768,14 @@ inputEl.addEventListener("keydown", (e) => { }); 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) { diff --git a/www/repl/repl.js b/www/repl/repl.js index 623a2ce..948ab10 100644 --- a/www/repl/repl.js +++ b/www/repl/repl.js @@ -127,7 +127,7 @@ function relock() { function newTab(name) { const id = state.nextTabId++; name = name || `session ${id}`; - state.tabs.push({ id, name, tier: "c", transcript: [] }); + state.tabs.push({ id, name, tier: "c", transcript: [], inputDraft: "" }); state.activeTabId = id; renderAll(); saveSoon(); @@ -153,6 +153,12 @@ function closeTab(id) { } function setActiveTab(id) { + // 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; state.activeTabId = id; renderAll(); saveSoon(); @@ -439,6 +445,16 @@ function renderAll() { 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" : ""); @@ -589,6 +605,7 @@ async function sendInput() { }; tab.transcript.push(entry); inputEl.value = ""; + tab.inputDraft = ""; autosizeInput(); resetHistory(); sendBtn.disabled = true; @@ -751,6 +768,14 @@ inputEl.addEventListener("keydown", (e) => { }); 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) {