@erdemtuna/doc-review 0.12.0 → 0.13.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.
Files changed (49) hide show
  1. package/README.md +23 -25
  2. package/lib/SKILL.md +65 -202
  3. package/lib/agent-handoff.js +24 -0
  4. package/lib/agent-output.js +287 -0
  5. package/lib/anchor-text.js +31 -19
  6. package/lib/chrome-api.js +23 -4
  7. package/lib/chrome.html +0 -25
  8. package/lib/cli.js +282 -104
  9. package/lib/comment-target.js +4 -0
  10. package/lib/contracts/agent.js +122 -0
  11. package/lib/contracts/feedback.js +369 -1
  12. package/lib/contracts/frame.js +81 -0
  13. package/lib/contracts/history.js +189 -1
  14. package/lib/contracts/index.js +7 -2
  15. package/lib/contracts/page-boundary.js +187 -0
  16. package/lib/contracts/validation.js +200 -0
  17. package/lib/conversation-anchor-controller.js +84 -0
  18. package/lib/conversation-capture.js +81 -0
  19. package/lib/conversation-controller.js +798 -0
  20. package/lib/conversation-save.js +175 -0
  21. package/lib/conversation-server.js +184 -0
  22. package/lib/conversation-shell.js +1013 -0
  23. package/lib/conversation-store.js +809 -0
  24. package/lib/frame-controller.js +8 -8
  25. package/lib/frame-policy.js +1 -0
  26. package/lib/history-policy.js +4 -15
  27. package/lib/history-server.js +42 -326
  28. package/lib/html-transform.js +19 -4
  29. package/lib/icons.js +323 -0
  30. package/lib/new-message-target.js +27 -0
  31. package/lib/paths.js +2 -2
  32. package/lib/poll-transport.js +109 -26
  33. package/lib/positioning.js +51 -0
  34. package/lib/references/context-and-recovery.md +130 -0
  35. package/lib/references/response-contract.md +108 -0
  36. package/lib/references/source-edits.md +47 -0
  37. package/lib/revision-store.js +1 -1
  38. package/lib/save-controller.js +28 -14
  39. package/lib/sdk.js +357 -35
  40. package/lib/server.js +73 -595
  41. package/lib/setup.js +17 -48
  42. package/lib/state.js +21 -7
  43. package/lib/thread-anchor-controller.js +56 -0
  44. package/lib/toolbar-controller.js +3 -3
  45. package/lib/ui/THIRD_PARTY_NOTICES.md +127 -8
  46. package/lib/ui/chrome.css +1079 -2782
  47. package/lib/ui/chrome.js +81 -17
  48. package/package.json +2 -2
  49. package/lib/chrome-client.js +0 -1706
