lavish-axi 0.1.67 → 0.1.68

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
@@ -198,8 +198,8 @@ pnpm link
198
198
  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()`.
199
199
  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.
200
200
  On wider screens, queued annotation preview pills and chat history share a scrollable Conversation panel above a sticky composer, so long feedback queues do not push the text box or send controls off screen.
201
- 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.
202
- Composer feedback is visibly queued before the text box clears and stays queued until acknowledged, with persistent recovery guidance if sending fails or takes longer than 10 seconds.
201
+ 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.
202
+ 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.
203
203
  - **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.
204
204
  - **Keyboard shortcuts** - In the chrome composer, Enter sends queued prompts and Shift+Enter inserts a newline.
205
205
  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.
@@ -5,6 +5,7 @@ const sessionData = JSON.parse(sessionDataElement?.textContent || "{}");
5
5
  const key = String(sessionData.key || "");
6
6
  const filePath = String(sessionData.file || "");
7
7
  const queueStorageKey = "lavish-axi:queued:" + key;
8
+ const terminalStorageKey = "lavish-axi:terminal:" + key;
8
9
  // Review-chrome state that must survive a browser refresh. Keyed per session so one review's
9
10
  // triage can never leak into another artifact's.
10
11
  const warningSelectionStorageKey = "lavish-axi:warning-selection:" + key;
@@ -183,7 +184,6 @@ const queued = loadQueuedPrompts();
183
184
  let annotation = true;
184
185
  let ended = false;
185
186
  let agentPresence = "waiting";
186
- let pendingSnapshot = "";
187
187
  const layoutGateEnabled = sessionData.layoutGateEnabled !== false;
188
188
  const configuredLayoutGateMaxHoldMs = Number(sessionData.layoutGateMaxHoldMs);
189
189
  const layoutGateMaxHoldMs =
@@ -211,11 +211,24 @@ let layoutGateTimer;
211
211
  let layoutWarnings = Array.isArray(sessionData.initialLayoutWarnings) ? sessionData.initialLayoutWarnings : [];
212
212
  const selectedWarningIds = new Set(loadJsonState(warningSelectionStorageKey, []));
213
213
  let warningsDrawerOpen = false;
214
- const snapshotRequests = [];
215
- let endAfterSubmit = false;
214
+ /** @typedef {{ done: Promise<boolean>, finish: (succeeded: boolean) => void }} FeedbackPreparation */
215
+ /** @typedef {{ prompts: any[], inFlight: boolean, order: number }} TerminalSubmission */
216
+ /** @type {Map<string, { action: "copy" | "submit", prompts?: any[], endAfter?: boolean, terminal?: TerminalSubmission | null, acknowledgement?: object, order?: number, timeout?: ReturnType<typeof setTimeout> }>} */
217
+ const snapshotRequests = new Map();
218
+ let nextSnapshotRequestId = 0;
219
+ let nextSendOperationOrder = 0;
216
220
  let workingBubble = null;
217
221
  let submitQueuedPromise = null;
218
- let submitQueuedAgain = false;
222
+ const pendingSubmissions = [];
223
+ const deliveredPrompts = new WeakSet();
224
+ const pendingAcknowledgements = new Set();
225
+ /** @type {Set<FeedbackPreparation>} */
226
+ const feedbackPreparations = new Set();
227
+ /** @type {TerminalSubmission | null} */
228
+ let terminalSubmission =
229
+ loadJsonState(terminalStorageKey, false) === true
230
+ ? { prompts: queued.slice(), inFlight: false, order: ++nextSendOperationOrder }
231
+ : null;
219
232
  /** @type {ReturnType<typeof setTimeout> | undefined} */
220
233
  let sendAcknowledgementTimer;
221
234
  let lastScroll = { x: 0, y: 0 };
@@ -249,10 +262,17 @@ const HEALTH_PROBE_TIMEOUT_MS = 4000;
249
262
  // batch. Bound that whole wait so a missing SDK response or a browser/network stall can never look
250
263
  // like a dead button while the user's queue remains safely stored in this tab.
251
264
  const SEND_ACKNOWLEDGEMENT_WARNING_MS = 10_000;
265
+ const TERMINAL_PREPARATION_TIMEOUT_MS = 5000;
266
+ // A DOM snapshot adds useful context, but the reviewer's own words are the payload.
267
+ // If the artifact frame navigated away from the injected SDK (or otherwise stopped
268
+ // answering), deliver those words without a snapshot instead of waiting forever.
269
+ const SNAPSHOT_REQUEST_TIMEOUT_MS = 5000;
252
270
  const SEND_STALLED_COPY =
253
271
  "Still trying to send. Your feedback is saved in this tab. Keep this tab open while Lavish catches up, and check that the server is running.";
254
272
  const SEND_FAILED_COPY =
255
273
  "Could not send. Your feedback is still queued in this tab. Check that Lavish is running, then click Send to Agent to retry.";
274
+ const TERMINAL_SEND_FAILED_COPY =
275
+ "Could not send. Your terminal feedback is still queued in this tab. Check that Lavish is running, then click Send & End to retry the same batch.";
256
276
  const HEALTH_NO_ANSWER_TITLE = "Lavish did not answer.";
257
277
  const HEALTH_NO_ANSWER_COPY =
258
278
  "Lavish did not answer the check, so this page cannot tell whether it is running. Try again in a moment.";
@@ -274,6 +294,9 @@ let copyHintTimer;
274
294
  /** @type {ReturnType<typeof setTimeout> | undefined} */
275
295
  let sendHintTimer;
276
296
  let sendHintPersistent = false;
297
+ /** @type {{ kind: "preparation" | "submission" | "terminal", operation: object, preparationType?: string } | null} */
298
+ let sendFailureOwner = null;
299
+ let sendAcknowledgementWarningVisible = false;
277
300
 
278
301
  function artifactFrameSrcForLoad(load) {
279
302
  const separator = artifactSrc.includes("?") ? "&" : "?";
@@ -380,6 +403,15 @@ function persistQueuedPrompts() {
380
403
  }
381
404
  }
382
405
 
406
+ function persistTerminalReservation(reserved) {
407
+ try {
408
+ if (reserved) sessionStorage.setItem(terminalStorageKey, "true");
409
+ else sessionStorage.removeItem(terminalStorageKey);
410
+ } catch {
411
+ // Session storage can be unavailable; the in-memory reservation still protects this page.
412
+ }
413
+ }
414
+
383
415
  function promptTargetLabel(prompt) {
384
416
  if (prompt?.target?.type === "table-cell") {
385
417
  const semantic = [prompt.target.rowLabel, prompt.target.columnLabel].filter(Boolean).join(" → ");
@@ -423,6 +455,7 @@ function render() {
423
455
 
424
456
  for (const button of annotationPills.querySelectorAll(".pill-close")) {
425
457
  const closeButton = /** @type {HTMLButtonElement} */ (button);
458
+ closeButton.disabled = terminalSubmission !== null;
426
459
  closeButton.addEventListener("click", (event) => removeQueuedPrompt(Number(closeButton.dataset.index), event));
427
460
  }
428
461
  updateSendState();
@@ -431,8 +464,17 @@ function render() {
431
464
  }
432
465
 
433
466
  function updateSendState() {
434
- sendButton.disabled = ended;
435
- sendAndEndButton.disabled = sendButton.disabled;
467
+ const terminalReserved = terminalSubmission !== null;
468
+ // A terminal send owns the exact review batch, so freeze interactions inside the
469
+ // artifact without disabling annotation mode. Disabling annotation mode closes the
470
+ // SDK card and destroys an unsent draft before delivery has actually succeeded.
471
+ frame.inert = ended || terminalReserved;
472
+ sendButton.disabled = ended || terminalReserved;
473
+ sendAndEndButton.disabled = ended || Boolean(terminalSubmission?.inFlight);
474
+ annotationSwitch.disabled = ended || terminalReserved;
475
+ chatInput.disabled = ended || terminalReserved;
476
+ chatAttachButton.disabled = ended || terminalReserved;
477
+ endButton.disabled = ended || terminalReserved;
436
478
  if (warningsQueueButton) updateWarningSelectionState();
437
479
  }
438
480
 
@@ -513,24 +555,49 @@ function hideSendHint(force = false) {
513
555
  sendHint.textContent = DEFAULT_SEND_HINT;
514
556
  sendHint.classList.remove("persistent");
515
557
  sendHintPersistent = false;
558
+ sendAcknowledgementWarningVisible = false;
516
559
  }
517
560
 
518
561
  function armSendAcknowledgementWarning() {
519
- if (sendAcknowledgementTimer || !queued.length) return;
562
+ if (sendAcknowledgementTimer || pendingAcknowledgements.size === 0) return;
520
563
  sendAcknowledgementTimer = setTimeout(() => {
521
564
  sendAcknowledgementTimer = undefined;
522
- if (queued.length) showSendHint(SEND_STALLED_COPY, null, false);
565
+ if (pendingAcknowledgements.size > 0 && !sendFailureOwner) {
566
+ sendAcknowledgementWarningVisible = true;
567
+ showSendHint(SEND_STALLED_COPY, null, false);
568
+ }
523
569
  }, SEND_ACKNOWLEDGEMENT_WARNING_MS);
524
570
  }
525
571
 
526
572
  function clearSendAcknowledgementWarning() {
527
573
  clearTimeout(sendAcknowledgementTimer);
528
574
  sendAcknowledgementTimer = undefined;
575
+ sendAcknowledgementWarningVisible = false;
576
+ }
577
+
578
+ function showQueuedSendFailure(message = SEND_FAILED_COPY, owner = null) {
579
+ showPersistentSendFailure(message, true, owner);
529
580
  }
530
581
 
531
- function showQueuedSendFailure(message = SEND_FAILED_COPY) {
582
+ function showPersistentSendFailure(message, requireQueuedFeedback = false, owner = null) {
532
583
  clearSendAcknowledgementWarning();
533
- if (queued.length) showSendHint(message, null, false);
584
+ if (requireQueuedFeedback && !queued.length) return;
585
+ if (sendFailureOwner?.kind === "preparation") return;
586
+ const currentOrder = Number(sendFailureOwner?.operation?.order) || 0;
587
+ const nextOrder = Number(owner?.operation?.order) || 0;
588
+ if (owner?.kind !== "preparation" && currentOrder > nextOrder) return;
589
+ sendFailureOwner = owner;
590
+ showSendHint(message, null, false);
591
+ }
592
+
593
+ function clearPersistentSendFailure() {
594
+ sendFailureOwner = null;
595
+ hideSendHint(true);
596
+ }
597
+
598
+ function clearPreparationFailure(preparationType) {
599
+ if (sendFailureOwner?.kind !== "preparation" || sendFailureOwner.preparationType !== preparationType) return;
600
+ clearPersistentSendFailure();
534
601
  }
535
602
 
536
603
  function setMenuOpen(button, menu, open) {
@@ -939,11 +1006,12 @@ function scrollElementIntoView(el) {
939
1006
 
940
1007
  function removeQueuedPrompt(index, event) {
941
1008
  if (event) event.stopPropagation();
1009
+ if (terminalSubmission) return;
942
1010
  queued.splice(index, 1);
943
1011
  persistQueuedPrompts();
944
1012
  if (!queued.length) {
945
1013
  clearSendAcknowledgementWarning();
946
- hideSendHint(true);
1014
+ if (sendFailureOwner?.kind !== "preparation") clearPersistentSendFailure();
947
1015
  }
948
1016
  render();
949
1017
  }
@@ -952,9 +1020,28 @@ function promptQueueKey(prompt) {
952
1020
  return prompt && typeof prompt[internalQueueKeyField] === "string" ? prompt[internalQueueKeyField].trim() : "";
953
1021
  }
954
1022
 
955
- function enqueuePrompt(rawPrompt) {
1023
+ function beginFeedbackPreparation() {
1024
+ if (ended || terminalSubmission) return null;
1025
+ /** @type {(succeeded: boolean) => void} */
1026
+ let finishPromise = () => {};
1027
+ const done = new Promise((resolve) => {
1028
+ finishPromise = resolve;
1029
+ });
1030
+ const preparation = {
1031
+ done,
1032
+ finish(succeeded) {
1033
+ if (!feedbackPreparations.delete(preparation)) return;
1034
+ finishPromise(succeeded);
1035
+ },
1036
+ };
1037
+ feedbackPreparations.add(preparation);
1038
+ return preparation;
1039
+ }
1040
+
1041
+ function enqueuePrompt(rawPrompt, /** @type {FeedbackPreparation | null} */ preparation = null) {
1042
+ if ((preparation && !feedbackPreparations.has(preparation)) || (terminalSubmission && !preparation)) return false;
956
1043
  const prompt = sanitizeQueuedPrompt(rawPrompt);
957
- if (!prompt) return;
1044
+ if (!prompt) return false;
958
1045
 
959
1046
  const queueKey = promptQueueKey(prompt);
960
1047
  if (queueKey) {
@@ -970,6 +1057,7 @@ function enqueuePrompt(rawPrompt) {
970
1057
 
971
1058
  persistQueuedPrompts();
972
1059
  render();
1060
+ return true;
973
1061
  }
974
1062
 
975
1063
  function stripInternalPromptFields(prompt) {
@@ -983,9 +1071,46 @@ function postToFrame(message) {
983
1071
  if (frame.contentWindow) frame.contentWindow.postMessage(message, "*");
984
1072
  }
985
1073
 
986
- function requestSnapshot(action) {
987
- snapshotRequests.push(action);
988
- postToFrame({ type: "lavish:requestSnapshot" });
1074
+ function requestSnapshot(action, prompts = [], endAfter = false, terminal = null) {
1075
+ const requestId = "snapshot-" + ++nextSnapshotRequestId;
1076
+ const request =
1077
+ action === "submit"
1078
+ ? { action, prompts, endAfter, terminal, order: terminal?.order || ++nextSendOperationOrder }
1079
+ : { action };
1080
+ snapshotRequests.set(requestId, request);
1081
+ if (action === "submit") {
1082
+ request.acknowledgement = {};
1083
+ pendingAcknowledgements.add(request.acknowledgement);
1084
+ armSendAcknowledgementWarning();
1085
+ request.timeout = setTimeout(() => completeSnapshotRequest(requestId, ""), SNAPSHOT_REQUEST_TIMEOUT_MS);
1086
+ }
1087
+ postToFrame({ type: "lavish:requestSnapshot", snapshot_request_id: requestId });
1088
+ }
1089
+
1090
+ function takeSnapshotRequest(requestId) {
1091
+ if (typeof requestId !== "string" || !requestId || !snapshotRequests.has(requestId)) return null;
1092
+ const request = snapshotRequests.get(requestId);
1093
+ snapshotRequests.delete(requestId);
1094
+ if (request?.timeout) clearTimeout(request.timeout);
1095
+ return request || null;
1096
+ }
1097
+
1098
+ function completeSnapshotRequest(requestId, snapshot) {
1099
+ const request = takeSnapshotRequest(requestId);
1100
+ if (!request) return;
1101
+ if (request.action === "copy") {
1102
+ copyText(snapshot || "");
1103
+ return;
1104
+ }
1105
+
1106
+ submitQueued({
1107
+ prompts: request.prompts || [],
1108
+ domSnapshot: snapshot || "",
1109
+ endAfter: request.endAfter === true,
1110
+ terminal: request.terminal || null,
1111
+ acknowledgement: request.acknowledgement || null,
1112
+ order: request.order || 0,
1113
+ }).catch(() => {});
989
1114
  }
990
1115
 
991
1116
  function createChatAttachmentsController() {
@@ -1188,6 +1313,10 @@ const chatAttachmentController = createChatAttachmentsController();
1188
1313
 
1189
1314
  function sendQueued(endAfter) {
1190
1315
  if (ended) return;
1316
+ if (terminalSubmission) {
1317
+ if (endAfter && !terminalSubmission.inFlight) retryTerminalSubmission();
1318
+ return;
1319
+ }
1191
1320
  closeMenus();
1192
1321
 
1193
1322
  // A pending or failed chip holds back only the COMPOSER message (and an
@@ -1214,50 +1343,138 @@ function sendQueued(endAfter) {
1214
1343
  chatAttachmentController.reset();
1215
1344
  }
1216
1345
  }
1217
- if (!queued.length) {
1218
- if (!chipsBlocked) showSendHint();
1346
+ const shouldEnd = Boolean(endAfter && !chipsBlocked);
1347
+ const preparations = shouldEnd ? [...feedbackPreparations] : [];
1348
+ if (!queued.length && preparations.length === 0) {
1349
+ if (!chipsBlocked && !sendFailureOwner) showSendHint();
1219
1350
  return;
1220
1351
  }
1221
- hideSendHint(true);
1222
- armSendAcknowledgementWarning();
1352
+ if (!sendFailureOwner) hideSendHint(true);
1353
+ if (shouldEnd) {
1354
+ const terminal = {
1355
+ prompts: [],
1356
+ inFlight: true,
1357
+ order: ++nextSendOperationOrder,
1358
+ };
1359
+ terminalSubmission = terminal;
1360
+ updateSendState();
1361
+ finishTerminalPreparation(terminal, preparations);
1362
+ return;
1363
+ }
1364
+ requestSnapshot("submit", queued.slice(), false, null);
1365
+ }
1366
+
1367
+ function finishTerminalPreparation(terminal, preparations) {
1368
+ if (preparations.length === 0) {
1369
+ completeTerminalPreparation(terminal, []);
1370
+ return;
1371
+ }
1372
+ const timeout = setTimeout(() => {
1373
+ if (terminalSubmission !== terminal || ended) return;
1374
+ for (const preparation of preparations) preparation.finish(false);
1375
+ showPersistentSendFailure(
1376
+ "Could not finish preparing all feedback within 5 seconds. This review remains open, and existing queued feedback is still editable.",
1377
+ false,
1378
+ { kind: "preparation", operation: terminal, preparationType: "terminal" },
1379
+ );
1380
+ releaseTerminalSubmission(terminal);
1381
+ }, TERMINAL_PREPARATION_TIMEOUT_MS);
1382
+ Promise.all(preparations.map((preparation) => preparation.done)).then((results) => {
1383
+ clearTimeout(timeout);
1384
+ completeTerminalPreparation(terminal, results);
1385
+ });
1386
+ }
1387
+
1388
+ function completeTerminalPreparation(terminal, results) {
1389
+ if (terminalSubmission !== terminal || ended) return;
1390
+ if (results.some((succeeded) => !succeeded)) {
1391
+ releaseTerminalSubmission(terminal);
1392
+ return;
1393
+ }
1394
+ terminal.prompts = queued.slice();
1395
+ clearPreparationFailure("terminal");
1396
+ if (!terminal.prompts.length) {
1397
+ releaseTerminalSubmission(terminal);
1398
+ if (!sendFailureOwner) showSendHint();
1399
+ return;
1400
+ }
1401
+ // Only make the reservation durable once every preparation has joined the exact
1402
+ // batch. A reload before this point must restore an ordinary editable queue, not
1403
+ // an incomplete terminal submission.
1404
+ persistTerminalReservation(true);
1405
+ requestSnapshot("submit", terminal.prompts, true, terminal);
1406
+ }
1407
+
1408
+ function retryTerminalSubmission() {
1409
+ if (!terminalSubmission || terminalSubmission.inFlight || ended) return;
1410
+ terminalSubmission.inFlight = true;
1411
+ updateSendState();
1412
+ requestSnapshot("submit", terminalSubmission.prompts, true, terminalSubmission);
1413
+ }
1223
1414
 
1224
- if (endAfter && !chipsBlocked) endAfterSubmit = true;
1225
- requestSnapshot("submit");
1415
+ function markTerminalSubmissionFailed(submission) {
1416
+ if (!terminalSubmission || submission.terminal !== terminalSubmission || ended) return;
1417
+ terminalSubmission.inFlight = false;
1418
+ updateSendState();
1419
+ }
1420
+
1421
+ function releaseTerminalSubmission(terminal) {
1422
+ if (terminalSubmission !== terminal || ended) return;
1423
+ terminalSubmission = null;
1424
+ persistTerminalReservation(false);
1425
+ render();
1226
1426
  }
1227
1427
 
1228
- async function submitQueued() {
1428
+ async function submitQueued(submission) {
1429
+ pendingSubmissions.push(submission);
1229
1430
  if (submitQueuedPromise) {
1230
- submitQueuedAgain = true;
1231
1431
  return submitQueuedPromise;
1232
1432
  }
1233
1433
 
1234
- let succeeded = false;
1235
- submitQueuedPromise = submitQueuedOnce();
1434
+ submitQueuedPromise = (async () => {
1435
+ /** @type {unknown} */
1436
+ let firstError = null;
1437
+ while (pendingSubmissions.length && !ended) {
1438
+ const next = pendingSubmissions.shift();
1439
+ if (!next) continue;
1440
+ try {
1441
+ const result = await submitQueuedOnce(next, firstError !== null);
1442
+ if (result === false) markTerminalSubmissionFailed(next);
1443
+ } catch (error) {
1444
+ markTerminalSubmissionFailed(next);
1445
+ if (firstError === null) firstError = error;
1446
+ } finally {
1447
+ if (next.acknowledgement) pendingAcknowledgements.delete(next.acknowledgement);
1448
+ }
1449
+ }
1450
+ if (firstError !== null) throw firstError;
1451
+ })();
1236
1452
  try {
1237
- const result = await submitQueuedPromise;
1238
- succeeded = result !== false;
1239
- return result;
1453
+ return await submitQueuedPromise;
1240
1454
  } finally {
1241
1455
  submitQueuedPromise = null;
1242
- const shouldSubmitAgain = submitQueuedAgain;
1243
- submitQueuedAgain = false;
1244
- if (!succeeded) {
1245
- endAfterSubmit = false;
1246
- } else if (!ended && shouldSubmitAgain) {
1247
- if (queued.length) {
1248
- submitQueued().catch(() => {});
1249
- } else if (endAfterSubmit) {
1250
- endAfterSubmit = false;
1251
- endSession();
1252
- }
1253
- }
1254
1456
  }
1255
1457
  }
1256
1458
 
1257
- async function submitQueuedOnce() {
1258
- const prompts = queued.slice();
1259
- const shouldEndSession = endAfterSubmit;
1260
- const body = { prompts: prompts.map(stripInternalPromptFields), domSnapshot: pendingSnapshot };
1459
+ async function submitQueuedOnce(submission, preserveFailureState = false) {
1460
+ const prompts = submission.prompts.filter((prompt) => !deliveredPrompts.has(prompt));
1461
+ const shouldEndSession = submission.endAfter;
1462
+ if (!prompts.length) {
1463
+ if (shouldEndSession && !ended) {
1464
+ try {
1465
+ await endSession(submission.terminal);
1466
+ } catch (error) {
1467
+ showPersistentSendFailure(TERMINAL_SEND_FAILED_COPY, false, {
1468
+ kind: "terminal",
1469
+ operation: submission.terminal,
1470
+ });
1471
+ throw error;
1472
+ }
1473
+ }
1474
+ settleAcknowledgementGuidance(submission, preserveFailureState);
1475
+ return;
1476
+ }
1477
+ const body = { prompts: prompts.map(stripInternalPromptFields), domSnapshot: submission.domSnapshot };
1261
1478
  if (shouldEndSession) body.endSession = true;
1262
1479
  let response;
1263
1480
  try {
@@ -1266,11 +1483,33 @@ async function submitQueuedOnce() {
1266
1483
  headers: { "content-type": "application/json" },
1267
1484
  body: JSON.stringify(body),
1268
1485
  });
1486
+ // The DOM snapshot is optional context and can be the difference between a
1487
+ // useful terminal batch and Express' request-size limit. Retry exactly once
1488
+ // without it; never mutate or split the user's exact feedback batch.
1489
+ if (response.status === 413 && body.domSnapshot) {
1490
+ body.domSnapshot = "";
1491
+ response = await fetch("/api/" + key + "/prompts", {
1492
+ method: "POST",
1493
+ headers: { "content-type": "application/json" },
1494
+ body: JSON.stringify(body),
1495
+ });
1496
+ }
1269
1497
  } catch (error) {
1270
- showQueuedSendFailure();
1498
+ showQueuedSendFailure(submission.terminal ? TERMINAL_SEND_FAILED_COPY : SEND_FAILED_COPY, {
1499
+ kind: submission.terminal ? "terminal" : "submission",
1500
+ operation: submission.terminal || submission,
1501
+ });
1271
1502
  throw error;
1272
1503
  }
1273
1504
  if (!response.ok) {
1505
+ if (response.status === 413) {
1506
+ showQueuedSendFailure(
1507
+ "Could not send because this feedback is too large. It is still queued. Remove some feedback or attachments, then try again.",
1508
+ { kind: "submission", operation: submission },
1509
+ );
1510
+ if (submission.terminal) releaseTerminalSubmission(submission.terminal);
1511
+ throw new Error("queued feedback exceeds the request size limit");
1512
+ }
1274
1513
  if (response.status === 409) {
1275
1514
  const data = await response.json().catch(() => null);
1276
1515
  // The session already ended before this batch arrived - most likely this chrome missed the
@@ -1278,16 +1517,16 @@ async function submitQueuedOnce() {
1278
1517
  // for another attempt that will be refused the same way.
1279
1518
  if (data?.status === "ended") {
1280
1519
  clearSendAcknowledgementWarning();
1281
- endAfterSubmit = false;
1282
1520
  markSessionEnded();
1283
1521
  return false;
1284
1522
  }
1285
1523
  if (Array.isArray(data?.warnings)) setLayoutWarnings(data.warnings);
1286
- endAfterSubmit = false;
1287
1524
  showQueuedSendFailure(
1288
1525
  "Could not send because the layout issue selection changed. Your feedback is still queued. Review the current issues, then click Send to Agent to retry.",
1526
+ { kind: "submission", operation: submission },
1289
1527
  );
1290
- return false;
1528
+ if (submission.terminal) releaseTerminalSubmission(submission.terminal);
1529
+ return;
1291
1530
  }
1292
1531
  // C4: the server persisted nothing (atomic reject) - the queue below is left
1293
1532
  // intact because the splice only runs on success. Surface exactly what failed
@@ -1295,31 +1534,64 @@ async function submitQueuedOnce() {
1295
1534
  if (response.status === 400) {
1296
1535
  const detail = await response.json().catch(() => ({}));
1297
1536
  if (Array.isArray(detail.rejected) && detail.rejected.length) {
1298
- showQueuedSendFailure(describeAttachmentRejection(detail.rejected, detail.caps));
1537
+ showQueuedSendFailure(describeAttachmentRejection(detail.rejected, detail.caps), {
1538
+ kind: "submission",
1539
+ operation: submission,
1540
+ });
1541
+ if (submission.terminal) releaseTerminalSubmission(submission.terminal);
1299
1542
  } else {
1300
- showQueuedSendFailure();
1543
+ showQueuedSendFailure(submission.terminal ? TERMINAL_SEND_FAILED_COPY : SEND_FAILED_COPY, {
1544
+ kind: submission.terminal ? "terminal" : "submission",
1545
+ operation: submission.terminal || submission,
1546
+ });
1301
1547
  }
1302
1548
  } else {
1303
- showQueuedSendFailure();
1549
+ showQueuedSendFailure(submission.terminal ? TERMINAL_SEND_FAILED_COPY : SEND_FAILED_COPY, {
1550
+ kind: submission.terminal ? "terminal" : "submission",
1551
+ operation: submission.terminal || submission,
1552
+ });
1304
1553
  }
1305
1554
  throw new Error("failed to submit queued prompts");
1306
1555
  }
1307
1556
  for (const prompt of prompts) {
1557
+ deliveredPrompts.add(prompt);
1308
1558
  const index = queued.indexOf(prompt);
1309
1559
  if (index !== -1) queued.splice(index, 1);
1310
1560
  }
1311
1561
  persistQueuedPrompts();
1312
1562
  render();
1313
- clearSendAcknowledgementWarning();
1314
- hideSendHint(true);
1315
- if (queued.length) armSendAcknowledgementWarning();
1563
+ settleAcknowledgementGuidance(submission, preserveFailureState);
1316
1564
  if (shouldEndSession) {
1317
- endAfterSubmit = false;
1318
1565
  markSessionEnded();
1319
1566
  return;
1320
1567
  }
1321
1568
  }
1322
1569
 
1570
+ function submissionResolvesSendFailure(submission) {
1571
+ if (!sendFailureOwner) return true;
1572
+ if (sendFailureOwner.kind === "preparation") return false;
1573
+ if (sendFailureOwner.kind === "terminal") return sendFailureOwner.operation === submission.terminal;
1574
+ const failedSubmission = sendFailureOwner.operation;
1575
+ return (
1576
+ failedSubmission === submission ||
1577
+ (Array.isArray(failedSubmission.prompts) &&
1578
+ failedSubmission.prompts.every((prompt) => deliveredPrompts.has(prompt)))
1579
+ );
1580
+ }
1581
+
1582
+ function settleAcknowledgementGuidance(submission, preserveFailureState) {
1583
+ if (!submissionResolvesSendFailure(submission) || (preserveFailureState && queued.length)) return;
1584
+ const hasLaterAcknowledgement = [...pendingAcknowledgements].some(
1585
+ (acknowledgement) => acknowledgement !== submission.acknowledgement,
1586
+ );
1587
+ if (hasLaterAcknowledgement) {
1588
+ if (!sendAcknowledgementTimer && !sendAcknowledgementWarningVisible) armSendAcknowledgementWarning();
1589
+ return;
1590
+ }
1591
+ clearSendAcknowledgementWarning();
1592
+ clearPersistentSendFailure();
1593
+ }
1594
+
1323
1595
  function normalizeLayoutFindings(value) {
1324
1596
  return Array.isArray(value)
1325
1597
  ? value.filter((item) => item && typeof item === "object" && String(item.severity || "").toLowerCase() === "error")
@@ -1758,7 +2030,7 @@ function updateWarningSelectionState() {
1758
2030
  warningsSelectAll.checked = selectable.length > 0 && selectedCount === selectable.length;
1759
2031
  warningsSelectAll.indeterminate = selectedCount > 0 && selectedCount < selectable.length;
1760
2032
  warningsSelected.textContent = selectedCount === 0 ? "None selected" : selectedCount + " selected";
1761
- warningsQueueButton.disabled = selectedCount === 0 || ended;
2033
+ warningsQueueButton.disabled = selectedCount === 0 || ended || terminalSubmission !== null;
1762
2034
  }
1763
2035
 
1764
2036
  function toggleSelectAllWarnings() {
@@ -1815,10 +2087,15 @@ async function dismissWarning(id) {
1815
2087
  // One queued batch = one ordinary queued prompt. The CLI cannot tell it apart from any other
1816
2088
  // feedback, which is exactly the point: no parallel agent protocol.
1817
2089
  async function queueSelectedWarningFixes() {
1818
- if (ended) return;
2090
+ const preparation = beginFeedbackPreparation();
2091
+ if (!preparation) return;
1819
2092
  const ids = [...selectedWarningIds];
1820
- if (ids.length === 0) return;
2093
+ if (ids.length === 0) {
2094
+ preparation.finish(true);
2095
+ return;
2096
+ }
1821
2097
  warningsQueueButton.disabled = true;
2098
+ let succeeded = false;
1822
2099
  try {
1823
2100
  const response = await fetch("/api/" + key + "/layout-warnings/queue", {
1824
2101
  method: "POST",
@@ -1828,21 +2105,36 @@ async function queueSelectedWarningFixes() {
1828
2105
  if (!response.ok) throw new Error("failed to queue layout warning fixes");
1829
2106
  const data = await response.json();
1830
2107
  if (data.prompt) {
1831
- enqueuePrompt({
1832
- uid: "",
1833
- prompt: data.prompt.prompt,
1834
- selector: "",
1835
- tag: "layout-warnings",
1836
- text: data.prompt.text,
1837
- target: data.prompt.target,
1838
- });
2108
+ if (
2109
+ !enqueuePrompt(
2110
+ {
2111
+ uid: "",
2112
+ prompt: data.prompt.prompt,
2113
+ selector: "",
2114
+ tag: "layout-warnings",
2115
+ text: data.prompt.text,
2116
+ target: data.prompt.target,
2117
+ },
2118
+ preparation,
2119
+ )
2120
+ )
2121
+ throw new Error("failed to retain layout warning fixes");
1839
2122
  }
1840
2123
  selectedWarningIds.clear();
1841
2124
  persistWarningSelection();
1842
2125
  if (Array.isArray(data.warnings)) setLayoutWarnings(data.warnings);
1843
2126
  closeWarningsDrawer({ restoreFocus: true });
2127
+ clearPreparationFailure("layout-warnings");
2128
+ succeeded = true;
1844
2129
  } catch {
2130
+ showPersistentSendFailure(
2131
+ "Could not prepare the selected layout fixes. Review the current issues and try again.",
2132
+ false,
2133
+ { kind: "preparation", operation: preparation, preparationType: "layout-warnings" },
2134
+ );
1845
2135
  updateWarningSelectionState();
2136
+ } finally {
2137
+ preparation.finish(succeeded);
1846
2138
  }
1847
2139
  }
1848
2140
 
@@ -1857,8 +2149,8 @@ async function refreshLayoutWarnings() {
1857
2149
  }
1858
2150
  }
1859
2151
 
1860
- async function endSession() {
1861
- if (ended) return;
2152
+ async function endSession(terminal = null) {
2153
+ if (ended || (terminalSubmission && terminal !== terminalSubmission)) return;
1862
2154
  const response = await fetch("/api/" + key + "/end", { method: "POST" });
1863
2155
  if (!response.ok) throw new Error("failed to end session");
1864
2156
  markSessionEnded();
@@ -1867,6 +2159,10 @@ async function endSession() {
1867
2159
  function markSessionEnded() {
1868
2160
  if (ended) return;
1869
2161
  ended = true;
2162
+ pendingAcknowledgements.clear();
2163
+ clearSendAcknowledgementWarning();
2164
+ terminalSubmission = null;
2165
+ persistTerminalReservation(false);
1870
2166
  cancelArtifactLoadRecovery();
1871
2167
  closeMenus();
1872
2168
  closeShareDialog();
@@ -2619,7 +2915,17 @@ function whiteboardSummaryText(summaryLines) {
2619
2915
  }
2620
2916
 
2621
2917
  async function queueWhiteboardFeedback(index, message, mode) {
2918
+ const preparation = beginFeedbackPreparation();
2919
+ if (!preparation) {
2920
+ postToWhiteboard(index, mode, {
2921
+ type: "lavish-whiteboard:queueResult",
2922
+ ok: false,
2923
+ error: "Feedback delivery is already ending this review.",
2924
+ });
2925
+ return;
2926
+ }
2622
2927
  const diagramId = whiteboardRecord(index).diagramId;
2928
+ let succeeded = false;
2623
2929
  try {
2624
2930
  // Persist the exact reviewed state before queueing, so the paths in the
2625
2931
  // prompt point at what the user actually saw.
@@ -2643,36 +2949,46 @@ async function queueWhiteboardFeedback(index, message, mode) {
2643
2949
  "\n\nEdited scene JSON: " +
2644
2950
  String(files.scene_path || "") +
2645
2951
  (files.preview_path ? "\nPNG preview: " + String(files.preview_path) : "");
2646
- enqueuePrompt({
2647
- uid: "",
2648
- prompt: promptText,
2649
- selector: "",
2650
- tag: "whiteboard",
2651
- text: "Whiteboard: diagram " + (index + 1),
2652
- target: {
2653
- type: "excalidraw-scene",
2654
- diagramIndex: index,
2655
- diagramId,
2656
- sourceHash: String(message.sourceHash || ""),
2657
- scenePath: String(files.scene_path || ""),
2658
- previewPath: String(files.preview_path || ""),
2659
- imageFallback: Boolean(message.imageFallback),
2660
- stats: message.stats && typeof message.stats === "object" ? message.stats : {},
2661
- },
2662
- // Re-queueing the same diagram's whiteboard before sending replaces the
2663
- // earlier unsent prompt instead of stacking duplicates.
2664
- [internalQueueKeyField]: "whiteboard:" + index,
2665
- });
2952
+ if (
2953
+ !enqueuePrompt(
2954
+ {
2955
+ uid: "",
2956
+ prompt: promptText,
2957
+ selector: "",
2958
+ tag: "whiteboard",
2959
+ text: "Whiteboard: diagram " + (index + 1),
2960
+ target: {
2961
+ type: "excalidraw-scene",
2962
+ diagramIndex: index,
2963
+ diagramId,
2964
+ sourceHash: String(message.sourceHash || ""),
2965
+ scenePath: String(files.scene_path || ""),
2966
+ previewPath: String(files.preview_path || ""),
2967
+ imageFallback: Boolean(message.imageFallback),
2968
+ stats: message.stats && typeof message.stats === "object" ? message.stats : {},
2969
+ },
2970
+ // Re-queueing the same diagram's whiteboard before sending replaces the
2971
+ // earlier unsent prompt instead of stacking duplicates.
2972
+ [internalQueueKeyField]: "whiteboard:" + index,
2973
+ },
2974
+ preparation,
2975
+ )
2976
+ )
2977
+ throw new Error("failed to retain whiteboard feedback");
2666
2978
  // Queued from the whiteboard inside the artifact, like any other in-artifact prompt.
2667
2979
  pulseSheetDock();
2668
2980
  postToWhiteboard(index, mode, { type: "lavish-whiteboard:queueResult", ok: true });
2669
2981
  if (mode === "overlay") closeWhiteboard();
2982
+ clearPreparationFailure("whiteboard");
2983
+ succeeded = true;
2670
2984
  } catch (error) {
2671
2985
  postToWhiteboard(index, mode, {
2672
2986
  type: "lavish-whiteboard:queueResult",
2673
2987
  ok: false,
2674
2988
  error: error instanceof Error ? error.message : String(error),
2675
2989
  });
2990
+ } finally {
2991
+ preparation.finish(succeeded);
2676
2992
  }
2677
2993
  }
2678
2994
 
@@ -2987,16 +3303,7 @@ window.addEventListener("message", (event) => {
2987
3303
  pulseSheetDock();
2988
3304
  }
2989
3305
  if (msg.type === "lavish:snapshot") {
2990
- const snapshotAction = snapshotRequests.shift() || "submit";
2991
- if (snapshotAction === "copy") {
2992
- copyText(msg.snapshot || "");
2993
- } else {
2994
- pendingSnapshot = msg.snapshot || "";
2995
- // submitQueuedOnce throws to signal "nothing was delivered" - its own
2996
- // finally already reset the end intent and it left the queue intact for a
2997
- // retry, so the rejection has no remaining consumer here.
2998
- submitQueued().catch(() => {});
2999
- }
3306
+ completeSnapshotRequest(msg.snapshot_request_id, msg.snapshot || "");
3000
3307
  }
3001
3308
  if (msg.type === "lavish:scroll") {
3002
3309
  lastScroll = { x: Number(msg.x) || 0, y: Number(msg.y) || 0 };
@@ -3150,7 +3457,7 @@ async function uploadAttachment(message, reportResult = postToFrame, signal) {
3150
3457
  loadFrame();
3151
3458
 
3152
3459
  function toggleAnnotationMode() {
3153
- if (ended) return;
3460
+ if (ended || terminalSubmission) return;
3154
3461
  annotation = !annotation;
3155
3462
  annotationSwitch.setAttribute("aria-pressed", String(annotation));
3156
3463
  postToFrame({ type: "lavish:setAnnotationMode", enabled: annotation });
package/dist/cli.mjs CHANGED
@@ -6689,7 +6689,10 @@ function createArtifactSdk(deriveQueueKey, isNativeInteractive = isNativeInterac
6689
6689
  activeAttachments?.handleResult(msg.localId, msg.ok, msg.id, msg.error);
6690
6690
  }
6691
6691
  if (msg.type === "lavish:requestSnapshot") {
6692
- postArtifactMessage("lavish:snapshot", { snapshot: snapshot() });
6692
+ postArtifactMessage("lavish:snapshot", {
6693
+ snapshot: snapshot(),
6694
+ snapshot_request_id: typeof msg.snapshot_request_id === "string" ? msg.snapshot_request_id : ""
6695
+ });
6693
6696
  }
6694
6697
  if (msg.type === "lavish:restoreScroll") {
6695
6698
  window.scrollTo(Number(msg.x) || 0, Number(msg.y) || 0);
@@ -10954,7 +10957,7 @@ var POLL_WAKE_PATH_RULES = Object.freeze([
10954
10957
  ]);
10955
10958
  var POLL_SEND_AND_END_RULE = "`Send & End` ends the session. Its final feedback is still delivered once. After that response, polling stops, and the agent must not reopen the session uninvited.";
10956
10959
  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.";
10957
- var VERSION = "0.1.67";
10960
+ var VERSION = "0.1.68";
10958
10961
  function detectInvokingAgent(env = process.env) {
10959
10962
  return ["CODEX_SANDBOX", "CODEX_THREAD_ID"].some((key) => Object.hasOwn(env, key)) ? "codex" : "generic";
10960
10963
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "lavish-axi",
3
- "version": "0.1.67",
3
+ "version": "0.1.68",
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.67",
4
+ "version": "0.1.68",
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",