@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.
- package/README.md +155 -19
- package/package.json +4 -2
- package/src/SKILL.md +56 -7
- package/src/atomic-write.js +53 -0
- package/src/chrome-client.js +844 -103
- package/src/chrome.css +253 -8
- package/src/chrome.html +78 -11
- package/src/cli.js +52 -130
- package/src/comment-target.js +87 -0
- package/src/comparison-view.js +337 -0
- package/src/document-execution.js +103 -0
- package/src/document-trust.js +2 -0
- package/src/edit-limits.js +23 -0
- package/src/execution-client.js +63 -0
- package/src/frame-policy.js +38 -0
- package/src/history-client.js +104 -0
- package/src/history-coordinator.js +186 -0
- package/src/history-policy.js +43 -0
- package/src/history-server.js +463 -0
- package/src/paths.js +1 -1
- package/src/poll-transport.js +222 -0
- package/src/review-mode.js +1 -1
- package/src/revision-diff.js +440 -0
- package/src/revision-schema.js +219 -0
- package/src/revision-store.js +177 -0
- package/src/sdk.js +419 -55
- package/src/semantic-snapshot.js +230 -0
- package/src/server.js +240 -53
- package/src/setup-guidance.js +128 -0
- package/src/setup.js +41 -15
- package/src/state.js +257 -35
- package/src/view-identity.js +99 -0
package/src/chrome-client.js
CHANGED
|
@@ -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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
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
|
-
|
|
457
|
-
|
|
458
|
-
|
|
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
|
|
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(
|
|
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
|
-
|
|
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:
|
|
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 —
|
|
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
|
-
|
|
1389
|
-
|
|
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 <=
|
|
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`, {
|
|
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 <
|
|
1431
|
-
|
|
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.
|
|
1483
|
-
frame.dataset.sdkReady = "true";
|
|
2028
|
+
state.frameReadyAt = Date.now();
|
|
1484
2029
|
const readyCapability = state.frameCapability;
|
|
1485
|
-
|
|
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
|
-
}).
|
|
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
|
-
|
|
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
|
-
|
|
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/${
|
|
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
|
-
//
|
|
1650
|
-
state.
|
|
1651
|
-
|
|
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 (
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
1783
|
-
|
|
1784
|
-
|
|
1785
|
-
|
|
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
|
-
|
|
1788
|
-
|
|
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
|
-
|
|
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
|
|
1805
|
-
|
|
1806
|
-
|
|
1807
|
-
|
|
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
|
-
|
|
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
|
-
|
|
1994
|
-
|
|
1995
|
-
|
|
1996
|
-
state.
|
|
1997
|
-
|
|
1998
|
-
|
|
1999
|
-
|
|
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
|
-
|
|
2003
|
-
|
|
2004
|
-
|
|
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"));
|