@erdemtuna/doc-review 0.8.0 → 0.9.0

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.
@@ -23,11 +23,17 @@ import { sanitizeClientRects, sanitizeClipRect, sanitizeRelation } from "./comme
23
23
  import { externalHref } from "./editing.js";
24
24
  import { framePolicy } from "./frame-policy.js";
25
25
  import { createIcon } from "./icons.js";
26
+ import { createComparisonView } from "./comparison-view.js";
27
+ import { createExecutionControls } from "./execution-client.js";
26
28
  import { alignedCardPosition, placeContextualSurface, visibleViewport } from "./positioning.js";
27
29
  import { normalizeReviewMode, reviewConfiguration } from "./review-mode.js";
30
+ import { createCaptureRequests, captureError, sameRender, draftCount, excerpt, changeKind, pendingCaptureTarget, comparisonFreshness } from "./history-client.js";
31
+ import { createCaptureCoordinator, createHistoryController, deliverFeedback, historyPresentation, requireCaptureSuccess } from "./history-coordinator.js";
28
32
 
29
33
  const $ = (id) => document.getElementById(id);
30
- const frame = $("frame");
34
+ let frame = $("frame");
35
+ let previousFrame = null;
36
+ let replacementTimer = null;
31
37
 
32
38
  const state = {
33
39
  sessionId: document.body.dataset.session,
@@ -63,12 +69,23 @@ const state = {
63
69
  renderId: null,
64
70
  frameCapability: null,
65
71
  frameLoading: true,
72
+ frameFailure: null,
66
73
  pendingInitialLoad: false,
67
74
  readyGeneration: null,
68
75
  frameReadyTimer: null,
69
76
  frameReadyRetries: 0,
70
77
  configurationGeneration: null,
71
78
  saveConflict: false,
79
+ comparing: false,
80
+ sending: false,
81
+ domDirty: false,
82
+ pendingReload: false,
83
+ renderSourceHash: null,
84
+ frameReadyAt: 0,
85
+ cleanObservation: null,
86
+ renderExecution: null,
87
+ executionPreference: null,
88
+ ended: false,
72
89
  };
73
90
 
74
91
  const diagnostic = (event, detail = {}) => {
@@ -263,14 +280,58 @@ async function api(path, options) {
263
280
  const detail = await res.json().catch(() => ({}));
264
281
  const error = new Error(detail.error || `Request failed (${res.status})`);
265
282
  error.status = res.status;
283
+ error.code = detail.code;
284
+ error.targets = detail.targets || [];
266
285
  throw error;
267
286
  }
268
287
  return res.json();
269
288
  }
270
289
 
290
+ const executionControls = createExecutionControls({
291
+ api,
292
+ sessionId: state.sessionId,
293
+ elements: {
294
+ static: $("executionStatic"), auto: $("executionAuto"), status: $("executionStatus"),
295
+ details: $("executionDetails"), menu: $("reviewDetails"),
296
+ editDescription: $("modeMenu").querySelector('[data-mode="edit"] small'),
297
+ },
298
+ changed(page) {
299
+ if (!page || page.key !== state.key) return;
300
+ state.executionPreference = page.executionPreference || null;
301
+ state.page = {
302
+ ...state.page,
303
+ executionPreference: page.executionPreference,
304
+ ...state.renderExecution,
305
+ };
306
+ state.savePolicy = reviewConfiguration(state.page, state.reviewMode).savePolicy;
307
+ render();
308
+ },
309
+ failed(error) { toast(error.message || String(error)); },
310
+ });
311
+
312
+ const toolbar = document.querySelector(".toolbar");
313
+ new ResizeObserver(() => {
314
+ document.documentElement.style.setProperty("--toolbar-h", `${toolbar.getBoundingClientRect().height}px`);
315
+ }).observe(toolbar);
316
+
317
+ function renderExecution() {
318
+ const visibleFrame = previousFrame || frame;
319
+ const rendered = visibleFrame.dataset.savePolicy ? {
320
+ executionMode: visibleFrame.dataset.executionMode,
321
+ savePolicy: visibleFrame.dataset.savePolicy,
322
+ executionNotice: visibleFrame.dataset.executionNotice || null,
323
+ } : null;
324
+ executionControls.render(state.page, rendered, {
325
+ pendingReload: state.pendingReload || !!previousFrame,
326
+ loading: state.frameLoading,
327
+ error: state.frameFailure,
328
+ });
329
+ }
330
+
271
331
  const editPipelines = new Map();
272
332
  const editBacklogs = new Map();
273
333
  const editErrors = new Map();
334
+ let editMutationSequence = 0;
274
335
 
275
336
  function editIdentity(payload) {
276
337
  return `${String(payload.label || "")}\u0000${String(payload.kind || "")}`;
@@ -286,6 +347,13 @@ function editBacklog(key) {
286
347
  }
287
348
 
288
349
  function persistEdit(key, payload) {
350
+ if (!payload.renderId && key === state.key && state.renderId) {
351
+ payload = {
352
+ ...payload,
353
+ sessionId: state.sessionId, renderId: state.renderId, generation: state.renderGeneration,
354
+ savePolicy: state.renderExecution?.savePolicy || "feedback-only",
355
+ };
356
+ }
289
357
  const identity = editIdentity(payload);
290
358
  const backlog = editBacklog(key);
291
359
  backlog.set(identity, payload);
@@ -318,6 +386,7 @@ function persistEdit(key, payload) {
318
386
  editPipelines.set(key, current);
319
387
  void current.finally(() => {
320
388
  if (editPipelines.get(key) === current) editPipelines.delete(key);
389
+ applyCleanObservation();
321
390
  });
322
391
  return current;
323
392
  }
@@ -386,6 +455,10 @@ const toFrame = (message) => {
386
455
  };
387
456
 
388
457
  function suspendFrame() {
458
+ captures.cancel();
459
+ captureCoordinator.reset();
460
+ manualCaptureController?.abort();
461
+ for (const request of flushRequests.values()) request.settle(false);
389
462
  clearTimeout(state.frameReadyTimer);
390
463
  state.frameReadyTimer = null;
391
464
  state.frameCapability = null;
@@ -393,10 +466,23 @@ function suspendFrame() {
393
466
  state.frameLoading = true;
394
467
  state.readyGeneration = null;
395
468
  state.configurationGeneration = null;
469
+ state.renderExecution = null;
396
470
  frame.removeAttribute("data-sdk-ready");
397
471
  }
398
472
 
399
473
  function beginFrameTransition() {
474
+ state.frameFailure = null;
475
+ if (state.compose) {
476
+ state.compose.unresolved = true;
477
+ state.compose.relation = "unavailable";
478
+ state.compose.rects = [];
479
+ state.compose.generation = -1;
480
+ }
481
+ state.target = null;
482
+ state.activeGeometry.clear();
483
+ state.baseHash = null;
484
+ state.renderSourceHash = null;
485
+ state.frameReadyAt = 0;
400
486
  state.renderGeneration += 1;
401
487
  suspendFrame();
402
488
  return state.renderGeneration;
@@ -409,10 +495,32 @@ async function registerFrame(key, generation) {
409
495
  });
410
496
  if (state.key !== key || state.renderGeneration !== generation) return false;
411
497
  state.renderId = registered.renderId;
498
+ state.renderSourceHash = registered.sourceHash || null;
412
499
  state.frameCapability = registered.capability;
413
500
  state.frameLoading = true;
414
501
  state.pendingInitialLoad = true;
415
- frame.src = `${ARTIFACT_ORIGIN}${registered.path}`;
502
+ if (state.reloading && frame.hasAttribute("src")) {
503
+ const next = frame.cloneNode(false);
504
+ next.src = `${ARTIFACT_ORIGIN}${registered.path}`;
505
+ next.removeAttribute("data-sdk-ready");
506
+ delete next.dataset.executionMode;
507
+ delete next.dataset.savePolicy;
508
+ delete next.dataset.executionNotice;
509
+ if (previousFrame) frame.remove();
510
+ else {
511
+ previousFrame = frame;
512
+ previousFrame.id = "previousFrame";
513
+ previousFrame.inert = true;
514
+ previousFrame.setAttribute("aria-hidden", "true");
515
+ previousFrame.tabIndex = -1;
516
+ }
517
+ frame = next;
518
+ frame.dataset.replacing = "true";
519
+ attachFrameEvents(frame);
520
+ previousFrame.after(frame);
521
+ }
522
+ if (frame.src !== `${ARTIFACT_ORIGIN}${registered.path}`) frame.src = `${ARTIFACT_ORIGIN}${registered.path}`;
523
+ renderExecution();
416
524
  state.frameReadyTimer = setTimeout(() => {
417
525
  if (state.key !== key || state.renderGeneration !== generation || !state.frameLoading) return;
418
526
  if (state.frameReadyRetries < 1) {
@@ -420,20 +528,37 @@ async function registerFrame(key, generation) {
420
528
  rotateCurrentFrame();
421
529
  return;
422
530
  }
423
- suspendFrame();
424
- toast("The reviewed page did not finish loading. Reload it after fixing the source or local server.");
531
+ failFrame("The reviewed page did not finish loading. Fix the source or local server, then reload.");
425
532
  }, FRAME_READY_TIMEOUT_MS);
426
533
  return true;
427
534
  }
428
535
 
536
+ function finishFrameReplacement() {
537
+ clearTimeout(replacementTimer);
538
+ replacementTimer = null;
539
+ previousFrame?.remove();
540
+ previousFrame = null;
541
+ delete frame.dataset.replacing;
542
+ renderExecution();
543
+ }
544
+
545
+ function failFrame(message) {
546
+ suspendFrame();
547
+ state.frameFailure = message;
548
+ state.pendingReload = true;
549
+ finishFrameReplacement();
550
+ $("reloadNotice").hidden = false;
551
+ $("reloadMessage").textContent = `${message} Reload keeps your comment drafts.`;
552
+ toast(message);
553
+ }
554
+
429
555
  function rotateCurrentFrame() {
430
556
  if (!state.key) return;
431
557
  const key = state.key;
432
558
  const generation = beginFrameTransition();
433
559
  void registerFrame(key, generation).catch((err) => {
434
560
  if (state.key !== key || state.renderGeneration !== generation) return;
435
- suspendFrame();
436
- toast(err.message);
561
+ failFrame(err.message);
437
562
  });
438
563
  }
439
564
 
@@ -442,27 +567,32 @@ function rotateCurrentFrame() {
442
567
  * wait until it has. Navigating away without this drops the last moments of
443
568
  * typing. The timeout covers a torn-down or never-booted frame.
444
569
  */
445
- let flushWaiter = null;
570
+ const flushRequests = new Map();
571
+ let flushSequence = 0;
446
572
  let configurationWaiter = null;
447
- async function flushFrame() {
573
+ async function flushFrame({ strict = false } = {}) {
448
574
  const key = state.key;
449
575
  if (!state.frameLoading && state.frameCapability) {
450
- await new Promise((resolve) => {
451
- const settle = () => {
452
- if (flushWaiter !== settle) return;
453
- flushWaiter = null;
454
- resolve();
576
+ await new Promise((resolve, reject) => {
577
+ const requestId = `flush-${++flushSequence}`;
578
+ const settle = (confirmed = true) => {
579
+ if (!flushRequests.has(requestId)) return;
580
+ flushRequests.delete(requestId);
581
+ clearTimeout(timer);
582
+ if (!confirmed && strict) reject(new Error("The page did not finish saving. Wait for it to load, then retry."));
583
+ else resolve();
455
584
  };
456
- flushWaiter = settle;
457
- toFrame({ type: "eh:flush" });
458
- setTimeout(settle, 400);
585
+ const timer = setTimeout(() => settle(false), strict ? 6000 : 400);
586
+ flushRequests.set(requestId, { settle, strict });
587
+ toFrame({ type: "eh:flush", requestId });
459
588
  });
460
- }
589
+ } else if (strict) throw new Error("The page is not ready. Wait for it to load, then retry.");
461
590
  if (key) await settleEditPersistence(key);
462
591
  }
463
592
 
464
593
  function configureFrame() {
465
- if (state.frameLoading || !state.frameCapability) return Promise.resolve(false);
594
+ if (state.frameLoading || !state.frameCapability || !state.renderExecution) return Promise.resolve(false);
595
+ Object.assign(state.page, state.renderExecution);
466
596
  const configuration = reviewConfiguration(state.page, state.reviewMode);
467
597
  state.savePolicy = configuration.savePolicy;
468
598
  return new Promise((resolve) => {
@@ -473,6 +603,14 @@ function configureFrame() {
473
603
  };
474
604
  configurationWaiter = { ...configuration, settle };
475
605
  toFrame({ type: "eh:configureReview", ...configuration });
606
+ if (previousFrame) {
607
+ clearTimeout(replacementTimer);
608
+ const configuringFrame = frame;
609
+ replacementTimer = setTimeout(() => {
610
+ if (frame !== configuringFrame || !previousFrame) return;
611
+ failFrame("The page did not confirm its review settings. Reload before continuing.");
612
+ }, FRAME_READY_TIMEOUT_MS);
613
+ }
476
614
  setTimeout(() => settle(false), 3000);
477
615
  });
478
616
  }
@@ -485,8 +623,7 @@ async function setReviewMode(nextMode) {
485
623
  render();
486
624
  try {
487
625
  if (state.reviewMode === "edit" && next === "view") {
488
- await flushFrame();
489
- await activeSavePromise;
626
+ await fullSaveBarrier();
490
627
  if (state.saveConflict) {
491
628
  toast("Resolve the save conflict before leaving Edit");
492
629
  announce("Save conflict. Edit mode remains active.");
@@ -515,6 +652,7 @@ async function loadPage(key, { reload = true } = {}) {
515
652
  state.key = key;
516
653
  const page = await api(pageUrl(key, state.sessionId));
517
654
  if (state.key !== key || state.renderGeneration !== generation) return;
655
+ state.executionPreference = page.executionPreference || null;
518
656
  reconcilePage(page, { reason: "reload", advance: false });
519
657
  state.savePolicy = reviewConfiguration(state.page, state.reviewMode).savePolicy;
520
658
  state.framePolicy = framePolicy(state.page, ARTIFACT_ORIGIN);
@@ -530,6 +668,8 @@ async function loadPage(key, { reload = true } = {}) {
530
668
  state.sent = false;
531
669
  state.dynamic = false;
532
670
  state.baseHash = null;
671
+ state.saveConflict = false;
672
+ state.domDirty = false;
533
673
  clearTimeout(retryTimer);
534
674
  if (reload) {
535
675
  state.reloading = true;
@@ -540,7 +680,7 @@ async function loadPage(key, { reload = true } = {}) {
540
680
  // the direct edits — which reads as data loss unless we say what happened.
541
681
  const edits = state.page.edits ? state.page.edits.length : 0;
542
682
  if (returning && state.page.feedbackOnly && edits > 0) {
543
- toast(`This page renders from your dev server — ${edits} ${edits === 1 ? "edit is" : "edits are"} queued for the agent`);
683
+ toast(`${edits} ${edits === 1 ? "edit is" : "edits are"} queued for the agent to apply to the source`);
544
684
  }
545
685
  }
546
686
 
@@ -563,6 +703,11 @@ const clock = () => new Date().toLocaleTimeString([], { hour: "numeric", minute:
563
703
  function render() {
564
704
  const page = state.page;
565
705
  if (!page) return;
706
+ // Source updates cannot change the policy of a frame awaiting draft-safe reload.
707
+ if (state.renderExecution) Object.assign(page, state.renderExecution);
708
+ if (state.executionPreference) page.executionPreference = state.executionPreference;
709
+ renderExecution();
710
+ syncFrameInteraction();
566
711
  const editWasFocused = skipEditCaptureOnce ? false : captureEditState();
567
712
  skipEditCaptureOnce = false;
568
713
  document.title = page.filename || 'doc-review';
@@ -575,7 +720,7 @@ function render() {
575
720
  $("empty").hidden = comments.length > 0 || !!state.compose;
576
721
  $("modeLabel").textContent = state.reviewMode === "edit" ? "Edit" : "View";
577
722
  replaceIcon($("modeIcon"), state.reviewMode === "edit" ? "pencil" : "eye");
578
- $("modeButton").disabled = state.modeApplying;
723
+ $("modeButton").disabled = state.modeApplying || state.comparing || state.sending || !state.renderExecution;
579
724
  for (const item of $("modeMenu").querySelectorAll("[data-mode]")) {
580
725
  const checked = item.dataset.mode === state.reviewMode;
581
726
  item.setAttribute("aria-checked", String(checked));
@@ -667,7 +812,8 @@ function render() {
667
812
  row.append(label, count);
668
813
  row.addEventListener("click", async () => {
669
814
  try {
670
- await flushFrame();
815
+ if (draftCount(state)) throw new Error("Save or cancel open comment drafts before changing pages");
816
+ await fullSaveBarrier();
671
817
  suspendFrame();
672
818
  await api(`/api/session/${state.sessionId}/goto`, {
673
819
  method: "POST",
@@ -693,8 +839,8 @@ function render() {
693
839
  const delivered = state.agent === "working";
694
840
  const stranded = state.agent === "stranded";
695
841
  const busy = delivered || stranded || state.sent;
696
- send.disabled = (total === 0 && !hasNote) || busy;
697
- send.textContent = delivered
842
+ send.disabled = (total === 0 && !hasNote) || busy || state.sending;
843
+ send.textContent = state.sending ? "Sending feedback…" : delivered
698
844
  ? "Feedback delivered"
699
845
  : stranded
700
846
  ? "Sent — agent is not listening"
@@ -713,6 +859,9 @@ function render() {
713
859
  // Server-authoritative, so it survives a browser refresh.
714
860
  $("handoff").hidden = !stranded;
715
861
  if (stranded) $("handoffCmd").textContent = handoffPrompt(state.pollCommand || page.pollCommand);
862
+ const excluded = draftCount(state);
863
+ $("draftWarning").hidden = excluded === 0;
864
+ $("draftWarning").textContent = `${excluded} open ${excluded === 1 ? "draft is" : "drafts are"} not included. Save comments explicitly before sending.`;
716
865
  restoreTransientFocus(editWasFocused);
717
866
  }
718
867
 
@@ -721,6 +870,15 @@ function positionCompose() {
721
870
  setComposePlacement("hidden");
722
871
  return;
723
872
  }
873
+ if (state.compose.unresolved) {
874
+ const surface = $("compose");
875
+ surface.hidden = false;
876
+ surface.classList.add("sheet");
877
+ surface.classList.remove("edge-top", "edge-bottom");
878
+ $("composeDirection").hidden = true;
879
+ setComposePlacement("sheet");
880
+ return;
881
+ }
724
882
  const viewport = visibleViewport();
725
883
  const frameRect = frame.getBoundingClientRect();
726
884
  const surface = $("compose");
@@ -731,7 +889,7 @@ function positionCompose() {
731
889
  surfaceWidth: Math.min(340, viewport.width - 24),
732
890
  surfaceHeight: Math.max(190, surface.offsetHeight),
733
891
  narrow: matchMedia("(max-width: 720px)").matches,
734
- toolbarHeight: 48,
892
+ toolbarHeight: document.querySelector(".toolbar").getBoundingClientRect().height,
735
893
  });
736
894
  surface.classList.toggle("sheet", placement.kind === "sheet");
737
895
  surface.classList.toggle("edge-top", placement.kind === "edge-top");
@@ -1217,6 +1375,11 @@ async function executeCommentPatch(edit, startEpoch) {
1217
1375
 
1218
1376
  function renderSave() {
1219
1377
  const line = $("saveLine");
1378
+ if (state.saveConflict) {
1379
+ line.className = "save-line failed";
1380
+ $("saveText").textContent = "Source changed — reload latest before saving. Your page edits remain in this tab.";
1381
+ return;
1382
+ }
1220
1383
  if (state.page && state.page.kind === "url") {
1221
1384
  line.className = "save-line dynamic";
1222
1385
  $("saveText").textContent = "Localhost page — your direct edits go to the agent for source updates";
@@ -1237,7 +1400,7 @@ function renderSave() {
1237
1400
  line.className = `save-line ${state.save === "saving" ? "saving" : state.save === "failed" ? "failed" : ""}`;
1238
1401
  const name = state.page ? state.page.filename : "";
1239
1402
  if (state.save === "saving") $("saveText").textContent = `Saving to ${name}…`;
1240
- else if (state.save === "failed") $("saveText").textContent = "Couldn't save — retrying…";
1403
+ else if (state.save === "failed") $("saveText").textContent = "Couldn't save — retry before sending";
1241
1404
  else $("saveText").textContent = state.savedAt ? `Saved to ${name} · ${state.savedAt}` : `Saved to ${name}`;
1242
1405
  }
1243
1406
 
@@ -1345,7 +1508,7 @@ async function executeComposeSubmit() {
1345
1508
  try {
1346
1509
  const result = await api(`/api/page/${state.key}/comment`, {
1347
1510
  method: "POST",
1348
- body: JSON.stringify({ kind: compose.kind, quote: compose.quote, anchor: compose.anchor, feedback }),
1511
+ body: JSON.stringify({ kind: compose.kind, quote: compose.quote, anchor: compose.unresolved ? null : compose.anchor, feedback }),
1349
1512
  });
1350
1513
  toFrame({
1351
1514
  type: "eh:commit",
@@ -1376,28 +1539,398 @@ async function executeComposeSubmit() {
1376
1539
  }
1377
1540
  }
1378
1541
 
1542
+ // ------------------------------------------------------------ review history
1543
+
1544
+ const currentRender = () => ({
1545
+ key: state.key, renderId: state.renderId, generation: state.renderGeneration,
1546
+ loading: state.frameLoading,
1547
+ });
1548
+ const captures = createCaptureRequests({ send: toFrame, current: currentRender });
1549
+ const comparisonView = createComparisonView($("changeDetail"));
1550
+ const historyUrl = (suffix = "") => `/api/session/${state.sessionId}/history${suffix}`;
1551
+ const historyController = createHistoryController({
1552
+ request: (suffix) => api(historyUrl(suffix)), changed: renderHistory, refreshed: scheduleResultCapture,
1553
+ });
1554
+ const history = historyController.state;
1555
+ let manualCaptureController = null;
1556
+ const captureCoordinator = createCaptureCoordinator({
1557
+ ready: () => !state.ended && !state.frameLoading && !state.pendingReload && !state.domDirty &&
1558
+ !state.saveConflict && !state.sending && !history.captureBusy && !history.finalizing &&
1559
+ (state.page?.kind === "url" || !!state.baseHash),
1560
+ candidates: () => [...history.rounds].reverse().flatMap((round) => {
1561
+ const target = pendingCaptureTarget(round, state.key, state.frameReadyAt);
1562
+ return target ? [{ id: `${roundId(round)}:${state.renderId}:${state.renderGeneration}`, round, target }] : [];
1563
+ }),
1564
+ capture: ({ round, target }, signal) => captureResult(round, target, true, signal),
1565
+ });
1566
+
1567
+ async function fullSaveBarrier() {
1568
+ const identity = currentRender();
1569
+ await flushFrame({ strict: true });
1570
+ await activeSavePromise;
1571
+ if (state.save === "failed" && !state.saveConflict && lastSave?.key === state.key && lastSave.generation === state.renderGeneration) {
1572
+ await saveNow(lastSave.html);
1573
+ }
1574
+ await settleEditPersistence(identity.key);
1575
+ applyCleanObservation();
1576
+ if (!sameRender(identity, currentRender())) throw new Error("The page changed while saving. Retry on the latest page.");
1577
+ if (state.saveConflict) throw new Error("Resolve the save conflict: the source changed outside this review. Reload latest; your comment drafts will be kept.");
1578
+ if (state.save === "failed" || (state.savePolicy === "writable" && !state.dynamic && state.domDirty)) {
1579
+ throw new Error("Your page edits have not finished saving. They have not been sent.");
1580
+ }
1581
+ if (state.savePolicy !== "writable" || state.dynamic) state.domDirty = false;
1582
+ }
1583
+
1584
+ async function captureStablePage(options) {
1585
+ const identity = currentRender();
1586
+ const captured = await captures.request(options);
1587
+ const capturedSaveSequence = saveSequence;
1588
+ const capturedEditSequence = editMutationSequence;
1589
+ // The SDK flushes its debounces before capture; their server writes must finish too.
1590
+ await activeSavePromise;
1591
+ await settleEditPersistence(identity.key);
1592
+ applyCleanObservation();
1593
+ if (options?.signal?.aborted) throw captureError("CAPTURE_CANCELLED", "Capture cancelled");
1594
+ if (!sameRender(identity, currentRender()) || state.saveConflict || state.save === "failed") {
1595
+ throw new Error("The page changed or could not be saved during capture. Recapture the latest version.");
1596
+ }
1597
+ if (saveSequence !== capturedSaveSequence || editMutationSequence !== capturedEditSequence) {
1598
+ throw new Error("The page changed while its captured edits were saving. Recapture the latest version.");
1599
+ }
1600
+ return captured;
1601
+ }
1602
+
1603
+ function syncFrameInteraction() {
1604
+ frame.inert = state.comparing || state.sending;
1605
+ }
1606
+
1607
+ function roundId(round) { return round?.id || round?.roundId; }
1608
+ function roundTargets(round) { return round?.targets || []; }
1609
+ function selectedTarget() {
1610
+ const targets = roundTargets(history.round);
1611
+ return targets.find((target) => target.key === history.targetKey) || targets[0] || null;
1612
+ }
1613
+ function targetComparison(target) { return target?.comparison || {}; }
1614
+ function comparisonItems() {
1615
+ const comparison = targetComparison(selectedTarget());
1616
+ const value = history.mode === "source" ? comparison.source : comparison.content;
1617
+ return value?.changes || value?.items || (history.mode === "content" ? comparison.changes || comparison.items : []) || [];
1618
+ }
1619
+
1620
+ const refreshHistory = () => state.ended ? Promise.resolve(false) : historyController.refresh();
1621
+
1622
+ function renderHistory() {
1623
+ syncFrameInteraction();
1624
+ const focusedMode = document.activeElement?.dataset.comparisonMode;
1625
+ const picker = $("roundPicker");
1626
+ picker.replaceChildren();
1627
+ for (const [index, round] of history.rounds.entries()) {
1628
+ const option = document.createElement("option");
1629
+ option.value = roundId(round);
1630
+ option.textContent = `Round ${round.ordinal || round.number || history.rounds.length - index} · ${round.captureStatus === "ready" ? "completed" : round.captureStatus || round.status || round.feedbackStatus || "pending"}`;
1631
+ picker.append(option);
1632
+ }
1633
+ picker.value = history.selectedId || "";
1634
+ picker.disabled = !history.rounds.length;
1635
+ const targets = roundTargets(history.round);
1636
+ const target = selectedTarget();
1637
+ if (history.round) history.targetKey = target?.key || null;
1638
+ $("historyTarget").replaceChildren();
1639
+ for (const item of targets) {
1640
+ const option = document.createElement("option");
1641
+ option.value = item.key;
1642
+ option.textContent = item.filename || item.label || item.key;
1643
+ $("historyTarget").append(option);
1644
+ }
1645
+ $("historyTarget").value = history.targetKey || "";
1646
+ $("historyTargetLabel").hidden = targets.length < 2;
1647
+ const comparison = targetComparison(target);
1648
+ const failedCapture = history.failures.get(`${roundId(history.round)}:${target?.key}`);
1649
+ const presentation = historyPresentation({ ...history, target, comparison, failure: failedCapture });
1650
+ const modes = presentation.modes;
1651
+ history.mode = modes.includes(history.preferredMode) ? history.preferredMode : modes[0] || "content";
1652
+ const displayedComparison = comparison[history.mode] || {};
1653
+ $("historyUnavailable").replaceChildren();
1654
+ for (const mode of ["content", "source"]) {
1655
+ const representation = comparison[mode];
1656
+ if (representation && representation.available === false) {
1657
+ const reason = document.createElement("p");
1658
+ reason.textContent = `${mode === "content" ? "Content" : "Source"} comparison unavailable: ${representation.reason || "This representation could not be compared."}`;
1659
+ $("historyUnavailable").append(reason);
1660
+ }
1661
+ }
1662
+ $("historyUnavailable").hidden = !$("historyUnavailable").childElementCount;
1663
+ $("historyTiming").replaceChildren();
1664
+ for (const [label, timestamp] of [
1665
+ ["Before captured", displayedComparison.beforeCapturedAt],
1666
+ ["Agent acknowledged", history.round?.acknowledgedAt],
1667
+ ["After captured", displayedComparison.afterCapturedAt],
1668
+ ]) {
1669
+ const row = document.createElement("div");
1670
+ const term = document.createElement("dt");
1671
+ term.textContent = label;
1672
+ const value = document.createElement("dd");
1673
+ const date = timestamp == null ? null : new Date(timestamp);
1674
+ value.textContent = date && Number.isFinite(date.getTime()) ? date.toLocaleString() : "Not available";
1675
+ row.append(term, value);
1676
+ $("historyTiming").append(row);
1677
+ }
1678
+ $("historyTiming").hidden = !history.round;
1679
+ const afterAt = displayedComparison.afterCapturedAt == null ? NaN : new Date(displayedComparison.afterCapturedAt).getTime();
1680
+ const acknowledgedAt = history.round?.acknowledgedAt == null ? NaN : new Date(history.round.acknowledgedAt).getTime();
1681
+ const delay = afterAt - acknowledgedAt;
1682
+ $("historyCaptureDelay").hidden = !Number.isFinite(delay);
1683
+ $("historyCaptureDelay").textContent = delay >= 0
1684
+ ? `Result captured ${Math.round(delay / 1000)} seconds after acknowledgment—not at the acknowledgment instant. A delayed capture can include later changes.`
1685
+ : "This result snapshot predates acknowledgment. It is not proof of the page state at acknowledgment.";
1686
+ $("historyCurrentStatus").hidden = !target?.resultRevisionId && !displayedComparison.afterCapturedAt;
1687
+ $("historyCurrentStatus").textContent = comparisonFreshness(displayedComparison, target?.key, {
1688
+ key: state.key, kind: state.page?.kind, ready: !state.frameLoading && !!state.frameReadyAt,
1689
+ pendingReload: state.pendingReload, dirty: state.domDirty || state.saveConflict,
1690
+ sourceHash: state.renderSourceHash || state.baseHash,
1691
+ sessionId: state.sessionId, generation: state.renderGeneration,
1692
+ });
1693
+ const viewComparison = history.mode === "content" ? displayedComparison.viewComparison : null;
1694
+ $("historyViewCoverage").hidden = !viewComparison?.message;
1695
+ $("historyViewCoverage").textContent = viewComparison?.message || "";
1696
+ $("historyViewCoverage").dataset.status = viewComparison?.status || "";
1697
+ $("comparisonModes").replaceChildren();
1698
+ for (const mode of modes) {
1699
+ const button = makeAction(mode === "source" ? "Source" : "Content", "btn-ghost");
1700
+ button.dataset.comparisonMode = mode;
1701
+ button.setAttribute("aria-pressed", String(history.mode === mode));
1702
+ button.addEventListener("click", () => {
1703
+ history.preferredMode = mode;
1704
+ history.mode = mode;
1705
+ history.index = 0;
1706
+ renderHistory();
1707
+ $("comparisonModes").querySelector(`[data-comparison-mode="${mode}"]`)?.focus({ preventScroll: true });
1708
+ });
1709
+ $("comparisonModes").append(button);
1710
+ }
1711
+ if (focusedMode) $("comparisonModes").querySelector(`[data-comparison-mode="${focusedMode}"]`)?.focus({ preventScroll: true });
1712
+ if ($("historyStatus").textContent !== presentation.message) $("historyStatus").textContent = presentation.message;
1713
+ $("historyStatus").dataset.state = presentation.state;
1714
+ const detail = history.error?.message || failedCapture || target?.capture?.error;
1715
+ $("historyError").hidden = !detail;
1716
+ $("historyError").textContent = detail || "";
1717
+ const captureAllowed = target && !target.resultRevisionId && target.capture?.status !== "unavailable" && history.round?.feedbackStatus === "acknowledged";
1718
+ $("captureResult").hidden = !captureAllowed;
1719
+ $("captureResult").disabled = history.captureBusy || history.finalizing || target?.key !== state.key || state.frameLoading;
1720
+ $("captureResult").textContent = history.captureBusy ? "Capturing result…" : "Capture result";
1721
+ const canFinalize = target && !target.resultRevisionId && history.round?.feedbackStatus === "acknowledged" &&
1722
+ ["pending", "failed"].includes(target.capture?.status || target.captureStatus || "pending");
1723
+ $("finishCapture").hidden = !canFinalize;
1724
+ $("finishCaptureHelp").hidden = !canFinalize;
1725
+ $("finishCapture").disabled = history.finalizing || history.captureBusy;
1726
+ $("finishCapture").setAttribute("aria-describedby", "finishCaptureHelp");
1727
+ $("finishCapture").textContent = history.finalizing ? "Finishing…" : "Finish with available snapshots";
1728
+ const items = comparisonItems();
1729
+ const limitations = [...new Set([
1730
+ ...(comparison.content?.limitations || []),
1731
+ ...(comparison.source?.limitations || []),
1732
+ ])];
1733
+ $("historyLimitations").hidden = limitations.length === 0;
1734
+ $("historyLimitationsList").replaceChildren();
1735
+ for (const limitation of limitations) {
1736
+ const line = document.createElement("li");
1737
+ line.textContent = String(limitation).replaceAll("-", " ").replaceAll("_", " ");
1738
+ $("historyLimitationsList").append(line);
1739
+ }
1740
+ const counts = displayedComparison.counts;
1741
+ $("historyCounts").textContent = modes.length && counts
1742
+ ? `${counts.added} Added · ${counts.modified} Modified · ${counts.removed} Removed`
1743
+ : "";
1744
+ $("historyCounts").hidden = !counts;
1745
+ history.index = Math.max(0, Math.min(history.index, items.length - 1));
1746
+ $("changeDetail").hidden = !modes.length;
1747
+ const comparisonHeader = $("historyPanel").querySelector(".comparison-header");
1748
+ if (comparisonHeader) comparisonHeader.hidden = !modes.length;
1749
+ if (modes.length) {
1750
+ comparisonView.render({ ...displayedComparison, changes: items }, {
1751
+ mode: history.mode, key: `${history.selectedId}:${history.targetKey}`,
1752
+ });
1753
+ comparisonView.select(history.index);
1754
+ }
1755
+ $("changeJump").replaceChildren();
1756
+ for (const [index, item] of items.entries()) {
1757
+ const option = document.createElement("option");
1758
+ option.value = index;
1759
+ option.textContent = `${index + 1}. ${changeKind(item)} · ${tidyMiddle(item.label || excerpt(item.after) || excerpt(item.before) || "Structure", 65)}`;
1760
+ $("changeJump").append(option);
1761
+ }
1762
+ $("changeJump").value = String(history.index);
1763
+ $("changeNavigation").hidden = items.length < 2;
1764
+ $("previousChange").disabled = history.index === 0;
1765
+ $("nextChange").disabled = history.index >= items.length - 1;
1766
+ $("changePosition").textContent = `${items.length ? history.index + 1 : 0} of ${items.length}`;
1767
+ }
1768
+
1769
+ async function captureResult(round, target, automatic = false, signal) {
1770
+ if (history.captureBusy || !round || target?.key !== state.key) return;
1771
+ const manualController = automatic ? null : new AbortController();
1772
+ if (manualController) {
1773
+ manualCaptureController = manualController;
1774
+ signal = manualController.signal;
1775
+ }
1776
+ const identity = currentRender();
1777
+ history.captureBusy = true;
1778
+ renderHistory();
1779
+ let phase = "saving";
1780
+ try {
1781
+ await fullSaveBarrier();
1782
+ if (signal?.aborted || state.ended || !sameRender(identity, currentRender())) throw captureError("CAPTURE_CANCELLED", "Capture cancelled");
1783
+ phase = "snapshot";
1784
+ const captured = await captureStablePage({ signal });
1785
+ phase = "publishing";
1786
+ const result = await api(historyUrl(`/${roundId(round)}/capture`), {
1787
+ method: "POST",
1788
+ signal,
1789
+ body: JSON.stringify({
1790
+ sessionId: state.sessionId, roundId: roundId(round), key: captured.key,
1791
+ renderId: captured.renderId, generation: captured.generation,
1792
+ semantic: captured.semantic, expectedSourceHash: captured.sourceHash || state.baseHash || state.renderSourceHash,
1793
+ semanticCapturedAt: captured.semanticCapturedAt, view: captured.view,
1794
+ manual: !automatic,
1795
+ }),
1796
+ });
1797
+ requireCaptureSuccess(result, target.key);
1798
+ if (signal?.aborted || state.ended || !sameRender(identity, currentRender())) return;
1799
+ history.failures.delete(`${roundId(round)}:${target.key}`);
1800
+ await refreshHistory();
1801
+ } catch (err) {
1802
+ if (signal?.aborted || state.ended || !sameRender(identity, currentRender()) || err.code === "CAPTURE_CANCELLED") return;
1803
+ history.failures.set(`${roundId(round)}:${target.key}`, err.message);
1804
+ if (phase === "snapshot" && sameRender(identity, currentRender()) && !state.frameLoading) {
1805
+ await api(historyUrl(`/${roundId(round)}/capture`), {
1806
+ method: "POST",
1807
+ signal,
1808
+ body: JSON.stringify({
1809
+ key: identity.key, renderId: identity.renderId, generation: identity.generation,
1810
+ manual: !automatic, error: String(err.message).slice(0, 500),
1811
+ }),
1812
+ }).catch(() => {});
1813
+ }
1814
+ $("historyError").hidden = false;
1815
+ $("historyError").textContent = `${err.message} Use Capture result to retry; existing captures are unchanged.`;
1816
+ if (automatic) throw err;
1817
+ } finally {
1818
+ if (manualCaptureController === manualController) manualCaptureController = null;
1819
+ history.captureBusy = false;
1820
+ renderHistory();
1821
+ scheduleResultCapture();
1822
+ }
1823
+ }
1824
+
1825
+ function scheduleResultCapture() {
1826
+ captureCoordinator.tick();
1827
+ }
1828
+
1829
+ function setHistoryView(comparing) {
1830
+ captureEditState();
1831
+ state.comparing = comparing;
1832
+ closeModeMenu("history-switch");
1833
+ document.body.classList.toggle("comparing", comparing);
1834
+ syncFrameInteraction();
1835
+ frame.setAttribute("aria-hidden", String(comparing));
1836
+ $("historyPanel").hidden = !comparing;
1837
+ $("latestVersion").setAttribute("aria-pressed", String(!comparing));
1838
+ $("seeChanges").setAttribute("aria-pressed", String(comparing));
1839
+ if (!comparing && state.pendingReload) $("reloadNotice").hidden = false;
1840
+ if (comparing) {
1841
+ closeDrawer();
1842
+ void refreshHistory();
1843
+ }
1844
+ render();
1845
+ }
1846
+ $("latestVersion").addEventListener("click", () => setHistoryView(false));
1847
+ $("seeChanges").addEventListener("click", () => setHistoryView(true));
1848
+ $("roundPicker").addEventListener("change", () => void historyController.selectRound($("roundPicker").value));
1849
+ $("historyTarget").addEventListener("change", () => void historyController.selectTarget($("historyTarget").value));
1850
+ function jumpToChange(index) {
1851
+ history.index = index;
1852
+ renderHistory();
1853
+ comparisonView.select(history.index, { scroll: true });
1854
+ }
1855
+ $("previousChange").addEventListener("click", () => jumpToChange(history.index - 1));
1856
+ $("nextChange").addEventListener("click", () => jumpToChange(history.index + 1));
1857
+ $("changeJump").addEventListener("change", (event) => jumpToChange(Number(event.target.value)));
1858
+ $("captureResult").addEventListener("click", () => void captureResult(history.round, selectedTarget()));
1859
+ $("finishCapture").addEventListener("click", async () => {
1860
+ const target = selectedTarget();
1861
+ const round = history.round;
1862
+ if (!target || !round || history.finalizing || history.captureBusy) return;
1863
+ history.finalizing = true;
1864
+ renderHistory();
1865
+ try {
1866
+ await api(historyUrl(`/${roundId(round)}/capture`), {
1867
+ method: "POST",
1868
+ body: JSON.stringify({ key: target.key, manual: true, finalUnavailable: true }),
1869
+ });
1870
+ history.failures.delete(`${roundId(round)}:${target.key}`);
1871
+ await refreshHistory();
1872
+ announce("Finished with available snapshots. Missing Content remains unavailable.");
1873
+ } catch (err) {
1874
+ $("historyError").hidden = false;
1875
+ $("historyError").textContent = `${err.message} The capture is still open; you can retry.`;
1876
+ } finally {
1877
+ history.finalizing = false;
1878
+ renderHistory();
1879
+ }
1880
+ });
1881
+
1379
1882
  // -------------------------------------------------------------------- saving
1380
1883
 
1381
1884
  let retryTimer = null;
1382
1885
  let saveAttempts = 0;
1383
1886
  let activeSavePromise = Promise.resolve(true);
1887
+ let saveSequence = 0;
1888
+ let savePending = 0;
1889
+ let lastSave = null;
1890
+
1891
+ function applyCleanObservation() {
1892
+ const clean = state.cleanObservation;
1893
+ if (!clean || clean.key !== state.key || clean.generation !== state.renderGeneration ||
1894
+ clean.saveSequence !== saveSequence || clean.editSequence !== editMutationSequence ||
1895
+ savePending || state.save === "failed" || state.saveConflict ||
1896
+ editBacklogs.get(state.key)?.size || editPipelines.has(state.key)) return;
1897
+ state.domDirty = false;
1898
+ state.save = state.savedAt ? "saved" : "idle";
1899
+ renderSave();
1900
+ }
1384
1901
 
1385
1902
  /** A fresh serialization from the SDK always starts a fresh attempt budget. */
1386
1903
  function saveNow(html) {
1904
+ const sequence = ++saveSequence;
1905
+ const key = state.key;
1906
+ const generation = state.renderGeneration;
1907
+ lastSave = { html, key, generation };
1908
+ savePending += 1;
1909
+ state.domDirty = true;
1387
1910
  saveAttempts = 0;
1388
- state.saveConflict = false;
1389
- activeSavePromise = saveHtml(html, state.key);
1911
+ activeSavePromise = activeSavePromise.catch(() => false).then(async () => {
1912
+ if (state.saveConflict || key !== state.key || generation !== state.renderGeneration) return false;
1913
+ const saved = await saveHtml(html, key, generation);
1914
+ if (saved && saveSequence === sequence) state.domDirty = false;
1915
+ return saved;
1916
+ }).finally(() => {
1917
+ savePending -= 1;
1918
+ applyCleanObservation();
1919
+ });
1390
1920
  return activeSavePromise;
1391
1921
  }
1392
1922
 
1393
- async function saveHtml(html, key) {
1923
+ async function saveHtml(html, key, generation = state.renderGeneration) {
1394
1924
  clearTimeout(retryTimer);
1395
1925
  // A retry that outlived a page switch must never write into the new page.
1396
- if (key !== state.key) return false;
1926
+ if (key !== state.key || generation !== state.renderGeneration || state.saveConflict) return false;
1397
1927
  if (!state.baseHash) {
1398
1928
  // The on-disk baseline hasn't arrived yet; wait for it rather than write blind.
1399
1929
  saveAttempts += 1;
1400
- if (saveAttempts <= 20) retryTimer = setTimeout(() => saveHtml(html, key), 500);
1930
+ if (saveAttempts <= 10) {
1931
+ await new Promise((resolve) => setTimeout(resolve, 300));
1932
+ return saveHtml(html, key, generation);
1933
+ }
1401
1934
  else {
1402
1935
  state.save = "failed";
1403
1936
  renderSave();
@@ -1405,14 +1938,23 @@ async function saveHtml(html, key) {
1405
1938
  return false;
1406
1939
  }
1407
1940
  try {
1408
- const result = await api(`/api/page/${key}/save`, { method: "POST", body: JSON.stringify({ html, baseHash: state.baseHash }) });
1941
+ const result = await api(`/api/page/${key}/save`, {
1942
+ method: "POST",
1943
+ body: JSON.stringify({
1944
+ html, baseHash: state.baseHash, renderId: state.renderId,
1945
+ generation, sessionId: state.sessionId,
1946
+ }),
1947
+ });
1948
+ if (key !== state.key || generation !== state.renderGeneration) return false;
1409
1949
  state.baseHash = result.hash || null;
1950
+ state.renderSourceHash = result.hash || state.renderSourceHash;
1410
1951
  state.save = "saved";
1411
1952
  state.savedAt = clock();
1412
1953
  saveAttempts = 0;
1413
1954
  renderSave();
1414
1955
  return true;
1415
1956
  } catch (err) {
1957
+ if (key !== state.key || generation !== state.renderGeneration) return false;
1416
1958
  if (err.status === 409) {
1417
1959
  // Someone else — usually the agent — wrote the file first. Their version
1418
1960
  // arrives via the reload event; this save is abandoned, not retried.
@@ -1421,14 +1963,18 @@ async function saveHtml(html, key) {
1421
1963
  state.saveConflict = true;
1422
1964
  saveAttempts = 0;
1423
1965
  renderSave();
1966
+ holdReload();
1424
1967
  announce("Save conflict. Edit mode remains active so you can reload safely.");
1425
1968
  diagnostic("save-conflict");
1426
1969
  return false;
1427
1970
  }
1428
1971
  saveAttempts += 1;
1429
1972
  state.save = "failed";
1430
- if (saveAttempts < 5) retryTimer = setTimeout(() => saveHtml(html, key), 2000);
1431
- else toast("Couldn't save — your edits still reach the agent as feedback");
1973
+ if (saveAttempts < 3) {
1974
+ await new Promise((resolve) => setTimeout(resolve, 500));
1975
+ return saveHtml(html, key, generation);
1976
+ }
1977
+ else if (!state.sending) toast("Couldn't save. Retry before sending feedback; your edits remain on this page.");
1432
1978
  return false;
1433
1979
  }
1434
1980
  }
@@ -1479,17 +2025,41 @@ window.addEventListener("message", async (event) => {
1479
2025
  state.frameReadyTimer = null;
1480
2026
  state.frameReadyRetries = 0;
1481
2027
  state.readyGeneration = state.renderGeneration;
1482
- state.frameLoading = false;
1483
- frame.dataset.sdkReady = "true";
2028
+ state.frameReadyAt = Date.now();
1484
2029
  const readyCapability = state.frameCapability;
1485
- void api(`/api/session/${state.sessionId}/render/${state.renderId}/ready`, {
2030
+ const readyIdentity = currentRender();
2031
+ const readySaveSequence = saveSequence;
2032
+ const ready = api(`/api/session/${state.sessionId}/render/${state.renderId}/ready`, {
1486
2033
  method: "POST",
1487
2034
  body: JSON.stringify({
1488
2035
  capability: readyCapability,
1489
2036
  generation: state.renderGeneration,
1490
2037
  pageKey: state.key,
1491
2038
  }),
1492
- }).catch(() => {});
2039
+ }).then((result) => {
2040
+ if (!sameRender(readyIdentity, currentRender())) return null;
2041
+ if (saveSequence === readySaveSequence) state.renderSourceHash = result.sourceHash || null;
2042
+ return result;
2043
+ }).catch(() => null);
2044
+ const readyState = await ready;
2045
+ if (!sameRender(readyIdentity, currentRender())) return;
2046
+ if (!readyState || !["static", "interactive", "application"].includes(readyState.executionMode) ||
2047
+ !["writable", "feedback-only"].includes(readyState.savePolicy)) {
2048
+ failFrame("The review could not confirm this document's execution policy. Reload before editing.");
2049
+ return;
2050
+ }
2051
+ state.renderExecution = {
2052
+ executionMode: readyState.executionMode,
2053
+ savePolicy: readyState.savePolicy,
2054
+ feedbackOnly: readyState.savePolicy === "feedback-only",
2055
+ executionNotice: readyState.executionNotice || null,
2056
+ };
2057
+ state.page = { ...state.page, ...state.renderExecution };
2058
+ state.frameLoading = false;
2059
+ frame.dataset.sdkReady = "true";
2060
+ frame.dataset.executionMode = readyState.executionMode;
2061
+ frame.dataset.savePolicy = readyState.savePolicy;
2062
+ frame.dataset.executionNotice = readyState.executionNotice || "";
1493
2063
  toFrame({ type: "eh:anchors", comments: state.page ? state.page.comments : [] });
1494
2064
  if (state.reloading) {
1495
2065
  toFrame({ type: "eh:restoreScroll", x: state.scroll.x, y: state.scroll.y });
@@ -1498,19 +2068,39 @@ window.addEventListener("message", async (event) => {
1498
2068
  const configuration = reviewConfiguration(state.page, state.reviewMode);
1499
2069
  state.savePolicy = configuration.savePolicy;
1500
2070
  toFrame({ type: "eh:configureReview", ...configuration });
1501
- if (configuration.savePolicy === "writable") {
2071
+ render();
2072
+ if (state.page?.kind !== "url") {
1502
2073
  // Hand the SDK the on-disk HTML so it can spot self-rendering pages.
1503
- api(`/api/page/${state.key}/raw`)
2074
+ const rawIdentity = currentRender();
2075
+ ready.then(() => {
2076
+ if (!sameRender(rawIdentity, currentRender())) return null;
2077
+ return api(`/api/page/${state.key}/raw`);
2078
+ })
1504
2079
  .then((raw) => {
2080
+ if (!raw || !sameRender(rawIdentity, currentRender()) || state.pendingReload) return;
2081
+ if (state.renderSourceHash && state.renderSourceHash !== raw.hash) {
2082
+ state.saveConflict = true;
2083
+ holdReload();
2084
+ return;
2085
+ }
1505
2086
  state.baseHash = raw.hash || null;
1506
- toFrame({ type: "eh:raw", html: raw.html });
2087
+ if (configuration.savePolicy === "writable") toFrame({ type: "eh:raw", html: raw.html });
2088
+ scheduleResultCapture();
1507
2089
  })
1508
2090
  .catch(() => {});
1509
2091
  }
2092
+ void ready.then(() => refreshHistory());
1510
2093
  break;
1511
2094
  }
1512
2095
  case "eh:configurationApplied":
1513
2096
  state.configurationGeneration = state.renderGeneration;
2097
+ if (previousFrame) {
2098
+ const configuredFrame = frame;
2099
+ requestAnimationFrame(() => requestAnimationFrame(() => {
2100
+ if (frame === configuredFrame && !state.frameLoading) finishFrameReplacement();
2101
+ }));
2102
+ }
2103
+ scheduleResultCapture();
1514
2104
  if (
1515
2105
  configurationWaiter &&
1516
2106
  msg.mode === configurationWaiter.mode &&
@@ -1611,6 +2201,7 @@ window.addEventListener("message", async (event) => {
1611
2201
  toast(String(msg.reason || "This form cannot be submitted from View mode"));
1612
2202
  break;
1613
2203
  case "eh:edit":
2204
+ editMutationSequence += 1;
1614
2205
  void persistEdit(state.key, {
1615
2206
  label: msg.label,
1616
2207
  kind: msg.kind,
@@ -1623,22 +2214,32 @@ window.addEventListener("message", async (event) => {
1623
2214
  staged_assets: msg.staged_assets,
1624
2215
  });
1625
2216
  break;
1626
- case "eh:asset":
2217
+ case "eh:asset": {
2218
+ const identity = currentRender();
2219
+ const query = new URLSearchParams({
2220
+ type: msg.assetType || "", sessionId: state.sessionId,
2221
+ renderId: identity.renderId, generation: String(identity.generation),
2222
+ savePolicy: state.renderExecution?.savePolicy || "feedback-only",
2223
+ });
1627
2224
  try {
1628
- const saved = await fetch(`/api/page/${state.key}/asset?type=${encodeURIComponent(msg.assetType || "")}`, {
2225
+ const saved = await fetch(`/api/page/${identity.key}/asset?${query}`, {
1629
2226
  method: "POST",
1630
2227
  headers: { "content-type": "application/octet-stream", "x-doc-review-token": state.token },
1631
2228
  body: msg.bytes,
1632
2229
  });
1633
2230
  const data = await saved.json();
2231
+ if (!sameRender(identity, currentRender())) break;
1634
2232
  if (!saved.ok) throw new Error(data.error || "could not save the pasted image");
1635
2233
  toFrame({ type: "eh:assetSaved", id: msg.id, src: data.src, stagedId: data.stagedId });
1636
2234
  } catch (err) {
2235
+ if (!sameRender(identity, currentRender())) break;
1637
2236
  toast(err.message);
1638
2237
  toFrame({ type: "eh:assetFailed", id: msg.id });
1639
2238
  }
1640
2239
  break;
2240
+ }
1641
2241
  case "eh:saving":
2242
+ state.domDirty = true;
1642
2243
  state.save = "saving";
1643
2244
  renderSave();
1644
2245
  break;
@@ -1646,16 +2247,33 @@ window.addEventListener("message", async (event) => {
1646
2247
  await saveNow(msg.html);
1647
2248
  break;
1648
2249
  case "eh:clean":
1649
- // Serialization matched what is already on disk; nothing to write.
1650
- state.save = state.savedAt ? "saved" : "idle";
1651
- renderSave();
2250
+ // SDK equality is not a server acknowledgement: prior saves may still be failing.
2251
+ state.cleanObservation = {
2252
+ key: state.key, generation: state.renderGeneration,
2253
+ saveSequence, editSequence: editMutationSequence,
2254
+ };
2255
+ applyCleanObservation();
2256
+ break;
2257
+ case "eh:snapshot":
2258
+ captures.receive(msg);
1652
2259
  break;
2260
+ case "eh:viewChanged": {
2261
+ // Only a deduplicated identity-change signal can reopen this render's
2262
+ // automatic attempt. Snapshot and ordinary mutation messages cannot.
2263
+ captureCoordinator.reset();
2264
+ manualCaptureController?.abort();
2265
+ scheduleResultCapture();
2266
+ break;
2267
+ }
1653
2268
  case "eh:dynamic":
1654
2269
  state.dynamic = true;
1655
2270
  renderSave();
1656
2271
  break;
1657
2272
  case "eh:flushed":
1658
- if (flushWaiter) flushWaiter();
2273
+ if (typeof msg.requestId === "string") flushRequests.get(msg.requestId)?.settle(true);
2274
+ else for (const request of flushRequests.values()) {
2275
+ if (!request.strict) request.settle(true);
2276
+ }
1659
2277
  break;
1660
2278
  case "eh:scroll":
1661
2279
  state.scroll = { x: msg.x, y: msg.y };
@@ -1670,7 +2288,8 @@ window.addEventListener("message", async (event) => {
1670
2288
  }
1671
2289
  case "eh:navigate":
1672
2290
  try {
1673
- await flushFrame();
2291
+ if (draftCount(state)) throw new Error("Save or cancel open comment drafts before changing pages");
2292
+ await fullSaveBarrier();
1674
2293
  const result = await api(`/api/session/${state.sessionId}/navigate`, {
1675
2294
  method: "POST",
1676
2295
  body: JSON.stringify({ href: msg.href }),
@@ -1777,34 +2396,92 @@ const dismissCommentMenuOutside = (event) => {
1777
2396
  document.addEventListener("pointerdown", dismissCommentMenuOutside, true);
1778
2397
  document.addEventListener("focusin", dismissCommentMenuOutside, true);
1779
2398
 
1780
- $("send").addEventListener("click", async () => {
2399
+ async function sendFeedback() {
2400
+ if (state.sending || state.ended) return;
2401
+ const identity = currentRender();
2402
+ const sentNote = $("note").value;
2403
+ state.sending = true;
2404
+ if ($("captureWarning")) $("captureWarning").hidden = true;
2405
+ captureCoordinator.reset();
2406
+ manualCaptureController?.abort();
2407
+ render();
2408
+ let phase = "saving";
2409
+ let delivered = false;
1781
2410
  try {
1782
- await flushFrame();
1783
- await api(`/api/page/${state.key}/send`, {
1784
- method: "POST",
1785
- body: JSON.stringify({ sessionId: state.sessionId, note: $("note").value.trim() }),
2411
+ const outcome = await deliverFeedback({
2412
+ save: fullSaveBarrier,
2413
+ capture: () => captureStablePage({ timeout: 500 }),
2414
+ deliver: async (snapshot) => {
2415
+ // A snapshot flush can reveal more queued writes. Those are still required.
2416
+ await activeSavePromise;
2417
+ await settleEditPersistence(state.key);
2418
+ if (state.ended || !sameRender(identity, currentRender())) throw new Error("The page changed before sending. Retry on the current page.");
2419
+ if (state.saveConflict || state.save === "failed" ||
2420
+ (state.savePolicy === "writable" && !state.dynamic && state.domDirty)) {
2421
+ throw new Error("Your page edits have not finished saving.");
2422
+ }
2423
+ phase = "delivery";
2424
+ const result = await api(`/api/page/${state.key}/send`, {
2425
+ method: "POST",
2426
+ body: JSON.stringify({
2427
+ sessionId: state.sessionId, note: sentNote.trim(),
2428
+ history: {
2429
+ renderId: state.renderId, generation: state.renderGeneration,
2430
+ semantic: snapshot?.semantic || null,
2431
+ semanticCapturedAt: snapshot?.semanticCapturedAt, view: snapshot?.view,
2432
+ expectedSourceHash: snapshot?.sourceHash || state.baseHash || state.renderSourceHash,
2433
+ allowUnavailable: true,
2434
+ },
2435
+ }),
2436
+ });
2437
+ if (result.ok === false) throw new Error(result.error || "Feedback delivery was not confirmed");
2438
+ },
2439
+ committed() {
2440
+ delivered = true;
2441
+ if ($("note").value === sentNote) $("note").value = "";
2442
+ state.sent = true;
2443
+ },
2444
+ refresh: refreshHistory,
1786
2445
  });
1787
- $("note").value = "";
1788
- state.sent = true;
1789
- render();
2446
+ if (outcome.refreshFailure) toast("Feedback sent. History could not be refreshed; your feedback does not need to be sent again.");
2447
+ else if (outcome.captureFailure) announce("Feedback sent. Content comparison may be incomplete.");
1790
2448
  } catch (err) {
1791
- toast(err.message);
2449
+ const message = delivered ? "Feedback sent. History is temporarily unavailable; do not send it again."
2450
+ : phase === "saving" ? `Feedback not sent: ${err.message} Your feedback and drafts are still here.`
2451
+ : `Feedback delivery was not confirmed: ${err.message} No automatic retry was made. Check the agent before sending again.`;
2452
+ toast(message);
2453
+ announce(message);
2454
+ } finally {
2455
+ state.sending = false;
2456
+ render();
2457
+ scheduleResultCapture();
1792
2458
  }
1793
- });
2459
+ }
2460
+ $("send").addEventListener("click", () => void sendFeedback());
1794
2461
 
1795
2462
  $("revert").addEventListener("click", async () => {
1796
2463
  const count = state.page.edits.length;
1797
2464
  if (!window.confirm(`Discard all ${count} of your edits?`)) return;
2465
+ const identity = currentRender();
1798
2466
  // Stop the SDK's debounced save and our own retries first, so a queued save
1799
2467
  // can't land after the revert and write the edits straight back.
1800
2468
  toFrame({ type: "eh:abortSave" });
1801
2469
  clearTimeout(retryTimer);
1802
- state.baseHash = null;
1803
2470
  try {
1804
- await settleEditPersistence(state.key).catch(() => {});
1805
- editBacklogs.delete(state.key);
1806
- editErrors.delete(state.key);
1807
- state.page = (await api(`/api/page/${state.key}/revert`, { method: "POST" })).page;
2471
+ await activeSavePromise;
2472
+ await settleEditPersistence(identity.key).catch(() => {});
2473
+ if (!sameRender(identity, currentRender())) throw new Error("The page changed before reverting. Retry on the current page.");
2474
+ const baseHash = state.baseHash;
2475
+ editBacklogs.delete(identity.key);
2476
+ editErrors.delete(identity.key);
2477
+ const result = await api(`/api/page/${identity.key}/revert`, {
2478
+ method: "POST",
2479
+ body: JSON.stringify({
2480
+ sessionId: state.sessionId, renderId: identity.renderId, generation: identity.generation, baseHash,
2481
+ }),
2482
+ });
2483
+ if (state.key !== identity.key) return;
2484
+ state.page = result.page;
1808
2485
  state.save = "idle";
1809
2486
  state.savedAt = "";
1810
2487
  render();
@@ -1816,6 +2493,11 @@ $("revert").addEventListener("click", async () => {
1816
2493
  /** The session is over: freeze the page and say so. Feedback is already safe. */
1817
2494
  function showEnded() {
1818
2495
  if (document.querySelector(".ended")) return;
2496
+ state.ended = true;
2497
+ captureCoordinator.stop();
2498
+ manualCaptureController?.abort();
2499
+ captures.cancel("Review ended");
2500
+ historyController.cancel();
1819
2501
  if (events) events.close();
1820
2502
  clearTimeout(retryTimer);
1821
2503
  const overlay = document.createElement("div");
@@ -1896,6 +2578,14 @@ document.addEventListener("keydown", (event) => {
1896
2578
  return;
1897
2579
  }
1898
2580
  if (event.key !== "Escape") return;
2581
+ const recoveryMenu = event.target.closest?.("#reviewDetails");
2582
+ if (recoveryMenu) {
2583
+ recoveryMenu.open = false;
2584
+ recoveryMenu.querySelector("summary")?.focus({ preventScroll: true });
2585
+ event.preventDefault();
2586
+ event.stopPropagation();
2587
+ return;
2588
+ }
1899
2589
  let handled = false;
1900
2590
  if (state.commentUi.confirmation) handled = cancelDeleteConfirmation();
1901
2591
  else if (state.commentUi.menu) handled = closeCommentMenu({ restoreFocus: true });
@@ -1916,37 +2606,82 @@ document.addEventListener("keydown", (event) => {
1916
2606
 
1917
2607
  let events = null;
1918
2608
 
2609
+ function holdReload() {
2610
+ state.pendingReload = true;
2611
+ state.baseHash = null;
2612
+ clearTimeout(retryTimer);
2613
+ toFrame({ type: "eh:abortSave" });
2614
+ if (state.domDirty) state.saveConflict = true;
2615
+ $("reloadNotice").hidden = false;
2616
+ $("reloadMessage").textContent = state.domDirty || state.saveConflict
2617
+ ? "The source changed. This page has unsaved edits; reload discards those page edits, but keeps open comment drafts. Stale edits will not overwrite the new source."
2618
+ : "The source changed. Reload keeps your comment drafts as unresolved excerpts so they cannot attach to the wrong content.";
2619
+ captureCoordinator.reset();
2620
+ manualCaptureController?.abort();
2621
+ captures.cancel();
2622
+ renderExecution();
2623
+ if (state.comparing) renderHistory();
2624
+ }
2625
+
2626
+ async function reloadLatest({ explicit = false } = {}) {
2627
+ if (!explicit && (draftCount(state) || state.domDirty || state.saveConflict || state.sending)) {
2628
+ holdReload();
2629
+ return;
2630
+ }
2631
+ const key = state.key;
2632
+ advancePageEpoch("reload");
2633
+ captureEditState();
2634
+ if (state.compose) {
2635
+ state.compose.unresolved = true;
2636
+ state.compose.relation = "unavailable";
2637
+ state.compose.rects = [];
2638
+ state.compose.generation = -1;
2639
+ }
2640
+ const generation = beginFrameTransition();
2641
+ state.frameReadyRetries = 0;
2642
+ state.reloading = true;
2643
+ state.dynamic = false;
2644
+ state.baseHash = null;
2645
+ clearTimeout(retryTimer);
2646
+ state.pendingReload = false;
2647
+ state.saveConflict = false;
2648
+ state.domDirty = false;
2649
+ $("reloadNotice").hidden = true;
2650
+ try {
2651
+ const page = await api(pageUrl(key, state.sessionId));
2652
+ if (state.key !== key || state.renderGeneration !== generation) return;
2653
+ state.executionPreference = page.executionPreference || null;
2654
+ // Keep parent-owned drafts rather than replacing their text on a source update.
2655
+ replacePage(state, page);
2656
+ state.save = "idle";
2657
+ state.savedAt = "";
2658
+ render();
2659
+ if (state.compose) {
2660
+ $("compose").hidden = false;
2661
+ $("compose").classList.add("sheet");
2662
+ $("composeError").hidden = false;
2663
+ $("composeError").textContent = "The source changed. This draft keeps its original excerpt; cancel it to select a new target.";
2664
+ }
2665
+ await registerFrame(key, generation);
2666
+ } catch (err) {
2667
+ if (state.key === key && state.renderGeneration === generation) failFrame(err.message);
2668
+ }
2669
+ }
2670
+ $("safeReload").addEventListener("click", () => void reloadLatest({ explicit: true }));
2671
+ $("keepCurrent").addEventListener("click", () => { $("reloadNotice").hidden = true; announce("Keeping the current page. Return to Latest version to reload when ready."); });
2672
+ window.addEventListener("beforeunload", (event) => {
2673
+ if (!state.domDirty && !draftCount(state, $("note").value) && !editBacklogs.get(state.key)?.size) return;
2674
+ event.preventDefault();
2675
+ event.returnValue = "";
2676
+ });
2677
+
1919
2678
  function connect() {
1920
2679
  const source = new EventSource(`/events/${state.sessionId}`);
1921
2680
  events = source;
1922
2681
  // Another window on this session hit End review.
1923
2682
  source.addEventListener("ended", () => showEnded());
1924
- source.addEventListener("reload", () => {
1925
- const key = state.key;
1926
- advancePageEpoch("reload");
1927
- const generation = beginFrameTransition();
1928
- state.frameReadyRetries = 0;
1929
- const hadEdits = state.page ? state.page.edits.length : 0;
1930
- state.reloading = true;
1931
- state.dynamic = false;
1932
- // The file on disk changed: queued saves are based on the old version.
1933
- state.baseHash = null;
1934
- clearTimeout(retryTimer);
1935
- api(pageUrl(key, state.sessionId)).then((page) => {
1936
- if (state.key !== key || state.renderGeneration !== generation) return;
1937
- replacePage(state, page);
1938
- state.save = "idle";
1939
- state.savedAt = "";
1940
- render();
1941
- // The agent's version wins, so say so rather than losing the rows silently.
1942
- if (hadEdits && page.edits.length === 0) {
1943
- toast(`Agent rewrote ${hadEdits} ${hadEdits === 1 ? "block" : "blocks"} you had edited`);
1944
- }
1945
- void registerFrame(key, generation).catch((err) => toast(err.message));
1946
- }).catch((err) => {
1947
- if (state.key === key && state.renderGeneration === generation) toast(err.message);
1948
- });
1949
- });
2683
+ source.addEventListener("reload", () => void reloadLatest());
2684
+ source.addEventListener("history", () => void refreshHistory());
1950
2685
  source.addEventListener("agent", (event) => {
1951
2686
  state.agent = JSON.parse(event.data).state;
1952
2687
  render();
@@ -1979,6 +2714,7 @@ function connect() {
1979
2714
  const key = bootstrap ? bootstrap.key : new URLSearchParams(location.search).get("key");
1980
2715
  await loadPage(key);
1981
2716
  connect();
2717
+ void refreshHistory();
1982
2718
  })();
1983
2719
 
1984
2720
  window.addEventListener("resize", () => {
@@ -1990,17 +2726,22 @@ if (window.visualViewport) {
1990
2726
  window.visualViewport.addEventListener("scroll", positionCompose);
1991
2727
  }
1992
2728
 
1993
- frame.addEventListener("load", () => {
1994
- if (!state.renderId || !state.frameCapability) return;
1995
- if (state.pendingInitialLoad) {
1996
- state.pendingInitialLoad = false;
1997
- return;
1998
- }
1999
- rotateCurrentFrame();
2000
- });
2729
+ function attachFrameEvents(target) {
2730
+ target.addEventListener("load", () => {
2731
+ if (target !== frame) return;
2732
+ if (!state.renderId || !state.frameCapability) return;
2733
+ if (state.pendingInitialLoad) {
2734
+ state.pendingInitialLoad = false;
2735
+ return;
2736
+ }
2737
+ rotateCurrentFrame();
2738
+ });
2001
2739
 
2002
- frame.addEventListener("focus", () => {
2003
- closeCommentMenu();
2004
- if (state.compose) $("compose").classList.add("pass-through");
2005
- });
2740
+ target.addEventListener("focus", () => {
2741
+ if (target !== frame) return;
2742
+ closeCommentMenu();
2743
+ if (state.compose) $("compose").classList.add("pass-through");
2744
+ });
2745
+ }
2746
+ attachFrameEvents(frame);
2006
2747
  $("compose").addEventListener("focusin", () => $("compose").classList.remove("pass-through"));