Fix 5 bugs from real-world live mode testing

1. Skill reference: poll should run as background task with no timeout.
   Changed "blocking poll loop" to "background task, no timeout" so the
   agent keeps the main conversation free for other work.

2. Resume restores selectedAction from localStorage: the bar was showing
   "Freeform" after page reload even when the user picked "Bolder". Also
   improved selectedElement targeting to prefer the visible variant's
   content over the wrapper parent.

3. Discard no longer shows "Applying variant...": accept shows the
   saving→confirmed flow, but discard now dismisses immediately and
   cleans up the DOM. Different intent, different UX.

4. Picker works after discard: cleanup() now removes the variant wrapper
   from the live DOM and restores the original element. Previously the
   stale wrapper with data-impeccable-variant attributes confused the
   picker's isPickable/own checks.

5. Stop live mode: added "Stopping Live Mode" section to the skill
   reference. The user can say "stop live mode" in the conversation, and
   the agent proceeds to cleanup (remove script tag, stop server).

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Paul Bakaus
2026-04-13 10:04:31 -07:00
co-authored by Claude Opus 4.6
parent 90d5158331
commit 52b050bb7e
25 changed files with 602 additions and 169 deletions
+14 -4
View File
@@ -3,7 +3,6 @@ Launch interactive live variant mode: select elements in the browser, pick a des
## Prerequisites
- A running development server with hot module replacement (Vite, Next.js, Bun, etc.), OR a static HTML file open in the browser
- The impeccable CLI installed (`npm i -g impeccable`)
## Start the Server
@@ -49,12 +48,12 @@ If browser automation tools are available, also navigate to the page so the user
## Enter the Poll Loop
Run a blocking poll loop. On each iteration, wait for a browser event and respond:
Run the poll as a **background task** if your harness supports it (Claude Code does). This keeps the main conversation free for other work while waiting for browser events. Do NOT set a timeout: the poll should wait indefinitely until the user acts.
```
LOOP:
Run: node {{scripts_path}}/live-poll.mjs
Read the JSON output. Dispatch based on the "type" field:
Run (background, no timeout): node {{scripts_path}}/live-poll.mjs
When the task completes, read the JSON output. Dispatch based on the "type" field:
TYPE "generate":
→ See "Handle Generate" below
@@ -170,6 +169,17 @@ The event contains: `{id}`.
node {{scripts_path}}/live-poll.mjs --reply SESSION_ID done
```
## Stopping Live Mode
The user can stop live mode in several ways:
- Saying "stop live mode" or "exit live" in the conversation
- Closing the browser tab (the SSE connection drops, poll returns `exit` after 8s)
- The browser's exit button (when the global bar is implemented)
When the user asks to stop, or the poll returns `exit`, proceed to Cleanup below.
If the poll is still running as a background task, kill it and proceed directly to cleanup.
## Cleanup (on exit)
When the loop ends:
@@ -1076,9 +1076,8 @@
if (!currentSessionId) return;
sendEvent({ type: 'discard', id: currentSessionId });
markSessionHandled();
state = 'SAVING';
updateBarContent('saving');
// Wait for "done" WS message to show confirmation and dismiss
// Discard dismisses immediately (no "Applying" state, the agent just cleans up)
cleanup();
}
// ---------------------------------------------------------------------------
@@ -1135,6 +1134,26 @@
}
function cleanup() {
// Remove any leftover variant wrapper from the live DOM.
// After discard, the agent cleans the source, but on dev servers without
// HMR the DOM still has the old wrapper, which confuses the picker.
if (currentSessionId) {
const wrapper = document.querySelector('[data-impeccable-variants="' + currentSessionId + '"]');
if (wrapper) {
// Restore the original element into the DOM
const orig = wrapper.querySelector('[data-impeccable-variant="original"]');
if (orig) {
const content = orig.firstElementChild;
if (content) {
wrapper.parentElement.replaceChild(content, wrapper);
} else {
wrapper.remove();
}
} else {
wrapper.remove();
}
}
}
hideBar();
hideHighlight();
stopScrollTracking();
@@ -1199,14 +1218,21 @@
const variants = wrapper.querySelectorAll('[data-impeccable-variant]:not([data-impeccable-variant="original"])');
arrivedVariants = variants.length;
// Restore visible variant from localStorage if available, else default to 1
// Restore state from localStorage if available
const saved = loadSession();
visibleVariant = (saved && saved.id === sessionId && saved.visible > 0 && saved.visible <= arrivedVariants)
? saved.visible : (arrivedVariants > 0 ? 1 : 0);
if (saved && saved.id === sessionId) {
visibleVariant = (saved.visible > 0 && saved.visible <= arrivedVariants) ? saved.visible : (arrivedVariants > 0 ? 1 : 0);
if (saved.action) selectedAction = saved.action;
if (saved.count) selectedCount = saved.count;
} else {
visibleVariant = arrivedVariants > 0 ? 1 : 0;
}
// Find the visible variant's content element for highlight positioning
// Find the visible variant's content element for highlight positioning.
// Try the visible variant first, fall back to the original's content.
const visEl = wrapper.querySelector('[data-impeccable-variant="' + visibleVariant + '"] > :first-child');
selectedElement = visEl || wrapper.parentElement;
const origEl = wrapper.querySelector('[data-impeccable-variant="original"] > :first-child');
selectedElement = visEl || origEl || wrapper.parentElement;
// Set display state BEFORE starting observer (avoid triggering it)
if (visibleVariant > 0) showVariantInDOM(currentSessionId, visibleVariant);