Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
112 changes: 110 additions & 2 deletions src/chrome-client.js
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,8 @@ const state = {
reloading: false,
dynamic: false,
framePolicy: null,
/** cids the current frame's SDK can still revert in the DOM. */
undoable: new Set(),
};

/**
Expand Down Expand Up @@ -105,6 +107,7 @@ async function loadPage(key, { reload = true } = {}) {
state.sent = false;
state.dynamic = false;
state.baseHash = null;
state.undoable = new Set();
clearTimeout(retryTimer);
if (reload) {
state.reloading = true;
Expand Down Expand Up @@ -236,6 +239,11 @@ function render() {
rows.textContent = "";
const LIMIT = 5;
const shown = state.editsExpanded ? edits : edits.slice(0, LIMIT);
// Undo is offered only where it can actually deliver: while nothing is
// sent, and only for rows the live frame can still revert — except on
// feedback-only pages, where the row itself is the whole change.
const feedbackOnly = page.kind === "url" || page.markdown || state.dynamic;
const locked = state.agent === "working" || state.agent === "stranded" || state.sent;
for (const edit of shown) {
const row = document.createElement("div");
row.className = `edit-row${edit.kind === "deleted" ? " deleted" : ""}`;
Expand All @@ -248,6 +256,19 @@ function render() {
kind.className = "kind";
kind.textContent = edit.kind;
row.append(pip, label, kind);
if (edit.id && !locked && (feedbackOnly || (edit.cid && state.undoable.has(edit.cid)))) {
const undo = document.createElement("button");
undo.type = "button";
undo.className = "edit-undo";
undo.title = "Undo this change";
undo.setAttribute("aria-label", `Undo the ${edit.kind} change to ${edit.label}`);
undo.textContent = "↩";
undo.addEventListener("click", (event) => {
event.stopPropagation();
undoEdit(edit);
});
row.append(undo);
}
rows.append(row);
}
if (edits.length > LIMIT) {
Expand Down Expand Up @@ -407,12 +428,77 @@ function editComment(card, body, comment) {
input.setSelectionRange(input.value.length, input.value.length);
}

function toast(message) {
function toast(message, action) {
const el = document.createElement("div");
el.className = "toast";
el.textContent = message;
if (action) {
const btn = document.createElement("button");
btn.type = "button";
btn.className = "toast-action";
btn.textContent = action.label;
btn.addEventListener("click", () => {
el.remove();
action.run();
});
el.append(btn);
}
document.body.append(el);
setTimeout(() => el.remove(), 3200);
setTimeout(() => el.remove(), action ? 6000 : 3200);
}

// --------------------------------------------------------------------- undo

/** In-flight eh:undo transactions, resolved by eh:undone (or a timeout). */
const undoWaiters = new Map();
function undoInFrame(cid, domRevert) {
return new Promise((resolve) => {
undoWaiters.set(cid, resolve);
toFrame({ type: "eh:undo", cid, domRevert });
setTimeout(() => {
if (undoWaiters.get(cid) === resolve) {
undoWaiters.delete(cid);
resolve({ ok: false });
}
}, 800);
});
}

/** A pending batch freezes what the agent will apply; undo must wait for it. */
const feedbackLocked = () => state.agent === "working" || state.agent === "stranded" || state.sent;

/**
* Undo one edit row. The SDK owns the transaction (purge queue → revert DOM →
* save); the row is dropped server-side only after the frame confirms, so a
* debounced flush can never resurrect it.
*/
async function undoEdit(edit) {
if (feedbackLocked()) {
toast("Feedback already sent — wait for the agent before undoing");
return;
}
const page = state.page;
// File-backed HTML must revert on disk; rendered sources (markdown,
// localhost, self-rendering pages) never wrote a file, so removing the row
// is the whole undo there.
const fileBacked = page.kind !== "url" && !page.markdown && !state.dynamic;
const domRevert = page.kind === "url" ? false : page.markdown ? true : !state.dynamic;
const result = edit.cid ? await undoInFrame(edit.cid, domRevert) : { ok: false };
if (!result.ok && fileBacked) {
toast("Can't undo this one — the page reloaded since the change was made");
render();
return;
}
try {
state.page = (await api(`/api/page/${state.key}/edit/${edit.id}`, { method: "DELETE" })).page;
} catch (err) {
toast(err.message);
return;
}
if (!fileBacked && !result.ok) toast("Removed from feedback");
render();
// A restore rebuilt the block's inner markup; re-anchor any comment marks it held.
if (result.ok && domRevert) toFrame({ type: "eh:anchors", comments: state.page.comments || [] });
}

function setActive(id, scroll) {
Expand Down Expand Up @@ -556,6 +642,8 @@ window.addEventListener("message", async (event) => {
state.page = (await api(`/api/page/${state.key}/edit`, {
method: "POST",
body: JSON.stringify({
cid: msg.cid,
boot: msg.boot,
label: msg.label,
kind: msg.kind,
before: msg.before,
Expand All @@ -568,7 +656,25 @@ window.addEventListener("message", async (event) => {
})).page;
state.sent = false;
render();
// Deleting a block is the easiest change to make by accident; offer the
// way back the moment it happens.
if (msg.kind === "deleted" && msg.cid) {
const row = (state.page.edits || []).find((e) => e.cid === msg.cid);
if (row) toast(`Deleted ${row.label}`, { label: "Undo", run: () => undoEdit(row) });
}
break;
case "eh:undoable":
state.undoable = new Set(msg.cids || []);
render();
break;
case "eh:undone": {
const waiter = undoWaiters.get(msg.cid);
if (waiter) {
undoWaiters.delete(msg.cid);
waiter({ ok: !!msg.ok });
}
break;
}
case "eh:asset":
try {
const saved = await fetch(`/api/page/${state.key}/asset?type=${encodeURIComponent(msg.assetType || "")}`, {
Expand Down Expand Up @@ -791,6 +897,8 @@ function connect() {
const hadEdits = state.page ? state.page.edits.length : 0;
state.reloading = true;
state.dynamic = false;
// The frame is about to reboot: its undo registry dies with it.
state.undoable = new Set();
// The file on disk changed: queued saves are based on the old version.
state.baseHash = null;
clearTimeout(retryTimer);
Expand Down
25 changes: 25 additions & 0 deletions src/chrome.css
Original file line number Diff line number Diff line change
Expand Up @@ -350,6 +350,20 @@ body.collapsed .handle { right: 0; }
.edit-row.deleted .pip { background: var(--danger); }
.edit-row .label { font-size: 12.5px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.edit-row .kind { margin-left: auto; color: var(--faint-2); font-size: 11px; white-space: nowrap; }
.edit-undo {
flex: none;
width: 21px;
height: 21px;
padding: 0;
border: 0;
border-radius: 50%;
background: none;
color: var(--faint-2);
font-size: 13px;
line-height: 1;
cursor: pointer;
}
.edit-undo:hover { background: var(--soft); color: var(--strong-txt); }

.edit-more {
width: 100%;
Expand Down Expand Up @@ -445,6 +459,17 @@ body.collapsed .handle { right: 0; }
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.18);
animation: rise 140ms ease-out;
}
.toast-action {
margin-left: 12px;
padding: 2px 8px;
border: 1px solid currentColor;
border-radius: 6px;
background: none;
color: inherit;
font-size: 11.5px;
font-weight: 600;
cursor: pointer;
}

@keyframes rise {
from { opacity: 0; transform: translateY(4px); }
Expand Down
Loading