@@ -0,0 +1,1013 @@
1
+ import { createReviewApi, decodePage, record } from "./chrome-api.js";
2
+ import { createConversationController } from "./conversation-controller.js";
3
+ import { createControllerStore } from "./controller-store.js";
4
+ import { createFrameHost } from "./frame-host.js";
5
+ import { createFrameController } from "./frame-controller.js";
6
+ import { createSaveController } from "./save-controller.js";
7
+ import { framePolicy } from "./frame-policy.js";
8
+ import { createCaptureRequests } from "./history-client.js";
9
+ import { limitEditFields } from "./edit-limits.js";
10
+ import { externalHref } from "./editing.js";
11
+ import { reviewSchema, directEditContentSchema } from "./contracts/index.js";
12
+ import { createConversationAnchorController, describeConversationAnchor } from "./conversation-anchor-controller.js";
13
+ import { placeConversationSurface, placeNewMessageSurface, visibleViewport } from "./positioning.js";
14
+ import { readNewMessageTarget } from "./new-message-target.js";
15
+ import { normalizeView, sameObservedView } from "./view-identity.js";
16
+ import { createResultCaptures, decodeComparison, resultCaptureKey } from "./conversation-capture.js";
17
+ export function createConversationShell() {
18
+ const reference = { reviewId: decodeURIComponent(document.body.dataset.review), entryKey: decodeURIComponent(document.body.dataset.entry) };
19
+ const api = createReviewApi({ token: document.body.dataset.token });
20
+ let sessionId = document.body.dataset.session;
21
+ const artifactOrigin = `${location.protocol}//${location.hostname === "127.0.0.1" ? "localhost" : "127.0.0.1"}:${location.port}`;
22
+ let page = null, mode = "view", theme = "light";
23
+ let loading = true, sourceError = "", captureError = "", reloadPending = false, connectionError = "";
24
+ let comparison = null;
25
+ let comparisonOpen = false, comparisonRequest = 0;
26
+ let comparisonTrigger = null;
27
+ const beginComparison = () => {
28
+ if (!comparisonOpen)
29
+ comparisonTrigger = document.activeElement instanceof HTMLElement ? document.activeElement : null;
30
+ comparisonOpen = true;
31
+ };
32
+ let disposed = false, eventSource = null, reconnectTimer = null;
33
+ let reconnecting = null, scroll = { x: 0, y: 0 };
34
+ let pendingTarget = null, hadNewMessage = false;
35
+ let newTarget = null, targetHighWater = 0, retiredTarget = 0;
36
+ let composer = null, composerHeight = 0;
37
+ const threadSizes = new Map();
38
+ let composerNotice = "";
39
+ let pendingJump = null;
40
+ const geometry = createConversationAnchorController();
41
+ let adjacent = null;
42
+ let anchorNotice = "", anchorThread = null, activeMark = null;
43
+ let fallbackSpace = false;
44
+ let unavailable = "render-loading";
45
+ const capturesAttempted = new Set();
46
+ let observedView = null;
47
+ let captureCycle = null;
48
+ const assetUrls = new Map();
49
+ const normalizeAssets = (value) => {
50
+ for (const [url, relative] of assetUrls)
51
+ value = value.replaceAll(url, relative);
52
+ return value;
53
+ };
54
+ const lifetime = new AbortController();
55
+ const owner = createConversationController({
56
+ ...reference, request: api.request,
57
+ barrier: () => save.barrier(), baseline: () => capture(null),
58
+ navigate(key) { pendingJump = null; return navigate(key); }, async jump(id) {
59
+ const thread = owner.getSnapshot().threads.find((item) => item.thread.threadId === id);
60
+ if (!thread)
61
+ return;
62
+ if (thread.thread.pageKey === frame.state.key && !loading) {
63
+ revealThread(id);
64
+ }
65
+ else {
66
+ pendingJump = id;
67
+ try {
68
+ await navigate(thread.thread.pageKey);
69
+ }
70
+ catch (cause) {
71
+ if (pendingJump === id)
72
+ pendingJump = null;
73
+ throw cause;
74
+ }
75
+ }
76
+ },
77
+ async revert() {
78
+ await save.barrier();
79
+ const current = owner.pages.find((item) => item.page.pageKey === frame.state.key);
80
+ if (!current?.canRevert || !current.revert)
81
+ throw new Error("This review does not own a current revert baseline.");
82
+ await owner.mutate("revert", { pageKey: current.page.pageKey, expectedSourceHash: current.revert.sourceHash,
83
+ baselineRevisionId: current.revert.baselineRevisionId });
84
+ await load(current.page.pageKey);
85
+ },
86
+ });
87
+ function currentPage() { return owner.pages.find((item) => item.page.pageKey === frame.state.key); }
88
+ function blocked() { return owner.review?.state !== "open" || !!currentPage()?.writeBlockedBy.length || !!owner.getSnapshot().uncertain; }
89
+ function policy() { return save.state.dynamic ? "feedback-only" : frame.state.execution?.savePolicy ?? currentPage()?.savePolicy ?? "feedback-only"; }
90
+ function reportSource(value) {
91
+ sourceError = value instanceof Error ? value.message : String(value);
92
+ publish();
93
+ }
94
+ function makeFrame() {
95
+ const element = document.querySelector("#frame");
96
+ if (!element)
97
+ throw new Error("Document frame is missing.");
98
+ return createFrameController({
99
+ sessionId, host: createFrameHost(element, artifactOrigin), request: api.request,
100
+ suspended() { loading = true; clearAnchors("render-loading"); publish(); },
101
+ failed(message) { pendingJump = null; clearAnchors("render-unavailable"); reportSource(message); },
102
+ activated() { loading = false; void activate().catch(reportSource); },
103
+ });
104
+ }
105
+ let frame = makeFrame();
106
+ const captures = createCaptureRequests({ send: (message) => frame.send(message), current: () => frame.identity() });
107
+ const resultCaptures = createResultCaptures({
108
+ current: currentCapture,
109
+ compare: (scope) => api.request("/api/conversation/comparison", { method: "POST", body: JSON.stringify({
110
+ ...reference, submissionId: scope.submissionId, pageKey: scope.pageKey, mode: "content",
111
+ }) }),
112
+ capture: (scope) => capture(scope.submissionId, scope),
113
+ refresh: () => owner.refresh(),
114
+ changed: publish,
115
+ });
116
+ const save = createSaveController({
117
+ sessionId, current: () => frame.identity(), policy, request: api.request,
118
+ flush: (strict) => frame.flush(strict), send: (message) => frame.send(message),
119
+ sourceHash: (hash) => frame.setSourceHash(hash), pageChanged() { },
120
+ conflict(message) { reportSource(message); mode = "view"; void configure(); },
121
+ failed: reportSource, diagnostic() { }, sending: () => owner.getSnapshot().busy,
122
+ conversation: {
123
+ record(key, payload) {
124
+ if (blocked())
125
+ return Promise.reject(new Error("Direct writes are blocked. Saved discussion can still be prepared."));
126
+ const { feedback_only: _feedback, ...content } = payload;
127
+ for (const field of ["before_html", "after_html"])
128
+ if (typeof content[field] === "string")
129
+ content[field] = normalizeAssets(content[field]);
130
+ if (Array.isArray(content.staged_assets))
131
+ content.staged_assets = content.staged_assets.map((value) => {
132
+ const asset = record(value);
133
+ return { ...asset, preview_src: typeof asset.preview_src === "string" ? normalizeAssets(asset.preview_src) : asset.preview_src };
134
+ });
135
+ const limited = limitEditFields(content);
136
+ if (limited.truncated) {
137
+ save.markDynamic();
138
+ reportSource("Incomplete edit capture retained as source-pending. No truncated content will be saved to source.");
139
+ void configure().catch(reportSource);
140
+ }
141
+ return owner.recordEdit(key, directEditContentSchema.parse({
142
+ ...content, ...limited.fields, truncated: limited.truncated || content.truncated === true,
143
+ truncated_fields: [...new Set([...limited.truncated_fields, ...(Array.isArray(content.truncated_fields) ? content.truncated_fields : [])])],
144
+ staged_assets: content.staged_assets ?? [],
145
+ }));
146
+ },
147
+ // serializeDocument adds a file terminator outside </html>. Parsing that
148
+ // terminator would invent a body text node unrelated to the recorded edit.
149
+ save: (key, html, sourceHash) => owner.saveHtml(key, normalizeAssets(html).replace(/(<\/html>)\n$/, "$1"), sourceHash),
150
+ },
151
+ });
152
+ const chrome = createControllerStore(() => ({
153
+ pageKey: frame.state.key, pageName: page?.filename ?? "", mode, theme, loading,
154
+ contentTop: contentTop(),
155
+ pollCommand: page?.pollCommand ?? "",
156
+ themeSync: frame.themeSync, executionPreference: page?.executionPreference ?? "auto",
157
+ supportsRecovery: page?.kind === "file" && !page.markdown,
158
+ sourceError, captureError, captureFailures: resultCaptures.failures, connectionError, reloadPending, save: save.getSnapshot(), blocked: blocked(),
159
+ canRevert: currentPage()?.canRevert ?? false, policy: policy(), comparison, comparisonOpen,
160
+ adjacent, anchorNotice, anchorThread, composer, composerNotice,
161
+ canComposeBeside: !!newTarget?.geometry && newTarget.geometry.relation !== "unavailable" && !loading,
162
+ composerRelation: newTarget?.geometry?.relation ?? "unavailable", viewport: visibleViewport(window),
163
+ anchorViews: Object.fromEntries(owner.getSnapshot().threads.map(({ thread }) => [thread.threadId,
164
+ thread.pageKey !== frame.state.key
165
+ ? { canJump: !loading, offscreen: false, reason: "Show in document opens its review page." }
166
+ : describeConversationAnchor(geometry.states.find((state) => state.threadId === thread.threadId) ??
167
+ { threadId: thread.threadId, state: "unavailable", reason: unavailable })])),
168
+ anchorPeers: Object.fromEntries(owner.getSnapshot().threads.map(({ thread }) => [thread.threadId, geometry.peers(thread.threadId)])),
169
+ }));
170
+ function publish() { if (!disposed)
171
+ chrome?.publish(); }
172
+ const stopSave = save.subscribe(publish);
173
+ let stopFrame = frame.subscribe(frameChanged);
174
+ let priorEdits = owner.getSnapshot().edits;
175
+ let priorCanComment = owner.getSnapshot().canComment;
176
+ let configurationRequest = 0;
177
+ const stopOwner = owner.subscribe(() => {
178
+ let modeChanged = false;
179
+ if (blocked() && mode === "edit") {
180
+ mode = "view";
181
+ frame.send({ type: "eh:abortSave" });
182
+ modeChanged = true;
183
+ }
184
+ const current = owner.getSnapshot();
185
+ if (modeChanged || priorCanComment !== current.canComment) {
186
+ priorCanComment = current.canComment;
187
+ void configure().catch(reportSource);
188
+ }
189
+ if (hadNewMessage && !current.newMessage && pendingTarget !== null) {
190
+ retiredTarget = Math.max(retiredTarget, targetHighWater, pendingTarget);
191
+ frame.send({ type: "eh:cancel", targetGeneration: pendingTarget, discardThroughGeneration: retiredTarget, restoreFocus: true });
192
+ pendingTarget = null;
193
+ newTarget = null;
194
+ composer = null;
195
+ composerNotice = "";
196
+ if (current.host === "compose")
197
+ owner.commands.open(false);
198
+ }
199
+ hadNewMessage = !!current.newMessage;
200
+ if (!save.state.dirty && !save.queued(frame.state.key)) {
201
+ const submitted = current.submissions.flatMap((item) => item.value.submission.edits);
202
+ const labels = priorEdits.filter((edit) => edit.pageKey === frame.state.key &&
203
+ submitted.some((item) => item.editId === edit.editId && item.version === edit.version) &&
204
+ !current.edits.some((item) => item.pageKey === edit.pageKey && item.content.label === edit.content.label))
205
+ .map((edit) => edit.content.label);
206
+ if (labels.length)
207
+ frame.send({ type: "eh:submittedEdits", labels });
208
+ }
209
+ priorEdits = current.edits;
210
+ syncAnchors();
211
+ updatePlacement();
212
+ const latest = owner.getSnapshot();
213
+ if (comparisonOpen && !comparison && latest.history.some((item) => item.result?.effect === "changes-reported"))
214
+ void showChanges().catch(owner.report);
215
+ const wanted = latest.open && latest.host === "adjacent" &&
216
+ latest.threads.some(({ thread }) => thread.threadId === latest.focusId && thread.status === "open") ? latest.focusId : null;
217
+ if (wanted !== activeMark) {
218
+ if (activeMark && geometry.projection?.anchors.some((item) => item.threadId === activeMark))
219
+ sendThreadAction("dismiss", activeMark);
220
+ activeMark = null;
221
+ if (wanted && geometry.states.some((state) => state.threadId === wanted && state.state === "found")) {
222
+ activeMark = wanted;
223
+ sendThreadAction("activate", wanted);
224
+ }
225
+ }
226
+ if (!loading)
227
+ void captureResults().catch(reportSource);
228
+ publish();
229
+ });
230
+ function clearAnchors(reason) {
231
+ geometry.reset();
232
+ unavailable = reason;
233
+ adjacent = null;
234
+ activeMark = null;
235
+ pendingTarget = null;
236
+ newTarget = null;
237
+ targetHighWater = 0;
238
+ retiredTarget = 0;
239
+ composer = null;
240
+ if (owner.getSnapshot().newMessage) {
241
+ composerNotice = "The original target belongs to a replaced render. Your draft is kept in Feedback.";
242
+ if (owner.getSnapshot().host === "compose")
243
+ owner.commands.focus(null);
244
+ }
245
+ fallbackSpace = false;
246
+ if (owner.getSnapshot().host === "adjacent") {
247
+ anchorNotice = describeConversationAnchor({ threadId: anchorThread ?? "", state: "unavailable", reason }).reason;
248
+ owner.commands.fallback();
249
+ }
250
+ }
251
+ function frameChanged() {
252
+ const projection = geometry.projection;
253
+ if (projection && (projection.renderId !== frame.state.renderId || projection.generation !== frame.state.generation ||
254
+ projection.pageKey !== frame.state.key || frame.state.phase.kind !== "ready"))
255
+ clearAnchors("render-changed");
256
+ if (!frame.identity().loading)
257
+ void captureResults().catch(reportSource);
258
+ publish();
259
+ }
260
+ function syncAnchors() {
261
+ if (loading || frame.state.phase.kind !== "ready" || !frame.state.capability || !frame.state.key || !frame.state.renderId)
262
+ return;
263
+ const projection = {
264
+ type: "eh:threadAnchors", capability: frame.state.capability, ...reference,
265
+ pageKey: frame.state.key, renderId: frame.state.renderId, generation: frame.state.generation,
266
+ anchors: owner.getSnapshot().threads.filter((item) => item.thread.pageKey === frame.state.key)
267
+ .map(({ thread }) => ({ threadId: thread.threadId, target: thread.target, resolved: thread.status === "resolved" })),
268
+ };
269
+ const { entryKey: _entryKey, ...payload } = projection;
270
+ if (geometry.project(payload))
271
+ frame.send(geometry.projection);
272
+ }
273
+ function sendThreadAction(action, id) {
274
+ const projection = geometry.projection;
275
+ if (loading || projection?.capability !== frame.state.capability || projection?.renderId !== frame.state.renderId ||
276
+ projection?.generation !== frame.state.generation || projection?.pageKey !== frame.state.key) {
277
+ throw new Error("The target belongs to a replaced or unavailable frame.");
278
+ }
279
+ frame.send(geometry.outgoing(action, id));
280
+ }
281
+ function revealThread(id) {
282
+ geometry.outgoing("reveal", id);
283
+ const state = geometry.states.find(item => item.threadId === id);
284
+ const element = document.querySelector("#frame");
285
+ const panel = document.querySelector(".conversation-panel")?.getBoundingClientRect();
286
+ const bounds = element?.getBoundingClientRect();
287
+ const besidePanel = state?.state === "found" && state.relation === "visible" && bounds && panel &&
288
+ state.viewport.width === element?.clientWidth && state.viewport.height === element?.clientHeight &&
289
+ state.rects.every(rect => rect.right + bounds.left <= panel.left || rect.left + bounds.left >= panel.right);
290
+ if (document.body.dataset.conversationDocked !== "true" && !besidePanel)
291
+ owner.commands.open(false);
292
+ sendThreadAction("reveal", id);
293
+ }
294
+ function updatePlacement() {
295
+ const current = owner.getSnapshot();
296
+ composer = null;
297
+ if (!current.newMessage)
298
+ composerHeight = 0;
299
+ if (current.open && current.newMessage && current.host === "compose") {
300
+ if (!composerHeight) {
301
+ publish();
302
+ return;
303
+ }
304
+ const element = document.querySelector("#frame");
305
+ const viewport = visibleViewport(window);
306
+ const targetViewport = newTarget?.geometry?.viewport;
307
+ if (element && targetViewport &&
308
+ (Math.abs(element.clientWidth - targetViewport.width) > 1 || Math.abs(element.clientHeight - targetViewport.height) > 1)) {
309
+ publish();
310
+ return;
311
+ }
312
+ composer = element && newTarget?.geometry ? placeNewMessageSurface(newTarget.geometry, {
313
+ frameRect: element.getBoundingClientRect(), viewport, surfaceWidth: 340, surfaceHeight: composerHeight,
314
+ toolbarHeight: Math.max(0, contentTop() - viewport.top),
315
+ }) : null;
316
+ if (!composer) {
317
+ composerNotice = !newTarget?.geometry || newTarget.geometry.relation === "unavailable"
318
+ ? "The original target cannot currently be shown. Your draft is kept in Feedback."
319
+ : "Opened in Feedback because there is not enough room beside, above or below the target without covering it.";
320
+ owner.commands.focus(null);
321
+ }
322
+ else
323
+ composerNotice = "";
324
+ }
325
+ adjacent = null;
326
+ if (!current.open || current.host !== "adjacent" || !current.focusId) {
327
+ const state = geometry.states.find((item) => item.threadId === anchorThread);
328
+ if (anchorNotice && state && !(fallbackSpace && state.state === "found" && state.relation === "visible")) {
329
+ anchorNotice = describeConversationAnchor(state).reason;
330
+ }
331
+ publish();
332
+ return;
333
+ }
334
+ const state = geometry.states.find((item) => item.threadId === current.focusId);
335
+ const element = document.querySelector("#frame");
336
+ if (document.body.dataset.conversationPane !== "open" || document.body.dataset.conversationHost !== "adjacent") {
337
+ publish();
338
+ return;
339
+ }
340
+ if (state?.state === "found" && element &&
341
+ (Math.abs(element.clientWidth - state.viewport.width) > 1 || Math.abs(element.clientHeight - state.viewport.height) > 1)) {
342
+ publish();
343
+ return; // Wait for fresh geometry at the current frame size.
344
+ }
345
+ const size = threadSizes.get(current.focusId);
346
+ if (!size) {
347
+ publish();
348
+ return;
349
+ }
350
+ adjacent = element && state ? placeConversationSurface(state, {
351
+ frameRect: element.getBoundingClientRect(), viewport: visibleViewport(window),
352
+ toolbarHeight: Math.max(0, contentTop() - visibleViewport(window).top),
353
+ ...size,
354
+ }) : null;
355
+ if (!adjacent) {
356
+ fallbackSpace = state?.state === "found" && state.relation === "visible";
357
+ anchorNotice = fallbackSpace
358
+ ? "Opened in Feedback because there is not enough room beside, above or below the target without covering it."
359
+ : describeConversationAnchor(state).reason;
360
+ if (fallbackSpace)
361
+ owner.commands.focus(current.focusId);
362
+ else
363
+ owner.commands.fallback();
364
+ }
365
+ else
366
+ anchorNotice = "";
367
+ publish();
368
+ }
369
+ function showAdjacent(id) {
370
+ if (!geometry.states.some((state) => state.threadId === id && state.state === "found")) {
371
+ throw new Error("The target is unavailable. Continue this conversation in Feedback.");
372
+ }
373
+ anchorThread = id;
374
+ anchorNotice = "";
375
+ fallbackSpace = false;
376
+ owner.commands.adjacent(id);
377
+ requestAnimationFrame(() => { if (!disposed)
378
+ updatePlacement(); });
379
+ }
380
+ async function configure() {
381
+ if (loading)
382
+ return;
383
+ const request = ++configurationRequest, identity = frame.identity();
384
+ const applied = await frame.configure(blocked() ? "view" : mode, policy(), owner.getSnapshot().canComment);
385
+ if (!applied && request === configurationRequest && frame.matches(identity))
386
+ throw new Error("The page did not confirm its review mode and comment availability.");
387
+ publish();
388
+ }
389
+ async function activate() {
390
+ syncAnchors();
391
+ frame.send({ type: "eh:restoreScroll", ...scroll });
392
+ frame.setTheme(theme);
393
+ await configure();
394
+ frame.restoredScroll();
395
+ if (page?.kind !== "url") {
396
+ const identity = frame.identity();
397
+ const raw = record(await api.request(`/api/page/${identity.key}/raw`));
398
+ if (identity.renderId !== frame.state.renderId)
399
+ return;
400
+ if (typeof raw.hash !== "string" || typeof raw.html !== "string")
401
+ throw new Error("Invalid source baseline.");
402
+ if (frame.state.sourceHash !== raw.hash)
403
+ throw new Error("Source changed while rendering. Reload latest before editing.");
404
+ save.baseline(raw.hash);
405
+ if (policy() === "writable")
406
+ frame.send({ type: "eh:raw", html: raw.html });
407
+ }
408
+ await owner.refresh();
409
+ void captureResults().catch(reportSource);
410
+ publish();
411
+ }
412
+ async function load(key) {
413
+ captures.cancel();
414
+ observedView = null;
415
+ if (frame.state.renderId)
416
+ frame.startReload();
417
+ loading = true;
418
+ sourceError = "";
419
+ reloadPending = false;
420
+ clearAnchors("render-loading");
421
+ const generation = frame.begin(key);
422
+ publish();
423
+ const loaded = decodePage(await api.request(`/api/page/${key}?session=${encodeURIComponent(sessionId)}`));
424
+ if (frame.state.key !== key || frame.state.generation !== generation)
425
+ return;
426
+ page = loaded;
427
+ document.title = loaded.filename || "doc-review";
428
+ frame.setPolicy(framePolicy(page, artifactOrigin));
429
+ save.reset();
430
+ await frame.register(key, generation);
431
+ }
432
+ async function navigate(key) {
433
+ if (key === frame.state.key)
434
+ return;
435
+ await save.barrier();
436
+ await api.request(`/api/session/${sessionId}/goto`, { method: "POST", body: JSON.stringify({ key }) });
437
+ scroll = { x: 0, y: 0 };
438
+ await load(key);
439
+ }
440
+ async function navigateHref(href) {
441
+ pendingJump = null;
442
+ await save.barrier();
443
+ const resolved = record(await api.request(`/api/session/${sessionId}/resolve-target`, { method: "POST", body: JSON.stringify({ href }) }));
444
+ if (typeof resolved.target !== "string")
445
+ throw new Error("Navigation target could not be identified.");
446
+ const joined = owner.pages.find(({ page }) => (page.target.kind === "file" ? page.target.path : page.target.url) === resolved.target);
447
+ if (joined)
448
+ return navigate(joined.page.pageKey);
449
+ const receipt = await owner.mutate("join-page", { target: resolved.target });
450
+ if (!receipt.value.pageKey)
451
+ throw new Error("Joined page identity is missing.");
452
+ await navigate(receipt.value.pageKey);
453
+ }
454
+ function captureScope(submissionId) {
455
+ const identity = frame.identity();
456
+ if (!identity.key || !identity.renderId || identity.loading)
457
+ throw new Error("The page is not ready to capture.");
458
+ return { ...reference, submissionId, pageKey: identity.key, sessionId, renderId: identity.renderId,
459
+ generation: identity.generation, sourceHash: frame.state.sourceHash, view: normalizeView(observedView) };
460
+ }
461
+ function currentCapture(scope) {
462
+ if (disposed || loading || frame.identity().loading || !frame.state.renderId)
463
+ return false;
464
+ return resultCaptureKey(scope) === resultCaptureKey(captureScope(scope.submissionId));
465
+ }
466
+ async function capture(submissionId, scope) {
467
+ const snapshot = record(await save.captureStable(() => captures.request()));
468
+ const identity = frame.identity();
469
+ if (scope && (!currentCapture(scope) || !sameObservedView(scope.view, snapshot.view))) {
470
+ throw new Error("The page or visible view changed during capture. Retry on the current page.");
471
+ }
472
+ await api.request("/api/conversation/capture", { method: "POST", body: JSON.stringify({
473
+ ...reference, pageKey: identity.key, submissionId, sessionId, renderId: identity.renderId,
474
+ generation: identity.generation, expectedSourceHash: snapshot.sourceHash ?? frame.state.sourceHash,
475
+ semantic: snapshot.semantic, semanticCapturedAt: snapshot.semanticCapturedAt, view: snapshot.view,
476
+ }) });
477
+ }
478
+ function captureResults() {
479
+ if (frame.identity().loading)
480
+ return Promise.resolve();
481
+ if (captureCycle)
482
+ return captureCycle;
483
+ captureCycle = capturePendingResults().finally(() => { captureCycle = null; });
484
+ return captureCycle;
485
+ }
486
+ async function capturePendingResults() {
487
+ for (const item of owner.getSnapshot().history) {
488
+ if (frame.identity().loading)
489
+ return;
490
+ const scope = captureScope(item.submissionId), key = resultCaptureKey(scope);
491
+ if (item.result?.effect !== "changes-reported" || !["pending", "partial", "failed", "unavailable"].includes(item.comparisonStatus) || capturesAttempted.has(key))
492
+ continue;
493
+ const submission = owner.getSnapshot().submissions.find((entry) => entry.id === item.submissionId)?.value.submission;
494
+ if (!submission?.pageKeys.includes(frame.state.key ?? ""))
495
+ continue;
496
+ capturesAttempted.add(key);
497
+ try {
498
+ await resultCaptures.request(scope);
499
+ }
500
+ catch { /* The exact result/page owns its capture failure, not the source-save banner. */ }
501
+ }
502
+ }
503
+ function connect() {
504
+ eventSource?.close();
505
+ eventSource = new EventSource(`/events/${sessionId}`);
506
+ eventSource.addEventListener("invalidate", () => { void owner.refresh().catch(owner.report); });
507
+ eventSource.addEventListener("open", () => {
508
+ owner.commands.connected(true);
509
+ connectionError = "";
510
+ publish();
511
+ void owner.refresh().catch(owner.report);
512
+ });
513
+ eventSource.addEventListener("reload", () => {
514
+ if (save.state.dirty || save.state.conflict || save.queued(frame.state.key)) {
515
+ reloadPending = true;
516
+ save.hold();
517
+ publish();
518
+ }
519
+ else if (frame.state.key)
520
+ void load(frame.state.key).catch(reportSource);
521
+ });
522
+ eventSource.onerror = () => {
523
+ eventSource?.close();
524
+ owner.commands.connected(false);
525
+ connectionError = "Connection lost. Reattaching this exact review; local drafts remain in this tab.";
526
+ publish();
527
+ scheduleReconnect();
528
+ };
529
+ }
530
+ function scheduleReconnect() {
531
+ if (!disposed && !reconnectTimer)
532
+ reconnectTimer = setTimeout(() => {
533
+ reconnectTimer = null;
534
+ void reconnect().catch((cause) => { connectionError = String(cause); publish(); scheduleReconnect(); });
535
+ }, 1000);
536
+ }
537
+ async function reconnect() {
538
+ if (reconnecting)
539
+ return reconnecting;
540
+ if (reconnectTimer) {
541
+ clearTimeout(reconnectTimer);
542
+ reconnectTimer = null;
543
+ }
544
+ reconnecting = (async () => {
545
+ const existing = await fetch(`/api/session/${sessionId}/page`, {
546
+ headers: { "x-doc-review-token": document.body.dataset.token }, cache: "no-store", signal: lifetime.signal,
547
+ });
548
+ if (existing.ok) {
549
+ const bootstrap = record(await existing.json());
550
+ const attached = reviewSchema.parse(bootstrap.review);
551
+ if (attached.reviewId !== reference.reviewId || attached.entryKey !== reference.entryKey || bootstrap.key !== frame.state.key) {
552
+ throw new Error("The active session no longer matches this review page.");
553
+ }
554
+ await owner.refresh();
555
+ const current = owner.pages.find(item => item.page.pageKey === frame.state.key)?.page;
556
+ if (current?.sourceHash && current.sourceHash !== frame.state.sourceHash) {
557
+ sourceError = "Source changed while disconnected. Local document state is preserved; inspect it before reloading.";
558
+ reloadPending = true;
559
+ save.hold();
560
+ publish();
561
+ }
562
+ connect();
563
+ return;
564
+ }
565
+ if (![401, 404].includes(existing.status))
566
+ throw new Error(`Connection recovery failed (${existing.status}).`);
567
+ const response = await fetch(`/r/${encodeURIComponent(reference.reviewId)}`, { cache: "no-store", signal: lifetime.signal });
568
+ if (!response.ok)
569
+ throw new Error(`Review reattachment failed (${response.status}).`);
570
+ const body = new DOMParser().parseFromString(await response.text(), "text/html").body;
571
+ if (decodeURIComponent(body.dataset.review ?? "") !== reference.reviewId || decodeURIComponent(body.dataset.entry ?? "") !== reference.entryKey ||
572
+ !body.dataset.token || !body.dataset.session)
573
+ throw new Error("Reattachment returned a different or invalid review.");
574
+ api.setToken(body.dataset.token);
575
+ sessionId = body.dataset.session;
576
+ document.body.dataset.session = sessionId;
577
+ document.body.dataset.token = body.dataset.token;
578
+ const key = frame.state.key ?? reference.entryKey;
579
+ captures.cancel();
580
+ stopFrame();
581
+ frame.dispose();
582
+ frame = makeFrame();
583
+ clearAnchors("render-changed");
584
+ stopFrame = frame.subscribe(frameChanged);
585
+ await owner.refresh();
586
+ if (save.state.dirty || save.queued(key)) {
587
+ sourceError = "Server restarted with local source edits. Reconcile any unknown request and inspect source before reloading; conversation drafts are kept.";
588
+ reloadPending = true;
589
+ loading = true;
590
+ }
591
+ else {
592
+ if (key !== reference.entryKey)
593
+ await api.request(`/api/session/${sessionId}/goto`, { method: "POST", body: JSON.stringify({ key }) });
594
+ await load(key);
595
+ }
596
+ connect();
597
+ })().finally(() => { reconnecting = null; });
598
+ return reconnecting;
599
+ }
600
+ window.addEventListener("message", (event) => {
601
+ if (frame.handleThemeMessage(event) || !frame.accepts(event))
602
+ return;
603
+ const message = event.data;
604
+ void (async () => {
605
+ switch (message.type) {
606
+ case "eh:ready":
607
+ await frame.ready();
608
+ break;
609
+ case "eh:configurationApplied":
610
+ frame.configured(message.mode, message.savePolicy, message.canComment);
611
+ break;
612
+ case "eh:openComment": {
613
+ let accepted = false;
614
+ try {
615
+ const detail = readNewMessageTarget(message);
616
+ if (detail.generation <= retiredTarget ||
617
+ (detail.generation < targetHighWater && detail.generation !== pendingTarget))
618
+ throw new Error("The new-comment target is stale.");
619
+ targetHighWater = Math.max(targetHighWater, detail.generation);
620
+ if (owner.review?.state === "open" && frame.state.key && !loading) {
621
+ if (pendingTarget === detail.generation && owner.getSnapshot().newMessage) {
622
+ if (JSON.stringify(detail.target) !== JSON.stringify(newTarget?.target))
623
+ throw new Error("Target identity changed without a new generation.");
624
+ newTarget = detail;
625
+ owner.commands.compose();
626
+ accepted = true;
627
+ }
628
+ else {
629
+ const previous = newTarget;
630
+ newTarget = detail;
631
+ try {
632
+ accepted = owner.commands.begin(frame.state.key, detail.target, true);
633
+ }
634
+ catch (cause) {
635
+ newTarget = previous;
636
+ throw cause;
637
+ }
638
+ if (accepted)
639
+ pendingTarget = detail.generation;
640
+ else
641
+ newTarget = previous;
642
+ }
643
+ }
644
+ }
645
+ finally {
646
+ frame.send({ type: "eh:commentOpenResult", accepted, requestedGeneration: message.targetGeneration, targetGeneration: pendingTarget });
647
+ }
648
+ break;
649
+ }
650
+ case "eh:target": {
651
+ const detail = readNewMessageTarget(message);
652
+ targetHighWater = Math.max(targetHighWater, detail.generation);
653
+ break;
654
+ }
655
+ case "eh:targetGeometry": {
656
+ if (message.targetGeneration !== pendingTarget || !newTarget)
657
+ break;
658
+ const detail = readNewMessageTarget(message);
659
+ if (JSON.stringify(detail.target) !== JSON.stringify(newTarget.target))
660
+ throw new Error("Target geometry cannot replace the original anchor.");
661
+ newTarget = detail;
662
+ updatePlacement();
663
+ break;
664
+ }
665
+ case "eh:revealTargetResult":
666
+ if (message.targetGeneration === pendingTarget && message.success === false) {
667
+ composerNotice = "The original target could not be revealed. Your draft is kept in Feedback.";
668
+ if (owner.getSnapshot().open)
669
+ owner.commands.focus(null);
670
+ publish();
671
+ }
672
+ break;
673
+ case "eh:threadAnchorStates":
674
+ if (!geometry.receive(message))
675
+ break;
676
+ updatePlacement();
677
+ if (pendingJump && owner.getSnapshot().threads.some(({ thread }) => thread.threadId === pendingJump && thread.pageKey === frame.state.key)) {
678
+ const id = pendingJump;
679
+ pendingJump = null;
680
+ const state = geometry.states.find((item) => item.threadId === id);
681
+ if (state?.state === "found")
682
+ revealThread(id);
683
+ else {
684
+ anchorNotice = describeConversationAnchor(state).reason;
685
+ owner.commands.focus(null);
686
+ }
687
+ }
688
+ break;
689
+ case "eh:threadAction": {
690
+ const action = geometry.incoming(message);
691
+ if (!action)
692
+ break;
693
+ if (action.action === "activate") {
694
+ const current = owner.getSnapshot();
695
+ if (current.open && (current.host === "adjacent" || current.host === "focus") &&
696
+ current.focusId && geometry.peers(action.threadId).includes(current.focusId)) {
697
+ owner.commands.open(false);
698
+ }
699
+ else
700
+ showAdjacent(action.threadId);
701
+ }
702
+ else if (owner.getSnapshot().focusId === action.threadId)
703
+ owner.commands.open(false);
704
+ break;
705
+ }
706
+ case "eh:edit": {
707
+ if (!frame.state.key || blocked())
708
+ throw new Error("Direct edits are blocked by review lifecycle or outstanding work.");
709
+ const content = {};
710
+ for (const name of ["label", "kind", "before", "after", "before_html", "after_html", "moved_after", "moved_before", "truncated", "truncated_fields", "staged_assets"]) {
711
+ if (message[name] !== undefined)
712
+ content[name] = message[name];
713
+ }
714
+ save.markEdit();
715
+ await save.persistEdit(frame.state.key, content);
716
+ break;
717
+ }
718
+ case "eh:html":
719
+ if (typeof message.html === "string") {
720
+ if (blocked())
721
+ throw new Error("Source writes are blocked.");
722
+ await save.settleEdits(frame.state.key);
723
+ if (!save.state.dynamic)
724
+ await save.save(message.html);
725
+ }
726
+ break;
727
+ case "eh:saving":
728
+ save.markSaving();
729
+ break;
730
+ case "eh:clean":
731
+ save.observeClean();
732
+ break;
733
+ case "eh:dynamic":
734
+ save.markDynamic();
735
+ break;
736
+ case "eh:flushed":
737
+ frame.flushed(message.requestId);
738
+ break;
739
+ case "eh:snapshot":
740
+ captures.receive(message);
741
+ break;
742
+ case "eh:viewChanged": {
743
+ const view = normalizeView(message.view);
744
+ if (!sameObservedView(observedView, view)) {
745
+ observedView = view;
746
+ await captureCycle;
747
+ await captureResults();
748
+ }
749
+ break;
750
+ }
751
+ case "eh:scroll":
752
+ if (typeof message.x === "number" && typeof message.y === "number")
753
+ scroll = { x: message.x, y: message.y };
754
+ break;
755
+ case "eh:navigate":
756
+ if (typeof message.href === "string")
757
+ await navigateHref(message.href);
758
+ break;
759
+ case "eh:external": {
760
+ const url = externalHref(message.href, location.href);
761
+ if (!url)
762
+ throw new Error("Unsupported external link.");
763
+ window.open(url, "_blank", "noopener");
764
+ break;
765
+ }
766
+ case "eh:formBlocked": throw new Error(typeof message.reason === "string" ? message.reason : "This form cannot be submitted from View mode.");
767
+ case "eh:asset": {
768
+ const identity = frame.identity();
769
+ try {
770
+ if (blocked() || !(message.bytes instanceof ArrayBuffer))
771
+ throw new Error("Image staging is unavailable.");
772
+ const bytes = new Uint8Array(message.bytes);
773
+ let binary = "";
774
+ for (let offset = 0; offset < bytes.length; offset += 8192)
775
+ binary += String.fromCharCode(...bytes.subarray(offset, offset + 8192));
776
+ const asset = record(await api.request("/api/conversation/asset", { method: "POST", body: JSON.stringify({
777
+ ...reference, pageKey: identity.key, type: message.assetType, base64: btoa(binary),
778
+ }) }));
779
+ if (!frame.matches(identity))
780
+ throw new Error("Image upload belongs to a replaced render and was not inserted.");
781
+ if (typeof asset.id !== "string" || asset.preview_src !== `__doc_review_paste__/${asset.id}`)
782
+ throw new Error("Invalid staged image reference.");
783
+ const preview = `${artifactOrigin}/artifact/${identity.renderId}/${asset.preview_src}`;
784
+ assetUrls.set(preview, asset.preview_src);
785
+ frame.send({ type: "eh:assetSaved", id: message.id, src: preview, stagedId: asset.id });
786
+ }
787
+ catch (cause) {
788
+ if (frame.matches(identity))
789
+ frame.send({ type: "eh:assetFailed", id: message.id });
790
+ throw cause;
791
+ }
792
+ break;
793
+ }
794
+ }
795
+ })().catch(owner.report);
796
+ }, { signal: lifetime.signal });
797
+ window.addEventListener("beforeunload", (event) => {
798
+ if (owner.draftsPresent || save.state.dirty || owner.getSnapshot().uncertain) {
799
+ event.preventDefault();
800
+ event.returnValue = "";
801
+ }
802
+ }, { signal: lifetime.signal });
803
+ function dispose() {
804
+ disposed = true;
805
+ eventSource?.close();
806
+ if (reconnectTimer)
807
+ clearTimeout(reconnectTimer);
808
+ captures.cancel();
809
+ lifetime.abort();
810
+ stopOwner();
811
+ stopSave();
812
+ stopFrame();
813
+ save.dispose();
814
+ frame.dispose();
815
+ api.dispose();
816
+ owner.dispose();
817
+ chrome.dispose();
818
+ }
819
+ window.addEventListener("pagehide", (event) => { if (!event.persisted)
820
+ dispose(); }, { signal: lifetime.signal });
821
+ window.addEventListener("online", () => { void owner.refresh().catch(owner.report); }, { signal: lifetime.signal });
822
+ window.addEventListener("resize", updatePlacement, { signal: lifetime.signal });
823
+ window.visualViewport?.addEventListener("resize", updatePlacement, { signal: lifetime.signal });
824
+ window.visualViewport?.addEventListener("scroll", updatePlacement, { signal: lifetime.signal });
825
+ function contentTop() { return document.querySelector(".shell-toolbar")?.getBoundingClientRect().bottom ?? 0; }
826
+ function updateLayout() {
827
+ document.documentElement.style.setProperty("--toolbar-h", `${contentTop()}px`);
828
+ updatePlacement();
829
+ }
830
+ const layoutObserver = new ResizeObserver(updateLayout);
831
+ const stage = document.querySelector(".stage");
832
+ if (stage)
833
+ layoutObserver.observe(stage);
834
+ const toolbar = document.querySelector(".shell-toolbar");
835
+ if (toolbar)
836
+ layoutObserver.observe(toolbar);
837
+ lifetime.signal.addEventListener("abort", () => layoutObserver.disconnect(), { once: true });
838
+ async function selectComparison(submissionId, pageKey, mode) {
839
+ const detail = owner.getSnapshot().submissions.find((item) => item.id === submissionId)?.value;
840
+ if (!detail?.result || !detail.submission.pageKeys.includes(pageKey) || !["source", "content"].includes(mode)) {
841
+ throw new Error("Choose a handled submission and a page belonging to that submission.");
842
+ }
843
+ const request = ++comparisonRequest;
844
+ const previous = comparison?.submissionId === submissionId && comparison.pageKey === pageKey && comparison.mode === mode ? comparison.value : null;
845
+ beginComparison();
846
+ if (detail.result.effect === "reply-only") {
847
+ comparison = { value: null, submissionId, pageKey, mode, loading: false, error: "" };
848
+ publish();
849
+ return;
850
+ }
851
+ comparison = { value: previous, submissionId, pageKey, mode, loading: true, error: "" };
852
+ publish();
853
+ try {
854
+ const value = decodeComparison(await api.request("/api/conversation/comparison", { method: "POST", body: JSON.stringify({ ...reference, submissionId, pageKey, mode }) }), mode);
855
+ if (disposed || request !== comparisonRequest)
856
+ return;
857
+ if (mode === "content")
858
+ resultCaptures.confirmContent({ ...reference, submissionId, pageKey }, value);
859
+ comparison = { value: JSON.stringify(previous) === JSON.stringify(value) ? previous : value, submissionId, pageKey, mode, loading: false, error: "" };
860
+ }
861
+ catch (cause) {
862
+ if (disposed || request !== comparisonRequest)
863
+ return;
864
+ comparison = { value: previous, submissionId, pageKey, mode, loading: false, error: cause instanceof Error ? cause.message : String(cause) };
865
+ }
866
+ publish();
867
+ }
868
+ async function showChanges() {
869
+ const snapshot = owner.getSnapshot();
870
+ const previous = comparison && snapshot.submissions.find((item) => item.id === comparison?.submissionId)?.value;
871
+ if (comparison && previous?.result?.effect === "changes-reported" && previous.submission.pageKeys.includes(comparison.pageKey)) {
872
+ if (comparisonOpen)
873
+ return;
874
+ await selectComparison(comparison.submissionId, comparison.pageKey, comparison.mode);
875
+ return;
876
+ }
877
+ const latest = snapshot.history.find((item) => item.result?.effect === "changes-reported" &&
878
+ snapshot.submissions.some((detail) => detail.id === item.submissionId && detail.value.result));
879
+ const detail = snapshot.submissions.find((item) => item.id === latest?.submissionId)?.value;
880
+ if (latest && detail?.submission.pageKeys[0]) {
881
+ const key = detail.submission.pageKeys.includes(frame.state.key ?? "") ? frame.state.key : detail.submission.pageKeys[0];
882
+ await selectComparison(latest.submissionId, key, "content");
883
+ }
884
+ else {
885
+ comparison = null;
886
+ beginComparison();
887
+ publish();
888
+ }
889
+ }
890
+ async function start() {
891
+ try {
892
+ theme = localStorage.getItem("doc-review:theme") === "dark" ? "dark" : "light";
893
+ }
894
+ catch (cause) {
895
+ owner.report(`Theme preference unavailable: ${String(cause)}`);
896
+ }
897
+ document.documentElement.dataset.theme = theme;
898
+ await owner.refresh();
899
+ const bootstrap = record(await api.request(`/api/session/${sessionId}/page`));
900
+ const reviewed = reviewSchema.parse(bootstrap.review);
901
+ if (reviewed.reviewId !== reference.reviewId || reviewed.entryKey !== reference.entryKey || typeof bootstrap.key !== "string")
902
+ throw new Error("Review bootstrap scope mismatch.");
903
+ if (typeof bootstrap.generation === "number")
904
+ frame.seedGeneration(bootstrap.generation);
905
+ await load(bootstrap.key);
906
+ connect();
907
+ }
908
+ void start().catch(owner.report);
909
+ return {
910
+ owner, getSnapshot: chrome.getSnapshot, subscribe: chrome.subscribe,
911
+ commands: {
912
+ measureComposer(height) {
913
+ if (!Number.isFinite(height) || height <= 0 || Math.abs(height - composerHeight) < 1)
914
+ return;
915
+ composerHeight = height;
916
+ updatePlacement();
917
+ },
918
+ measureThread(id, height, minHeight) {
919
+ if (![height, minHeight].every(value => Number.isFinite(value) && value > 0))
920
+ return;
921
+ const previous = threadSizes.get(id);
922
+ if (previous && Math.abs(previous.height - height) < 1 && Math.abs(previous.minHeight - minHeight) < 1)
923
+ return;
924
+ threadSizes.set(id, { height, minHeight });
925
+ updatePlacement();
926
+ },
927
+ composeBeside() { owner.commands.compose(); },
928
+ revealSelection() {
929
+ if (pendingTarget === null || !newTarget?.geometry || newTarget.geometry.relation === "unavailable" || loading)
930
+ return;
931
+ frame.send({ type: "eh:revealTarget", targetGeneration: pendingTarget });
932
+ },
933
+ adjacent: showAdjacent,
934
+ async mode(next) {
935
+ if (next === "edit" && blocked())
936
+ throw new Error("Edit is blocked by ended review or outstanding work.");
937
+ if (mode === "edit")
938
+ await save.barrier();
939
+ const previous = mode;
940
+ try {
941
+ mode = next;
942
+ await configure();
943
+ }
944
+ catch (cause) {
945
+ mode = previous;
946
+ throw cause;
947
+ }
948
+ finally {
949
+ publish();
950
+ }
951
+ },
952
+ theme(next) {
953
+ if (next === theme)
954
+ return;
955
+ theme = next;
956
+ document.documentElement.dataset.theme = theme;
957
+ frame.setTheme(theme);
958
+ try {
959
+ localStorage.setItem("doc-review:theme", theme);
960
+ }
961
+ catch (cause) {
962
+ owner.report(`Theme preference not saved: ${String(cause)}`);
963
+ }
964
+ publish();
965
+ },
966
+ reconnect,
967
+ async retryTheme() { if (!await frame.retryTheme())
968
+ throw new Error("The annotation tools could not confirm the latest theme."); },
969
+ async execution(preference) {
970
+ if (!page || page.kind !== "file" || page.markdown)
971
+ throw new Error("Script recovery is available only for HTML files.");
972
+ await save.barrier();
973
+ await api.request(`/api/session/${sessionId}/execution`, { method: "POST", body: JSON.stringify({ key: page.key, preference }) });
974
+ await load(page.key);
975
+ },
976
+ async reload() {
977
+ const key = frame.state.key ?? reference.entryKey;
978
+ await save.discardLocal(key);
979
+ await load(key);
980
+ },
981
+ showChanges,
982
+ comparison: selectComparison,
983
+ async recapture(submissionId, pageKey) {
984
+ if (pageKey !== frame.state.key)
985
+ throw new Error("Open this submission page before capturing its rendered content.");
986
+ const detail = owner.getSnapshot().submissions.find((item) => item.id === submissionId)?.value;
987
+ if (!detail?.result || !detail.submission.pageKeys.includes(pageKey))
988
+ throw new Error("Choose a handled submission and its own page.");
989
+ const selected = comparisonRequest;
990
+ try {
991
+ await resultCaptures.request(captureScope(submissionId));
992
+ if (!disposed && selected === comparisonRequest && comparisonOpen && comparison?.submissionId === submissionId &&
993
+ comparison.pageKey === pageKey && comparison.mode === "content")
994
+ await selectComparison(submissionId, pageKey, "content");
995
+ }
996
+ catch { /* The correlated result warning remains available without stealing the current view. */ }
997
+ },
998
+ closeComparison() {
999
+ comparisonRequest++;
1000
+ comparisonOpen = false;
1001
+ if (comparison)
1002
+ comparison = { ...comparison, loading: false };
1003
+ publish();
1004
+ const trigger = comparisonTrigger;
1005
+ comparisonTrigger = null;
1006
+ requestAnimationFrame(() => {
1007
+ (trigger?.isConnected && !trigger.hasAttribute("disabled") ? trigger : document.getElementById("latestVersion"))?.focus({ preventScroll: true });
1008
+ });
1009
+ },
1010
+ },
1011
+ dispose,
1012
+ };
1013
+ }