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:
parent
f41fa652ac
commit
d085d26c78
1 changed files with 170 additions and 7 deletions
|
|
@ -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(); };
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue