lavish-axi 0.1.80 → 0.1.82

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -197,18 +197,19 @@ pnpm link
197
197
  Unresolved local assets or export notices such as author-set CSP meta tags and redacted file URLs are surfaced in command or browser output.
198
198
  Use `--token` or `LAVISH_AXI_HTML_APP_TOKEN` for an optional bearer token when publishing a new page (a republish or `--unpublish` authorizes with the `update_key` instead and rejects `--token`); set `LAVISH_AXI_HTML_APP_API_URL` to override the ht-ml.app API base and point `share` at a backend you control (see [Self-hosting the share backend](docs/self-hosting-share.md) for the contract it must implement).
199
199
  - **Live reload** - Lavish watches the HTML artifact file by default and preserves review context across reloads: the artifact iframe scroll position, an open annotation card's unsent text, and answers to `data-lavish-question` controls (application-owned form state is left alone). Unsent annotation text also survives a full reload of the review page itself. While a queued layout-issue batch is outstanding, closely spaced saves coalesce so one batch of fixes costs one refresh. To also reload on sibling asset changes, add `data-lavish-live-reload-root` to the root element or `<meta name="lavish-live-reload" content="root">`.
200
- If the element an unsent annotation was attached to is gone from the artifact for good, Lavish cannot reopen that card, so it writes your text into the conversation panel under **Unsent annotation** - selectable, never written over anything you have typed, and kept there across reloads; no note is ever dropped to make room for a newer one, and a note the browser refuses to store says so where it is shown.
201
- - **Feedback controls** - Native controls (radios, checkboxes, inputs, selects, buttons, labels, disclosure summaries, contenteditable) are interactive automatically, so they do not need `data-lavish-action`.
200
+ If the element an unsent annotation was attached to is gone from the artifact for good, Lavish cannot reopen that card, and if the artifact replaces a queued note while you are editing it, the replacement is kept as the artifact sent it. Either way, Lavish writes your edited text into the conversation panel under **Unsent annotation** - selectable, never written over anything you have typed, and kept there across reloads; no note is ever dropped to make room for a newer one, and a note the browser refuses to store says so where it is shown.
201
+ - **Feedback controls** - Native controls (radios, checkboxes, inputs, selects, buttons, labels, disclosure summaries, contenteditable) and custom widgets with an interactive ARIA role (`button`, `checkbox`, `combobox`, `menuitem`, `menuitemcheckbox`, `menuitemradio`, `option`, `radio`, `switch`, `tab`, `treeitem`) are interactive automatically, so they do not need `data-lavish-action`. A link (`<a href>`) stays annotatable when it carries one of these roles or sits inside such a widget.
202
202
  For reversible choices, let option clicks update local state, then queue exactly one final answer from a per-question submit or Queue answer button with `window.lavish.queuePrompt()`.
203
203
  If someone may open a decision artifact as a standalone HTML file, `lavish-axi playbook input` provides an optional Copy all answers control they can use to paste their form answers back into chat.
204
204
  Mark only custom (non-native) clickable elements with `data-lavish-action` so Lavish does not annotate them, and use `data-lavish-question` or `queueKey` when pre-send updates for the same question should replace each other.
205
- Everything you send is part of the conversation: a note on an element, a text selection, a table cell, a diagram node, a whiteboard, a layout-issue batch, or a composer message each becomes a bubble on your side of the Conversation panel, with an anchor line naming what it was attached to (the element tag, text selection, table cell, diagram node, whiteboard, or layout issues), a quoted excerpt, and, when available, the selector on hover. A note you have queued but not sent sits at the end of the conversation as a dashed bubble labelled **Queued** with a remove control; while its batch is in flight it reads **Sending**, and once the server accepts it, it settles in place as a sent bubble in every tab of the session. The panel keeps the newest stored transcript up to 5 MiB; older bubbles drop from view, but an accepted note still settles and is not sent twice. Agent replies render a small Markdown subset - paragraphs, line breaks, headings as bold lead lines, bullet and numbered lists, fenced and inline code, bold, italic, and http(s) links - while tables, images, and raw HTML stay as the text the agent wrote; your own messages keep their line breaks and are never parsed.
205
+ Everything you send is part of the conversation: a note on an element, a text selection, a table cell, a diagram node, a whiteboard, a layout-issue batch, or a composer message each becomes a bubble on your side of the Conversation panel, with an anchor line naming what it was attached to (the element tag, text selection, table cell, diagram node, whiteboard, or layout issues), a quoted excerpt, and, when available, the selector on hover. A note you have queued but not sent sits at the end of the conversation as a dashed bubble labelled **Queued** with edit and remove controls. Clicking the bubble or its edit control opens its words for editing in place and scrolls the artifact to the element it points at; clicking that element again in annotate mode opens the same note rather than a fresh card. Saving a note with no words and no images removes it, and **Send** delivers an open edit as typed. While its batch is in flight it reads **Sending** and can no longer be edited, and once the server accepts it, it settles in place as a sent bubble in every tab of the session. The panel keeps the newest stored transcript up to 5 MiB; older bubbles drop from view, but an accepted note still settles and is not sent twice. Agent replies render a small Markdown subset - paragraphs, line breaks, headings as bold lead lines, bullet and numbered lists, fenced and inline code, bold, italic, and http(s) links - while tables, images, and raw HTML stay as the text the agent wrote; your own messages keep their line breaks and are never parsed.
206
206
  On wider screens, the conversation and the queued notes share one scrollable Conversation panel above a sticky composer, so long feedback queues do not push the text box or send controls off screen.
