drawer: sync visual element picker from astroagent

Pick an element on the rendered site, comment, and the agent edits the
source. Includes the discoverability fixes (SVG crosshair, pick-mode
banner) and publish auto-reload.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RCfiiNfHmDRj4ZZiFM535z
This commit is contained in:
Carlos Arias 2026-07-05 00:42:17 +00:00
parent f41fa652ac
commit d085d26c78

View file

@ -7,9 +7,10 @@
* hiding is only UX. Unlock once by visiting any page with ?devkey=<TOKEN>. * hiding is only UX. Unlock once by visiting any page with ?devkey=<TOKEN>.
*/ */
const route = "/devconsole"; const route = "/devconsole";
const previewRoute = "/_preview";
--- ---
<div id="aa-console" data-route={route} hidden> <div id="aa-console" data-route={route} data-preview-route={previewRoute} hidden>
<button id="aa-handle" type="button" aria-label="Open developer console">▲ astroagent</button> <button id="aa-handle" type="button" aria-label="Open developer console">▲ astroagent</button>
<section id="aa-panel" hidden aria-label="Developer console"> <section id="aa-panel" hidden aria-label="Developer console">
<header> <header>
@ -25,12 +26,18 @@ const route = "/devconsole";
<a id="aa-preview" href="#" target="_blank" rel="noopener">Open preview ↗</a> <a id="aa-preview" href="#" target="_blank" rel="noopener">Open preview ↗</a>
<button id="aa-publish" type="button">Publish</button> <button id="aa-publish" type="button">Publish</button>
</div> </div>
<div id="aa-picks" hidden></div>
<form id="aa-form"> <form id="aa-form">
<button id="aa-pick" type="button" aria-label="Select an element on the page" title="Select an element on the page (Esc to cancel)">
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" aria-hidden="true"><circle cx="12" cy="12" r="6.5"></circle><path d="M12 1.5v4M12 18.5v4M1.5 12h4M18.5 12h4"></path></svg>
</button>
<textarea id="aa-prompt" rows="2" placeholder="Ask or change anything — “what pages do I have?”, “make the buttons green”, then “now make them bigger”"></textarea> <textarea id="aa-prompt" rows="2" placeholder="Ask or change anything — “what pages do I have?”, “make the buttons green”, then “now make them bigger”"></textarea>
<button id="aa-run" type="submit">Send</button> <button id="aa-run" type="submit">Send</button>
</form> </form>
</section> </section>
<div id="aa-hl" hidden><span id="aa-hl-tag"></span></div>
<div id="aa-authmodal" hidden> <div id="aa-authmodal" hidden>
<div class="aa-modal"> <div class="aa-modal">
<div class="aa-modal-h">Agent sign-in required</div> <div class="aa-modal-h">Agent sign-in required</div>
@ -68,8 +75,22 @@ const route = "/devconsole";
#aa-actions a { color:#6cb6ff; font-size:12px; text-decoration:none; margin-right:auto; } #aa-actions a { color:#6cb6ff; font-size:12px; text-decoration:none; margin-right:auto; }
#aa-form { display:flex; gap:8px; padding:10px; border-top:1px solid #2a2a2a; } #aa-form { display:flex; gap:8px; padding:10px; border-top:1px solid #2a2a2a; }
#aa-prompt { flex:1; resize:none; background:#141416; color:#e6e6e6; border:1px solid #2a2a2a; border-radius:6px; padding:8px; font:inherit; font-size:12px; } #aa-prompt { flex:1; resize:none; background:#141416; color:#e6e6e6; border:1px solid #2a2a2a; border-radius:6px; padding:8px; font:inherit; font-size:12px; }
#aa-panel button:not(#aa-close):not(#aa-lock):not(#aa-key):not(#aa-new) { background:#238636; color:#fff; border:none; border-radius:6px; padding:8px 14px; font-size:12px; cursor:pointer; } #aa-panel button:not(#aa-close):not(#aa-lock):not(#aa-key):not(#aa-new):not(#aa-pick):not(.aa-x) { background:#238636; color:#fff; border:none; border-radius:6px; padding:8px 14px; font-size:12px; cursor:pointer; }
#aa-panel button:disabled { opacity:.5; cursor:default; } #aa-panel button:disabled { opacity:.5; cursor:default; }
#aa-pick { display:flex; align-items:center; justify-content:center; background:#141416; color:#8a8a8a; border:1px solid #2a2a2a; border-radius:6px; padding:0 11px; cursor:pointer; }
#aa-pick:hover { color:#fff; border-color:#3a3a3a; }
#aa-pick.on { background:#1f6feb; color:#fff; border-color:#1f6feb; }
#aa-handle.aa-picking { background:#1f6feb; border-color:#1f6feb; color:#fff; animation:aa-pulse 1.6s ease-in-out infinite; }
@keyframes aa-pulse { 0%,100% { box-shadow:0 4px 16px rgba(31,111,235,.35); } 50% { box-shadow:0 4px 24px rgba(31,111,235,.8); } }
@media (prefers-reduced-motion: reduce) { #aa-handle.aa-picking { animation:none; } }
#aa-picks { display:flex; flex-direction:column; gap:6px; padding:8px 10px; border-top:1px solid #2a2a2a; max-height:130px; overflow:auto; }
.aa-chip { display:flex; align-items:center; gap:8px; background:#161b22; border:1px solid #2a2a2a; border-radius:7px; padding:6px 8px; }
.aa-chip-tag { color:#6cb6ff; font-size:11px; white-space:nowrap; max-width:150px; overflow:hidden; text-overflow:ellipsis; flex-shrink:0; }
.aa-chip input { flex:1; background:#0c0c0d; color:#e6e6e6; border:1px solid #2a2a2a; border-radius:5px; padding:5px 7px; font:inherit; font-size:11px; min-width:0; }
.aa-x { background:none; border:none; color:#8a8a8a; cursor:pointer; font-size:13px; padding:0 3px; flex-shrink:0; }
.aa-x:hover { color:#ff7b72; }
#aa-hl { position:fixed; pointer-events:none; z-index:2147482998; background:rgba(31,111,235,.14); border:1.5px solid rgba(31,111,235,.9); border-radius:3px; }
#aa-hl-tag { position:absolute; top:-20px; left:-1.5px; background:#1f6feb; color:#fff; font-size:10px; font-family:ui-monospace, Menlo, monospace; padding:2px 6px; border-radius:3px; white-space:nowrap; }
#aa-authmodal { position:fixed; inset:0; background:rgba(0,0,0,.55); display:flex; align-items:center; justify-content:center; z-index:2147483001; } #aa-authmodal { position:fixed; inset:0; background:rgba(0,0,0,.55); display:flex; align-items:center; justify-content:center; z-index:2147483001; }
#aa-authmodal .aa-modal { background:#141416; color:#e6e6e6; border:1px solid #2a2a2a; border-radius:10px; padding:18px; width:min(440px, calc(100vw - 32px)); box-shadow:0 12px 40px rgba(0,0,0,.6); } #aa-authmodal .aa-modal { background:#141416; color:#e6e6e6; border:1px solid #2a2a2a; border-radius:10px; padding:18px; width:min(440px, calc(100vw - 32px)); box-shadow:0 12px 40px rgba(0,0,0,.6); }
.aa-modal-h { font-weight:600; color:#fff; margin-bottom:6px; } .aa-modal-h { font-weight:600; color:#fff; margin-bottom:6px; }
@ -116,7 +137,7 @@ const route = "/devconsole";
const scroll = () => { chat.scrollTop = chat.scrollHeight; }; const scroll = () => { chat.scrollTop = chat.scrollHeight; };
const setState = (s) => (stateEl.textContent = s || ""); const setState = (s) => (stateEl.textContent = s || "");
const busy = (b) => { running = b; runBtn.disabled = b; }; const busy = (b) => { running = b; runBtn.disabled = b; pickBtn.disabled = b; };
function bubble(cls) { const d = document.createElement("div"); d.className = "aa-msg " + cls; chat.appendChild(d); scroll(); return d; } function bubble(cls) { const d = document.createElement("div"); d.className = "aa-msg " + cls; chat.appendChild(d); scroll(); return d; }
function userMsg(t) { const d = bubble("aa-user"); d.textContent = t; } function userMsg(t) { const d = bubble("aa-user"); d.textContent = t; }
@ -143,7 +164,17 @@ const route = "/devconsole";
sysMsg("✓ preview updated", true); sysMsg("✓ preview updated", true);
break; break;
case "turn_end": setState(""); busy(false); bot = null; break; case "turn_end": setState(""); busy(false); bot = null; break;
case "published": sysMsg("✓ published — " + (ev.commit || "").slice(0,8) + " (live)", true); actions.hidden = true; break; case "published":
sysMsg("✓ published — " + (ev.commit || "").slice(0,8) + " (live)", true); actions.hidden = true;
sysMsg("reloading to show the live site…");
// The published event fires after the prod build, so the live site is
// ready. From a preview page, go to the live equivalent of this route.
setTimeout(() => {
const live = stripPreview(location.pathname);
if (live !== location.pathname) location.href = live;
else location.reload();
}, 1200);
break;
case "auth_required": setState(""); busy(false); botErr("agent not signed in"); openAuth(); break; case "auth_required": setState(""); busy(false); botErr("agent not signed in"); openAuth(); break;
case "error": setState(""); busy(false); botErr(ev.text || "error"); break; case "error": setState(""); busy(false); botErr(ev.text || "error"); break;
case "end": es && es.close(); es = null; convId = null; busy(false); setState(""); break; case "end": es && es.close(); es = null; convId = null; busy(false); setState(""); break;
@ -156,17 +187,22 @@ const route = "/devconsole";
e.preventDefault(); e.preventDefault();
if (running) return; if (running) return;
const message = promptEl.value.trim(); const message = promptEl.value.trim();
if (!message) return; const selections = picks.map((p) => ({ ...p.data, comment: p.input.value.trim() }));
if (!message && !selections.length) return;
promptEl.value = ""; promptEl.value = "";
userMsg(message); botStart(); busy(true); setState("working…"); const shown = [message, ...picks.map((p) => "⌖ " + p.label + (p.input.value.trim() ? " — " + p.input.value.trim() : ""))].filter(Boolean).join("\n");
userMsg(shown); botStart(); busy(true); setState("working…");
try { try {
const body = convId ? { conversationId: convId, message } : { message };
if (selections.length) body.selections = selections;
const r = await fetch(api("/run"), { const r = await fetch(api("/run"), {
method:"POST", credentials:"same-origin", headers:{ "content-type":"application/json" }, method:"POST", credentials:"same-origin", headers:{ "content-type":"application/json" },
body: JSON.stringify(convId ? { conversationId: convId, message } : { message }), body: JSON.stringify(body),
}); });
const d = await r.json(); const d = await r.json();
if (!r.ok) throw new Error(d.error || "run failed"); if (!r.ok) throw new Error(d.error || "run failed");
convId = d.conversationId; convId = d.conversationId;
clearPicks();
ensureStream(); ensureStream();
} catch (err) { botErr(err.message); busy(false); setState(""); } } catch (err) { botErr(err.message); busy(false); setState(""); }
}; };
@ -187,6 +223,7 @@ const route = "/devconsole";
try { await fetch(api("/discard"), { method:"POST", credentials:"same-origin", headers:{ "content-type":"application/json" }, body: JSON.stringify({ conversationId: convId }) }); } catch {} try { await fetch(api("/discard"), { method:"POST", credentials:"same-origin", headers:{ "content-type":"application/json" }, body: JSON.stringify({ conversationId: convId }) }); } catch {}
if (es) { es.close(); es = null; } if (es) { es.close(); es = null; }
convId = null; bot = null; chat.textContent = ""; actions.hidden = true; setState(""); convId = null; bot = null; chat.textContent = ""; actions.hidden = true; setState("");
clearPicks();
sysMsg("new conversation"); sysMsg("new conversation");
promptEl.focus(); promptEl.focus();
}; };
@ -196,6 +233,132 @@ const route = "/devconsole";
if (e.key === "Enter" && !e.shiftKey) { e.preventDefault(); $("aa-form").requestSubmit(); } if (e.key === "Enter" && !e.shiftKey) { e.preventDefault(); $("aa-form").requestSubmit(); }
}); });
// --- visual element picker: click an element on the page, attach a comment ---
const pickBtn = $("aa-pick"), picksEl = $("aa-picks"), hl = $("aa-hl"), hlTag = $("aa-hl-tag");
const prevRoute = root.dataset.previewRoute || "/_preview";
const MAX_PICKS = 5;
let picking = false, picks = [], lastTarget = null;
const isOurs = (t) => t instanceof Element && t.closest("#aa-console");
// "/_preview/<jobId>/about/" -> "/about/" (no-op on live pages)
const stripPreview = (p) => p.replace(new RegExp("^" + prevRoute.replace(/[.*+?^${}()|[\]\\]/g, "\\$&") + "/[^/]+"), "") || "/";
function hlOn(el) {
const r = el.getBoundingClientRect();
hl.style.left = r.left + "px"; hl.style.top = r.top + "px";
hl.style.width = r.width + "px"; hl.style.height = r.height + "px";
hlTag.textContent = el.tagName.toLowerCase() + (el.classList[0] ? "." + el.classList[0] : "");
hl.hidden = false;
}
function onMove(e) {
const t = e.target;
if (!(t instanceof Element) || isOurs(t)) { hl.hidden = true; lastTarget = null; return; }
lastTarget = t; hlOn(t);
}
function onScroll() { if (picking && lastTarget) hlOn(lastTarget); }
function onKey(e) { if (e.key === "Escape") { exitPick(); open(); } }
function onClick(e) {
const t = e.target;
if (isOurs(t)) { exitPick(); return; } // our own UI (the handle) — cancel, let the click through
e.preventDefault(); e.stopImmediatePropagation();
exitPick();
addPick(t);
open();
}
function enterPick() {
if (picking || running) return;
if (picks.length >= MAX_PICKS) { sysMsg("max " + MAX_PICKS + " selections per turn"); return; }
picking = true; pickBtn.classList.add("on");
close(); // collapse to the handle so the panel doesn't cover the page
handle.textContent = "click any element — Esc cancels";
handle.classList.add("aa-picking");
document.body.style.cursor = "crosshair";
document.addEventListener("mousemove", onMove, true);
document.addEventListener("click", onClick, true);
document.addEventListener("keydown", onKey, true);
document.addEventListener("scroll", onScroll, { passive: true });
}
function exitPick() {
if (!picking) return;
picking = false; pickBtn.classList.remove("on");
handle.textContent = "▲ astroagent";
handle.classList.remove("aa-picking");
hl.hidden = true; lastTarget = null;
document.body.style.cursor = "";
document.removeEventListener("mousemove", onMove, true);
document.removeEventListener("click", onClick, true);
document.removeEventListener("keydown", onKey, true);
document.removeEventListener("scroll", onScroll);
}
pickBtn.onclick = () => { if (picking) { exitPick(); open(); } else enterPick(); };
// Serialized at pick time from the BUILT page DOM. Class attributes and visible
// text survive Astro's production build verbatim — they're the agent's grep keys.
function cssPath(el) {
const parts = [];
let n = el;
for (let d = 0; n && n.nodeType === 1 && n.tagName !== "HTML" && d < 8; d++) {
const tag = n.tagName.toLowerCase();
if (n.id) { parts.unshift(tag + "#" + n.id); break; }
const cls = [...n.classList].filter((c) => !c.startsWith("astro-")).slice(0, 2);
let sel = tag + cls.map((c) => "." + c).join("");
const sibs = n.parentElement ? [...n.parentElement.children].filter((s) => s.tagName === n.tagName) : [];
if (sibs.length > 1) sel += ":nth-of-type(" + (sibs.indexOf(n) + 1) + ")";
parts.unshift(sel);
if (tag === "body") break;
n = n.parentElement;
}
return parts.join(" > ");
}
function ancestorChain(el) {
const out = [];
for (let n = el.parentElement; n && n.tagName !== "HTML" && out.length < 6; n = n.parentElement) {
out.unshift(n.tagName.toLowerCase() + [...n.classList].filter((c) => !c.startsWith("astro-")).slice(0, 2).map((c) => "." + c).join(""));
}
return out;
}
function trimHtml(el) {
const c = el.cloneNode(true);
for (const x of c.querySelectorAll("#aa-console")) x.remove();
for (const s of c.querySelectorAll("svg")) s.replaceChildren();
for (const n of [c, ...c.querySelectorAll("*")]) {
for (const a of [...n.attributes]) if (a.value.length > 150) n.setAttribute(a.name, a.value.slice(0, 150) + "…");
}
let h = c.outerHTML;
if (h.length > 2000) h = h.slice(0, 2000) + "…";
return h;
}
function capture(el) {
const r = el.getBoundingClientRect();
return {
route: stripPreview(location.pathname),
selector: cssPath(el),
classes: el.getAttribute("class") || "",
text: (el.innerText || "").trim().replace(/\s+/g, " ").slice(0, 200),
html: trimHtml(el),
ancestors: ancestorChain(el),
rect: { x: Math.round(r.x), y: Math.round(r.y), w: Math.round(r.width), h: Math.round(r.height) },
};
}
function addPick(el) {
const data = capture(el);
const label = el.tagName.toLowerCase() + (data.text ? " “" + data.text.slice(0, 24) + (data.text.length > 24 ? "…" : "") + "”" : (el.classList[0] ? "." + el.classList[0] : ""));
const chip = document.createElement("div"); chip.className = "aa-chip";
const tag = document.createElement("span"); tag.className = "aa-chip-tag"; tag.textContent = label; tag.title = data.selector;
const input = document.createElement("input"); input.placeholder = "What should change here?"; input.maxLength = 500;
const x = document.createElement("button"); x.type = "button"; x.className = "aa-x"; x.textContent = "×"; x.setAttribute("aria-label", "Remove selection");
chip.append(tag, input, x);
const pick = { el, data, label, input, chip };
x.onclick = () => { picks = picks.filter((p) => p !== pick); chip.remove(); picksEl.hidden = !picks.length; };
tag.onmouseenter = () => { if (pick.el && pick.el.isConnected) hlOn(pick.el); };
tag.onmouseleave = () => { hl.hidden = true; };
input.addEventListener("keydown", (e) => { if (e.key === "Enter") { e.preventDefault(); $("aa-form").requestSubmit(); } });
picks.push(pick);
picksEl.appendChild(chip); picksEl.hidden = false;
input.focus();
}
function clearPicks() { picks = []; picksEl.textContent = ""; picksEl.hidden = true; }
// --- agent auth token modal (pops up on auth loss, or via the 🔑 button) --- // --- agent auth token modal (pops up on auth loss, or via the 🔑 button) ---
const authModal = $("aa-authmodal"), authInput = $("aa-authinput"), authMsg = $("aa-authmsg"); const authModal = $("aa-authmodal"), authInput = $("aa-authinput"), authMsg = $("aa-authmsg");
const openAuth = () => { authMsg.textContent = ""; authInput.value = ""; authModal.hidden = false; authInput.focus(); }; const openAuth = () => { authMsg.textContent = ""; authInput.value = ""; authModal.hidden = false; authInput.focus(); };