From 26e0b8a7869bc98ba1e8f7ef3cc10d0386139de3 Mon Sep 17 00:00:00 2001 From: Paul Bakaus Date: Sun, 12 Apr 2026 18:13:52 -0700 Subject: [PATCH] Persist live session state in localStorage, fix HTML hot-reload Two fixes: 1. Session persistence via localStorage: the previous DOM-attribute approach (data-impeccable-handled) didn't survive page reloads because the DOM is rebuilt from source. Now using localStorage: - Session state (id, action, count, arrived, visible variant) saved on every state change - Handled sessions (accepted/discarded) tracked separately - resumeSession() checks localStorage before resuming, skips if the session was already handled - Visible variant index preserved across reloads (user sees the same variant they were looking at) - cleanup() clears session, clearHandled() clears on next load when the wrapper is gone from source 2. Dev server HTML hot-reload: replaced static Bun HTML imports (import homepage from "../public/index.html") with dynamic file() serving via the existing serveGenerated() helper. HTML edits are now reflected on browser refresh without restarting the dev server. This matches how sub-pages already work. Co-Authored-By: Claude Opus 4.6 (1M context) --- server/index.js | 6 ++-- src/live/browser.js | 87 +++++++++++++++++++++++++++++++++++++++------ 2 files changed, 79 insertions(+), 14 deletions(-) diff --git a/server/index.js b/server/index.js index 0e0327167..64d80dfcb 100644 --- a/server/index.js +++ b/server/index.js @@ -1,8 +1,6 @@ import { serve, file } from "bun"; import path from "node:path"; import { fileURLToPath } from "node:url"; -import homepage from "../public/index.html"; -import privacy from "../public/privacy.html"; import { getSkills, getCommands, @@ -39,8 +37,8 @@ const server = serve({ port: process.env.PORT || 3000, routes: { - "/": homepage, - "/privacy": privacy, + "/": () => serveGenerated(path.join(ROOT_DIR, "public/index.html")), + "/privacy": () => serveGenerated(path.join(ROOT_DIR, "public/privacy.html")), // Legacy URL redirects (kept stable for external links and existing users). "/cheatsheet": Response.redirect("/docs", 301), diff --git a/src/live/browser.js b/src/live/browser.js index 223bd36a0..f5a99ac47 100644 --- a/src/live/browser.js +++ b/src/live/browser.js @@ -643,9 +643,9 @@ if (next < 1 || next > arrivedVariants) return; visibleVariant = next; showVariantInDOM(currentSessionId, next); - // Update selectedElement to the newly visible variant's content updateSelectedElement(); updateBarContent('cycling'); + saveSession(); } function updateSelectedElement() { @@ -705,6 +705,7 @@ } else if (state === 'GENERATING') { updateBarContent('generating'); } + saveSession(); updating = false; }); @@ -868,6 +869,7 @@ state = 'GENERATING'; showBar('generating'); + saveSession(); if (variantObserver) variantObserver.disconnect(); variantObserver = startVariantObserver(currentSessionId); } @@ -875,20 +877,77 @@ function handleAccept() { if (!currentSessionId || arrivedVariants === 0) return; sendWS({ type: 'accept', id: currentSessionId, variantId: String(visibleVariant) }); + // Mark the wrapper so resumeSession won't pick it up after reload + markSessionHandled(); cleanup(); } function handleDiscard() { if (!currentSessionId) return; sendWS({ type: 'discard', id: currentSessionId }); + markSessionHandled(); cleanup(); } + // --------------------------------------------------------------------------- + // Session persistence via localStorage + // --------------------------------------------------------------------------- + // Survives page reloads, browser close/reopen, HMR, and accidental refreshes. + + const LS_KEY = PREFIX + '-session'; + + function saveSession() { + if (!currentSessionId) return; + try { + localStorage.setItem(LS_KEY, JSON.stringify({ + id: currentSessionId, + state: state, + action: selectedAction, + count: selectedCount, + expected: expectedVariants, + arrived: arrivedVariants, + visible: visibleVariant, + })); + } catch { /* quota exceeded or private mode */ } + } + + function loadSession() { + try { + const raw = localStorage.getItem(LS_KEY); + return raw ? JSON.parse(raw) : null; + } catch { return null; } + } + + function clearSession() { + try { localStorage.removeItem(LS_KEY); } catch {} + } + + /** Mark session as handled (accepted/discarded). The agent will clean up + * the source, but until it does the wrapper is still in the HTML. This + * prevents resumeSession from picking it up again after reload. */ + function markSessionHandled() { + if (!currentSessionId) return; + try { + localStorage.setItem(LS_KEY + '-handled', currentSessionId); + } catch {} + } + + function isSessionHandled(id) { + try { + return localStorage.getItem(LS_KEY + '-handled') === id; + } catch { return false; } + } + + function clearHandled() { + try { localStorage.removeItem(LS_KEY + '-handled'); } catch {} + } + function cleanup() { hideBar(); hideHighlight(); stopScrollTracking(); if (variantObserver) { variantObserver.disconnect(); variantObserver = null; } + clearSession(); selectedElement = null; currentSessionId = null; selectedAction = 'impeccable'; @@ -936,26 +995,34 @@ // variants before HMR fired. Pick up where we left off. function resumeSession() { const wrapper = document.querySelector('[data-impeccable-variants]'); - if (!wrapper) return false; + if (!wrapper) { clearSession(); clearHandled(); return false; } - currentSessionId = wrapper.dataset.impeccableVariants; + const sessionId = wrapper.dataset.impeccableVariants; + + // Don't resume if this session was already accepted/discarded + if (isSessionHandled(sessionId)) return false; + + currentSessionId = sessionId; expectedVariants = parseInt(wrapper.dataset.impeccableVariantCount || '0'); const variants = wrapper.querySelectorAll('[data-impeccable-variant]:not([data-impeccable-variant="original"])'); arrivedVariants = variants.length; - visibleVariant = arrivedVariants > 0 ? 1 : 0; - // Find the visible variant's content element for highlight positioning. - // The wrapper has display:contents (no box), so we target the actual - // content element inside the currently visible variant. - const visEl = wrapper.querySelector('[data-impeccable-variant]:not([style*="display: none"]):not([data-impeccable-variant="original"])'); - selectedElement = (visEl && visEl.firstElementChild) || visEl || wrapper.parentElement; + // Restore visible variant from localStorage if available, else default to 1 + const saved = loadSession(); + visibleVariant = (saved && saved.id === sessionId && saved.visible > 0 && saved.visible <= arrivedVariants) + ? saved.visible : (arrivedVariants > 0 ? 1 : 0); + + // Find the visible variant's content element for highlight positioning + const visEl = wrapper.querySelector('[data-impeccable-variant="' + visibleVariant + '"] > :first-child'); + selectedElement = visEl || wrapper.parentElement; // Set display state BEFORE starting observer (avoid triggering it) - if (arrivedVariants > 0) showVariantInDOM(currentSessionId, 1); + if (visibleVariant > 0) showVariantInDOM(currentSessionId, visibleVariant); state = arrivedVariants >= expectedVariants ? 'CYCLING' : 'GENERATING'; showBar(state === 'CYCLING' ? 'cycling' : 'generating'); startScrollTracking(); + saveSession(); // Start observing for more variants AFTER initial setup if (variantObserver) variantObserver.disconnect();