207
207
  The browser chrome keeps editing actions in the overflow menu (copy path, reload artifact, copy DOM snapshot, export standalone HTML, publish link, end session), while the composer exposes **Send & End** beside **Send to Agent** to submit queued prompts and user-ended attribution together. Once **Send & End** starts, Lavish pauses new feedback, waits up to 5 seconds for feedback preparation already underway, keeps any open annotation draft intact until delivery succeeds, then holds that exact terminal batch. A completed reservation survives a page reload, including when its prompts were already delivered and only the pending end remains; incomplete preparation is not restored. If preparation times out, Lavish keeps the review open and its existing queue editable with a visible error. A transient delivery failure permits only retrying the same terminal batch through **Send & End** until it succeeds. If a snapshot makes the request too large (HTTP 413), Lavish retries that exact batch once without the optional snapshot. An actionable attachment rejection (HTTP 400), recoverable layout-selection conflict (HTTP 409), or unrecoverable 413 instead cancels the terminal reservation without ending the session, keeps the prompts queued, and restores editing, removal, **Send to Agent**, **Send & End**, and **End session** so the reviewer can revise or remove stale feedback before submitting again.
208
208
  Composer feedback is visibly queued before the text box clears and stays queued until acknowledged. Send waits up to 5 seconds for the artifact's DOM snapshot, then delivers without that optional context if the frame no longer answers; a late snapshot cannot submit the batch twice. A failed or still-unacknowledged POST keeps the queue and shows persistent recovery guidance unless a transcript sync proves the server already accepted that feedback.
209
209
  - **Reviewing on a phone** - Below 860px wide, the artifact takes the whole screen above a **Conversation** dock, and the conversation opens as a bottom sheet over it: tap the dock, swipe it up, or press the chevron to raise it; tap the dimmed artifact, swipe the sheet down, press the chevron, or press Escape to lower it. The dock reports what matters while the sheet is down - how many prompts are queued, a reply that arrived while you were reading, or whether the agent is listening - and the sheet stays open across a reload of the review page. The sheet sizes itself to the visible viewport and respects safe-area insets; if the keyboard or attachments leave little room, conversation content yields or scrolls while the send actions remain pinned above the bottom edge. In landscape the sheet covers the top bar as well. Wider screens keep the side-by-side layout.
210
210
  - **Keyboard shortcuts** - In the chrome composer, Enter sends queued prompts and Shift+Enter inserts a newline.
211
211
  In the annotation card, Enter queues the annotation, Shift+Enter inserts a newline, and Ctrl+Enter (Cmd+Enter on macOS) queues it and sends all queued prompts immediately. Escape closes the card, same as Cancel, but only while it is empty (no text, no attachment); with unsent text or an attachment present, Escape does nothing rather than risk discarding it.
212
+ While editing a queued note, Enter saves it, Shift+Enter inserts a newline, and Escape discards the edit.
212
213
  Cmd+I or Ctrl+I toggles between annotate and explore mode from either the browser chrome or the artifact iframe, including while focus is in a textarea or control.
213
214
  - **Agent presence** - The browser shows when no agent is listening, keeps queued feedback for the next successful `lavish-axi poll` send even across reloads, and keeps human feedback actions available while the agent is working because the server queues them for the next poll. An agent reply concludes delivered work and returns presence to waiting (see the CLI Reference for reply and poll commands). A supervisor-owned process listener is shown as an external listener rather than an idle captain turn.
214
215
  In a Herdr-managed pane, set `LAVISH_AXI_HERDR_CHIME=1` to show a Herdr request notification and play its chime once an indefinite poll enters the waiting state. Immediate queued feedback and timed debug polls do not trigger a notification. The integration is off unless both `LAVISH_AXI_HERDR_CHIME` and Herdr's `HERDR_ENV` session marker equal `1`; leaving either unset or using any other value opts out. Notification delivery runs without blocking feedback: if Herdr is unavailable, rejects the notification, stalls, or fails, the poll continues unchanged.
@@ -261,6 +261,12 @@ let terminalSubmission =
261
261
  /** @type {ReturnType<typeof setTimeout> | undefined} */
262
262
  let sendAcknowledgementTimer;
263
263
  let lastScroll = { x: 0, y: 0 };
264
+ // The queued note open for editing in the panel, by prompt identity, and its unsaved words.
265
+ let editingPromptId = "";
266
+ let editingDraft = "";
267
+ let editFocusPending = false;
268
+ // The selector list last told to the artifact, so a render that changes nothing stays quiet.
269
+ let postedQueuedAnchors = "[]";
264
270
  // In-iframe review context (an open annotation card's unsent text, Lavish-owned question
265
271
  // answers). The sandbox means the chrome cannot read it back after a reload, so the SDK reports
266
272
  // it as it changes and the chrome replays it once the new document is up. It is persisted per
@@ -486,6 +492,8 @@ function persistTerminalReservation(reserved) {
486
492
 
487
493
  const REMOVE_ICON_SVG =
488
494
  '<svg width="10" height="10" viewBox="0 0 10 10" fill="none" aria-hidden="true" focusable="false"><path d="M1 1L9 9M9 1L1 9" stroke="currentColor" stroke-width="1.6" stroke-linecap="round"/></svg>';
495
+ const EDIT_ICON_SVG =
496
+ '<svg width="10" height="10" viewBox="0 0 10 10" fill="none" aria-hidden="true" focusable="false"><path d="M6.5 1.5L8.5 3.5L3.5 8.5H1.5V6.5L6.5 1.5Z" stroke="currentColor" stroke-width="1.3" stroke-linejoin="round"/></svg>';
489
497
  const ANCHOR_EXCERPT_MAX = 120;
490
498
  const ANCHOR_SELECTOR_MAX = 512;
491
499
  const ANCHOR_LABEL_MAX = 40;
@@ -587,14 +595,23 @@ function userBubbleTextHtml(entry, text) {
587
595
  }
588
596
 
589
597
  // A queued note is the user bubble in its not-yet-sent state: dashed, labelled Queued (Sending
590
- // while its batch is in flight), and removable until then. It settles in place as a sent bubble
591
- // once the server's transcript carries it, so nothing moves between regions.
598
+ // while its batch is in flight), and editable and removable until then. It settles in place as a
599
+ // sent bubble once the server's transcript carries it, so nothing moves between regions.
592
600
  function queuedBubbleHtml(prompt, index) {
593
601
  const sending = isPromptSending(prompt);
602
+ if (!sending && editingPromptId && promptIdentity(prompt) === editingPromptId) {
603
+ return queuedEditorHtml(prompt, index);
604
+ }
594
605
  return (
595
- '<div class="bubble user queued"><small>' +
606
+ '<div class="bubble user queued" data-index="' +
607
+ index +
608
+ '"><small>' +
596
609
  (sending ? "Sending\u2026" : "Queued") +
597
- ' <button class="queued-remove" type="button" aria-label="Remove queued prompt" data-index="' +
610
+ ' <button class="queued-edit" type="button" aria-label="Edit queued prompt" data-index="' +
611
+ index +
612
+ '">' +
613
+ EDIT_ICON_SVG +
614
+ '</button><button class="queued-remove" type="button" aria-label="Remove queued prompt" data-index="' +
598
615
  index +
599
616
  '">' +
600
617
  REMOVE_ICON_SVG +
@@ -606,6 +623,25 @@ function queuedBubbleHtml(prompt, index) {
606
623
  );
607
624
  }
608
625
 
626
+ // The same bubble while its words are being revised. The anchor and images stay as they were:
627
+ // an edit changes what the note says, not what it points at.
628
+ function queuedEditorHtml(prompt, index) {
629
+ return (
630
+ '<div class="bubble user queued editing" data-index="' +
631
+ index +
632
+ '"><small>Editing</small>' +
633
+ anchorHtml(promptAnchor(prompt)) +
634
+ '<textarea class="queued-edit-input" aria-label="Edit queued prompt" data-index="' +
635
+ index +
636
+ '">' +
637
+ escapeHtml(editingDraft) +
638
+ '</textarea><div class="queued-edit-actions"><button class="queued-edit-cancel" type="button">Cancel</button>' +
639
+ '<button class="queued-edit-save" type="button">Save</button></div>' +
640
+ bubbleAttachmentsHtml(prompt) +
641
+ "</div>"
642
+ );
643
+ }
644
+
609
645
  // Whether a queued prompt is committed to a send that has not been answered yet: waiting for its
610
646
  // snapshot, queued behind another submission, in flight, or held by the terminal reservation.
611
647
  // Derived from the existing send bookkeeping rather than tracked separately, so no failure path
@@ -624,6 +660,23 @@ function isPromptSending(prompt) {
624
660
  }
625
661
 
626
662
  function render() {
663
+ const editing = editingPromptId ? queued.find((prompt) => promptIdentity(prompt) === editingPromptId) : null;
664
+ // A note that left the queue or started sending takes its editor with it.
665
+ if (editingPromptId && (!editing || !isPromptEditable(editing))) endQueuedEdit();
666
+ const keepEditFocus = editFocusPending || queuedLog.contains(document.activeElement);
667
+ // A re-render while the reviewer is typing keeps their caret and scroll; only a freshly opened
668
+ // edit starts at the end of its words.
669
+ const previousInput = /** @type {HTMLTextAreaElement | null} */ (queuedLog.querySelector(".queued-edit-input"));
670
+ const caret =
671
+ !editFocusPending && previousInput && previousInput === document.activeElement
672
+ ? {
673
+ start: previousInput.selectionStart,
674
+ end: previousInput.selectionEnd,
675
+ direction: previousInput.selectionDirection,
676
+ scrollTop: previousInput.scrollTop,
677
+ }
678
+ : null;
679
+ editFocusPending = false;
627
680
  queuedLog.innerHTML = queued.map((prompt, index) => queuedBubbleHtml(prompt, index)).join("");
628
681
 
629
682
  for (const button of queuedLog.querySelectorAll(".queued-remove")) {
@@ -632,6 +685,23 @@ function render() {
632
685
  removeButton.disabled = terminalSubmission !== null || isPromptSending(prompt);
633
686
  removeButton.addEventListener("click", (event) => removeQueuedPrompt(Number(removeButton.dataset.index), event));
634
687
  }
688
+ for (const button of queuedLog.querySelectorAll(".queued-edit")) {
689
+ const editButton = /** @type {HTMLButtonElement} */ (button);
690
+ editButton.disabled = !isPromptEditable(queued[Number(editButton.dataset.index)]);
691
+ }
692
+ if (editingPromptId && keepEditFocus) {
693
+ const input = /** @type {HTMLTextAreaElement | null} */ (queuedLog.querySelector(".queued-edit-input"));
694
+ if (input) {
695
+ input.focus();
696
+ if (caret) {
697
+ input.setSelectionRange?.(caret.start, caret.end, caret.direction);
698
+ input.scrollTop = caret.scrollTop;
699
+ } else {
700
+ input.setSelectionRange?.(input.value.length, input.value.length);
701
+ }
702
+ }
703
+ }
704
+ postQueuedAnchors();
635
705
  updateSendState();
636
706
  scrollPanelToBottom();
637
707
  renderSheetSummary();
@@ -1060,6 +1130,7 @@ function setReviewState(state) {
1060
1130
  }
1061
1131
 
1062
1132
  function hasUnsentDraft() {
1133
+ if (editingPromptId) return true;
1063
1134
  return Boolean(lastReviewState && lastReviewState.card && String(lastReviewState.card.text || "").trim());
1064
1135
  }
1065
1136
 
@@ -1108,7 +1179,7 @@ function renderRetiredDraft(text, stored = true) {
1108
1179
  const el = document.createElement("div");
1109
1180
  el.className = "bubble note";
1110
1181
  el.innerHTML =
1111
- "<small>Unsent annotation</small><div>The element this note was attached to is no longer in the artifact, so Lavish could not reopen the card. Your text is kept here:</div>" +
1182
+ "<small>Unsent annotation</small><div>The artifact removed or replaced what this note was attached to, so Lavish could not keep it open. Your text is kept here:</div>" +
1112
1183
  '<div class="note-draft">' +
1113
1184
  escapeHtml(text) +
1114
1185
  "</div>" +
@@ -1338,16 +1409,149 @@ function scrollElementIntoView(el) {
1338
1409
  el.scrollIntoView({ block: "nearest", inline: "nearest" });
1339
1410
  }
1340
1411
 
1412
+ function isPromptEditable(prompt) {
1413
+ return Boolean(prompt) && !terminalSubmission && !isPromptSending(prompt);
1414
+ }
1415
+
1416
+ function editQueuedPrompt(index, { reveal = true } = {}) {
1417
+ const prompt = queued[index];
1418
+ if (ended || !isPromptEditable(prompt)) return;
1419
+ const id = promptIdentity(prompt);
1420
+ if (!id || id === editingPromptId) return;
1421
+ // Moving to another note keeps what was typed into the first, the way leaving a field does.
1422
+ commitQueuedEdit();
1423
+ editingPromptId = id;
1424
+ editingDraft = String(prompt.prompt || "");
1425
+ editFocusPending = true;
1426
+ if (isMobileSheet()) setSheetOpen(true);
1427
+ render();
1428
+ if (reveal && prompt.selector) postToFrame({ type: "lavish:revealElement", selector: String(prompt.selector) });
1429
+ }
1430
+
1431
+ function endQueuedEdit() {
1432
+ editingPromptId = "";
1433
+ editingDraft = "";
1434
+ }
1435
+
1436
+ // Writes the open edit into its note without rendering. Returns whether the queue changed. A note
1437
+ // cleared of words and images is removed, as if its remove control had been used.
1438
+ function commitQueuedEdit() {
1439
+ if (!editingPromptId) return false;
1440
+ const index = queued.findIndex((prompt) => promptIdentity(prompt) === editingPromptId);
1441
+ const text = editingDraft.trim();
1442
+ endQueuedEdit();
1443
+ const prompt = queued[index];
1444
+ if (!isPromptEditable(prompt)) return false;
1445
+ if (!text && !attachmentCount(prompt)) {
1446
+ queued.splice(index, 1);
1447
+ afterQueuedPromptRemoved();
1448
+ } else {
1449
+ // In place, not replaced: the send bookkeeping tracks notes by object identity.
1450
+ prompt.prompt = text;
1451
+ persistQueuedPrompts();
1452
+ }
1453
+ return true;
1454
+ }
1455
+
1456
+ function saveQueuedEdit() {
1457
+ commitQueuedEdit();
1458
+ render();
1459
+ }
1460
+
1461
+ function cancelQueuedEdit() {
1462
+ endQueuedEdit();
1463
+ render();
1464
+ }
1465
+
1466
+ // A click on an element the artifact knows carries a queued note. The artifact names only the
1467
+ // selector, so the most a forged message can do is open one of the user's own notes for editing.
1468
+ function editQueuedAnchor(selector) {
1469
+ if (!selector) return;
1470
+ for (let index = queued.length - 1; index >= 0; index--) {
1471
+ if (isElementAnchoredPrompt(queued[index]) && queuedAnchorSelectorsOf(queued[index]).includes(selector)) {
1472
+ if (isPromptEditable(queued[index])) {
1473
+ editQueuedPrompt(index, { reveal: false });
1474
+ return;
1475
+ }
1476
+ break;
1477
+ }
1478
+ }
1479
+ // The note is already on its way, or gone: the click still deserves the card it would have had.
1480
+ postToFrame({ type: "lavish:annotateElement", selector });
1481
+ }
1482
+
1483
+ // Notes that point at a whole element, cell, or diagram node, which a later click on that same
1484
+ // target can find again. A text selection's note is about the words, not the element around them.
1485
+ function isElementAnchoredPrompt(prompt) {
1486
+ const kind = promptAnchor(prompt)?.kind;
1487
+ return Boolean(prompt.selector) && (kind === "element" || kind === "cell" || kind === "node");
1488
+ }
1489
+
1490
+ // A diagram node is one target however deep the click lands, so its note answers to the node's
1491
+ // own selector as well as the clicked element's. Other notes name only the exact element.
1492
+ function queuedAnchorSelectorsOf(prompt) {
1493
+ const target = prompt.target && typeof prompt.target === "object" ? prompt.target : null;
1494
+ const whole = target?.type === "mermaid-node" ? String(target.selector || "") : "";
1495
+ return whole ? [String(prompt.selector), whole] : [String(prompt.selector)];
1496
+ }
1497
+
1498
+ // Tells the artifact which element selectors carry a queued note. Only selectors cross into the
1499
+ // frame: the artifact is agent-authored, and the note text stays in the chrome.
1500
+ function postQueuedAnchors(force = false) {
1501
+ const selectors = [
1502
+ ...new Set(queued.filter((prompt) => isElementAnchoredPrompt(prompt)).flatMap(queuedAnchorSelectorsOf)),
1503
+ ];
1504
+ const signature = JSON.stringify(selectors);
1505
+ if (!force && signature === postedQueuedAnchors) return;
1506
+ postedQueuedAnchors = signature;
1507
+ postToFrame({ type: "lavish:queuedAnchors", selectors });
1508
+ }
1509
+
1510
+ queuedLog.addEventListener("click", (event) => {
1511
+ const target = /** @type {Element | null} */ (event.target);
1512
+ if (!target || typeof target.closest !== "function") return;
1513
+ // The remove control owns its own click, and an editor's textarea is for typing.
1514
+ if (target.closest(".queued-remove") || target.closest(".queued-edit-input")) return;
1515
+ if (target.closest(".queued-edit-save")) return saveQueuedEdit();
1516
+ if (target.closest(".queued-edit-cancel")) return cancelQueuedEdit();
1517
+ const bubble = /** @type {HTMLElement | null} */ (target.closest(".queued-edit") || target.closest(".bubble.queued"));
1518
+ if (bubble) editQueuedPrompt(Number(bubble.dataset.index));
1519
+ });
1520
+ queuedLog.addEventListener("input", (event) => {
1521
+ const target = /** @type {HTMLTextAreaElement | null} */ (event.target);
1522
+ if (target && typeof target.closest === "function" && target.closest(".queued-edit-input")) {
1523
+ editingDraft = String(target.value || "");
1524
+ }
1525
+ });
1526
+ queuedLog.addEventListener("keydown", (event) => {
1527
+ const target = /** @type {Element | null} */ (event.target);
1528
+ if (!target || typeof target.closest !== "function" || !target.closest(".queued-edit-input")) return;
1529
+ // The annotation card's keys: Enter keeps the words, Shift+Enter breaks the line.
1530
+ if (event.key === "Enter" && !event.shiftKey && !event.isComposing) {
1531
+ event.preventDefault();
1532
+ event.stopPropagation();
1533
+ saveQueuedEdit();
1534
+ } else if (event.key === "Escape" && !event.isComposing) {
1535
+ event.preventDefault();
1536
+ event.stopPropagation();
1537
+ cancelQueuedEdit();
1538
+ }
1539
+ });
1540
+
1341
1541
  function removeQueuedPrompt(index, event) {
1342
1542
  if (event) event.stopPropagation();
1343
1543
  if (terminalSubmission || isPromptSending(queued[index])) return;
1344
1544
  queued.splice(index, 1);
1545
+ afterQueuedPromptRemoved();
1546
+ render();
1547
+ }
1548
+
1549
+ function afterQueuedPromptRemoved() {
1345
1550
  persistQueuedPrompts();
1346
1551
  if (!queued.length) {
1347
1552
  clearSendAcknowledgementWarning();
1348
1553
  if (sendFailureOwner?.kind !== "preparation") clearPersistentSendFailure();
1349
1554
  }
1350
- render();
1351
1555
  }
1352
1556
 
1353
1557
  function promptQueueKey(prompt) {
@@ -1382,6 +1586,13 @@ function enqueuePrompt(rawPrompt, /** @type {FeedbackPreparation | null} */ prep
1382
1586
  if (queueKey) {
1383
1587
  const index = queued.findIndex((item) => promptQueueKey(item) === queueKey);
1384
1588
  if (index !== -1) {
1589
+ // The replacement is the artifact's newer answer. Words typed against the old answer never
1590
+ // move onto it; they are handed back to the reviewer instead.
1591
+ if (editingPromptId && promptIdentity(queued[index]) === editingPromptId) {
1592
+ const draft = editingDraft.trim();
1593
+ endQueuedEdit();
1594
+ if (draft !== String(queued[index].prompt || "").trim()) keepRetiredDraft(draft);
1595
+ }
1385
1596
  queued[index] = prompt;
1386
1597
  } else {
1387
1598
  queued.push(prompt);
@@ -1660,6 +1871,8 @@ function sendQueued(endAfter) {
1660
1871
  return;
1661
1872
  }
1662
1873
  closeMenus();
1874
+ // Send delivers what the panel shows, so an open edit is kept rather than sent stale.
1875
+ if (commitQueuedEdit()) render();
1663
1876
 
1664
1877
  // A pending or failed chip holds back only the COMPOSER message (and an
1665
1878
  // explicit end, which would strand the chips) - queued annotation prompts
@@ -3965,6 +4178,7 @@ window.addEventListener("message", (event) => {
3965
4178
  if (msg.type === "lavish:endSession") endSession();
3966
4179
  if (msg.type === "lavish:revisions") applyRevisionMessage(msg);
3967
4180
  if (msg.type === "lavish:toggleAnnotationMode") toggleAnnotationMode();
4181
+ if (msg.type === "lavish:editQueuedAnchor") editQueuedAnchor(String(msg.selector || ""));
3968
4182
  });
3969
4183
 
3970
4184
  // The sandboxed artifact iframe can't reach the loopback server (opaque origin),
@@ -4293,6 +4507,7 @@ frame.addEventListener("load", () => {
4293
4507
  // Replay the pre-reload scroll position so hot reloads don't jump the artifact to the top.
4294
4508
  postToFrame({ type: "lavish:restoreScroll", x: lastScroll.x, y: lastScroll.y });
4295
4509
  if (lastReviewState) postToFrame({ type: "lavish:restoreReviewState", state: lastReviewState });
4510
+ postQueuedAnchors(true);
4296
4511
  if (overlayIndex !== null) {
4297
4512
  inlineWhiteboardChannels.delete(overlayIndex);
4298
4513
  postToFrame({ type: "lavish:suspendWhiteboard", diagramIndex: overlayIndex });
package/dist/chrome.css CHANGED
@@ -1199,6 +1199,15 @@ body.lavish {
1199
1199
  align-items: center;
1200
1200
  gap: 8px;
1201
1201
  }
1202
+ .bubble.queued:not(.editing) {
1203
+ cursor: pointer;
1204
+ }
1205
+ .bubble.queued.editing {
1206
+ align-self: stretch;
1207
+ max-width: 100%;
1208
+ border-color: var(--accent);
1209
+ }
1210
+ .queued-edit,
1202
1211
  .queued-remove {
1203
1212
  display: flex;
1204
1213
  align-items: center;
@@ -1212,13 +1221,51 @@ body.lavish {
1212
1221
  color: var(--fg-muted);
1213
1222
  cursor: pointer;
1214
1223
  }
1224
+ .queued-edit:disabled,
1215
1225
  .queued-remove:disabled {
1216
1226
  opacity: 0.55;
1217
1227
  cursor: not-allowed;
1218
1228
  }
1229
+ .queued-edit svg,
1219
1230
  .queued-remove svg {
1220
1231
  display: block;
1221
1232
  }
1233
+ .queued-edit-input {
1234
+ display: block;
1235
+ width: 100%;
1236
+ min-height: 64px;
1237
+ resize: vertical;
1238
+ border-radius: var(--radius-md);
1239
+ border: 1px solid var(--border);
1240
+ background: var(--bg);
1241
+ color: var(--fg);
1242
+ padding: 8px;
1243
+ font: inherit;
1244
+ box-sizing: border-box;
1245
+ }
1246
+ .queued-edit-actions {
1247
+ display: flex;
1248
+ justify-content: flex-end;
1249
+ gap: 8px;
1250
+ margin-top: 8px;
1251
+ }
1252
+ .queued-edit-actions button {
1253
+ border: 0;
1254
+ border-radius: var(--radius-md);
1255
+ padding: 6px 10px;
1256
+ font-family: inherit;
1257
+ font-size: 12px;
1258
+ font-weight: var(--w-bold);
1259
+ cursor: pointer;
1260
+ }
1261
+ .queued-edit-cancel {
1262
+ background: var(--border);
1263
+ color: var(--fg);
1264
+ }
1265
+ .queued-edit-save {
1266
+ background: var(--accent);
1267
+ color: var(--accent-ink);
1268
+ }
1222
1269
  /* The anchor line names what a note is attached to, in the annotation card's words. */
1223
1270
  .anchor {
1224
1271
  display: flex;
package/dist/cli.mjs CHANGED
@@ -224,7 +224,7 @@ var PLAYBOOKS = [
224
224
  "Show selected state separately from queued state so the user trusts what will be sent back."
225
225
  ],
226
226
  design_rules: [
227
- "Native controls - radios, checkboxes, text inputs, selects, textareas, buttons, options, labels, disclosure summaries, and contenteditable regions - are interactive automatically: clicks toggle, focus, and type instead of annotating, so they do not need data-lavish-action. Build choice and option UIs from these whenever you can.",
227
+ "Native controls - radios, checkboxes, text inputs, selects, textareas, buttons, options, labels, disclosure summaries, and contenteditable regions - and custom widgets with an interactive ARIA role (button, checkbox, combobox, menuitem, option, radio, switch, tab, treeitem, and the menuitem variants) are interactive automatically: clicks toggle, focus, and type instead of annotating, so they do not need data-lavish-action. An <a href> stays annotatable when it carries one of these roles or sits inside such a widget. Build choice and option UIs from these whenever you can.",
228
228
  "For reversible choices, do not call window.lavish.queuePrompt() from radio change handlers or option click handlers. Those handlers should only update local selected state.",
229
229
  "Use a per-question form submit or explicit Queue answer button to read the current values and call window.lavish.queuePrompt() exactly once for the final answer.",
230
230
  "Put data-lavish-action only on custom (non-native) elements that should act like a feedback control - typically a styled div or span you made clickable - so Lavish does not annotate it and shows a pointer cursor instead.",
@@ -5284,9 +5284,18 @@ function deriveLavishQueueKey(element, options = {}) {
5284
5284
  return "";
5285
5285
  }
5286
5286
  function isNativeInteractiveControl(el) {
5287
- return !!(el && el.closest && el.closest(
5287
+ if (!el || !el.closest) return false;
5288
+ if (el.closest(
5288
5289
  "button,input,select,textarea,option,optgroup,label,summary,[contenteditable]:not([contenteditable='false'])"
5289
- ));
5290
+ )) {
5291
+ return true;
5292
+ }
5293
+ const widget = el.closest(
5294
+ "[role='button'],[role='checkbox'],[role='combobox'],[role='menuitem'],[role='menuitemcheckbox'],[role='menuitemradio'],[role='option'],[role='radio'],[role='switch'],[role='tab'],[role='treeitem']"
5295
+ );
5296
+ if (!widget) return false;
5297
+ const link = el.closest("a[href]");
5298
+ return !(link && widget.contains(link));
5290
5299
  }
5291
5300
  function classifySevereTextOverflow({
5292
5301
  fragments,
@@ -5487,6 +5496,7 @@ function createArtifactSdk(deriveQueueKey, isNativeInteractive = isNativeInterac
5487
5496
  let hovered = null;
5488
5497
  let selected = null;
5489
5498
  let ignoreNextClick = false;
5499
+ let queuedAnchorSelectors = /* @__PURE__ */ new Set();
5490
5500
  let shadow = null;
5491
5501
  let counter = 0;
5492
5502
  const ids = /* @__PURE__ */ new WeakMap();
@@ -6000,7 +6010,7 @@ function createArtifactSdk(deriveQueueKey, isNativeInteractive = isNativeInterac
6000
6010
  if (annotationMode && !style) {
6001
6011
  style = document.createElement("style");
6002
6012
  style.id = "lavish-cursor-style";
6003
- style.textContent = ":root{--lavish-accent:#f4c95d;--lavish-annotate-outline:2px solid var(--lavish-accent);--lavish-annotate-offset:2px}*{cursor:default!important}[data-lavish-action],[data-lavish-action] *{cursor:pointer!important}input,textarea,[contenteditable]:not([contenteditable='false']){cursor:text!important}button,select,label,option,input[type='button'],input[type='submit'],input[type='reset'],input[type='checkbox'],input[type='radio'],input[type='file'],input[type='color'],input[type='range'],input[type='image']{cursor:pointer!important}";
6013
+ style.textContent = ":root{--lavish-accent:#f4c95d;--lavish-annotate-outline:2px solid var(--lavish-accent);--lavish-annotate-offset:2px}*{cursor:default!important}:where(:is([role='button'],[role='checkbox'],[role='combobox'],[role='menuitem'],[role='menuitemcheckbox'],[role='menuitemradio'],[role='option'],[role='radio'],[role='switch'],[role='tab'],[role='treeitem']):not(a[href])),:where(:is([role='button'],[role='checkbox'],[role='combobox'],[role='menuitem'],[role='menuitemcheckbox'],[role='menuitemradio'],[role='option'],[role='radio'],[role='switch'],[role='tab'],[role='treeitem']):not(a[href])) *{cursor:pointer!important}:where(:is([role='button'],[role='checkbox'],[role='combobox'],[role='menuitem'],[role='menuitemcheckbox'],[role='menuitemradio'],[role='option'],[role='radio'],[role='switch'],[role='tab'],[role='treeitem']) a[href]),:where(:is([role='button'],[role='checkbox'],[role='combobox'],[role='menuitem'],[role='menuitemcheckbox'],[role='menuitemradio'],[role='option'],[role='radio'],[role='switch'],[role='tab'],[role='treeitem']) a[href]) *{cursor:default!important}[data-lavish-action],[data-lavish-action] *{cursor:pointer!important}input,textarea,[contenteditable]:not([contenteditable='false']){cursor:text!important}button,select,label,option,input[type='button'],input[type='submit'],input[type='reset'],input[type='checkbox'],input[type='radio'],input[type='file'],input[type='color'],input[type='range'],input[type='image']{cursor:pointer!important}";
6004
6014
  document.head.appendChild(style);
6005
6015
  }
6006
6016
  if (!annotationMode && style) style.remove();
@@ -7051,6 +7061,13 @@ function createArtifactSdk(deriveQueueKey, isNativeInteractive = isNativeInterac
7051
7061
  }
7052
7062
  if (msg.type === "lavish:restoreReviewState") restoreReviewState(msg.state);
7053
7063
  if (msg.type === "lavish:revealElement") revealElement(msg.selector);
7064
+ if (msg.type === "lavish:queuedAnchors") {
7065
+ queuedAnchorSelectors = new Set(Array.isArray(msg.selectors) ? msg.selectors.map(String) : []);
7066
+ }
7067
+ if (msg.type === "lavish:annotateElement" && annotationMode) {
7068
+ const target = safeQuerySelector(msg.selector);
7069
+ if (target) showAnnotationCard(target);
7070
+ }
7054
7071
  });
7055
7072
  function revealElement(selector2) {
7056
7073
  const target = selector2 === "html" ? document.documentElement : safeQuerySelector(selector2);
@@ -7135,6 +7152,14 @@ function createArtifactSdk(deriveQueueKey, isNativeInteractive = isNativeInterac
7135
7152
  ignoreNextClick = false;
7136
7153
  return;
7137
7154
  }
7155
+ const clicked = queuedAnchorSelectors.size ? context(event.target) : null;
7156
+ const node = clicked?.target?.type === "mermaid-node" ? clicked.target.selector : "";
7157
+ const selector2 = [clicked?.selector, node].find((candidate) => candidate && queuedAnchorSelectors.has(candidate));
7158
+ if (selector2) {
7159
+ closeCard();
7160
+ postArtifactMessage("lavish:editQueuedAnchor", { selector: selector2 });
7161
+ return;
7162
+ }
7138
7163
  showAnnotationCard(event.target);
7139
7164
  },
7140
7165
  true
@@ -12315,7 +12340,7 @@ var AGENT_REPLY_INPUT_LIMIT_BYTES = AGENT_REPLY_JSON_LIMIT_BYTES - AGENT_REPLY_J
12315
12340
  var AGENT_REPLY_LIMIT_LABEL = "2 MB JSON request limit";
12316
12341
  var POLL_STATE_HEADER = "lavish-poll-state";
12317
12342
  var CODEX_POLL_WAKE_PATH_GUIDANCE = "Codex detected: completed background tasks may not resume Codex automatically, so keep the poll attached to the active turn.";
12318
- var VERSION = "0.1.80";
12343
+ var VERSION = "0.1.82";
12319
12344
  function detectInvokingAgent(env = process.env) {
12320
12345
  return ["CODEX_SANDBOX", "CODEX_THREAD_ID"].some((key) => Object.hasOwn(env, key)) ? "codex" : "generic";
12321
12346
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "lavish-axi",
3
- "version": "0.1.80",
3
+ "version": "0.1.82",
4
4
  "packageManager": "pnpm@11.1.1",
5
5
  "description": "HTML is the new markdown. Lavish is the new editor for your HTML artifacts.",
6
6
  "type": "module",
package/plugin.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "$schema": "https://agent-plugins.org/schemas/1.0.0/plugin.schema.json",
3
3
  "name": "lavish-axi",
4
- "version": "0.1.80",
4
+ "version": "0.1.82",
5
5
  "description": "HTML is the new markdown. Lavish is the new editor for your HTML artifacts.",
6
6
  "author": {
7
7
  "name": "Kun Chen",