@erdemtuna/doc-review 0.9.0 → 0.11.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 +58 -222
- package/{src → lib}/SKILL.md +5 -2
- package/lib/anchor-text.js +155 -0
- package/lib/atomic-write.js +54 -0
- package/lib/changes-controller.js +184 -0
- package/lib/chrome-api.js +78 -0
- package/lib/chrome-client.js +1700 -0
- package/lib/chrome-session.js +73 -0
- package/lib/chrome.html +53 -0
- package/lib/cli.js +255 -0
- package/lib/click-target.js +28 -0
- package/lib/comment-anchor.js +23 -0
- package/lib/comment-target.js +164 -0
- package/lib/comments-controller.js +109 -0
- package/lib/contextual-controller.js +33 -0
- package/lib/contracts/feedback.js +1 -0
- package/lib/contracts/frame.js +7 -0
- package/lib/contracts/history.js +1 -0
- package/lib/contracts/index.js +2 -0
- package/lib/contracts/page.js +23 -0
- package/lib/controller-store.js +96 -0
- package/lib/document-execution.js +115 -0
- package/lib/edit-limits.js +24 -0
- package/lib/editing.js +107 -0
- package/lib/execution-client.js +19 -0
- package/lib/feedback-controller.js +113 -0
- package/lib/feedback-panel-controller.js +161 -0
- package/lib/frame-channel.js +42 -0
- package/lib/frame-controller.js +313 -0
- package/lib/frame-host.js +136 -0
- package/lib/frame-policy.js +50 -0
- package/lib/history-client.js +112 -0
- package/lib/history-coordinator.js +217 -0
- package/lib/history-policy.js +43 -0
- package/lib/history-server.js +464 -0
- package/lib/html-transform.js +52 -0
- package/lib/icons.js +325 -0
- package/{src → lib}/markdown.js +25 -26
- package/lib/paths.js +83 -0
- package/lib/poll-transport.js +220 -0
- package/lib/positioning.js +97 -0
- package/lib/recovery-controller.js +123 -0
- package/lib/review-controller.js +93 -0
- package/lib/review-mode.js +66 -0
- package/lib/revision-diff.js +482 -0
- package/lib/revision-schema.js +235 -0
- package/lib/revision-store.js +190 -0
- package/lib/save-controller.js +303 -0
- package/lib/sdk.js +2629 -0
- package/lib/semantic-snapshot.js +268 -0
- package/lib/serialize.js +32 -0
- package/lib/server-entry.js +26 -0
- package/lib/server-lock.js +109 -0
- package/lib/server.js +1501 -0
- package/lib/setup-guidance.js +119 -0
- package/{src → lib}/setup.js +44 -55
- package/lib/state.js +733 -0
- package/lib/toolbar-controller.js +32 -0
- package/lib/ui/THIRD_PARTY_NOTICES.md +1254 -0
- package/lib/ui/chrome.css +4881 -0
- package/lib/ui/chrome.js +52 -0
- package/lib/view-identity.js +112 -0
- package/package.json +50 -14
- package/src/anchor-text.js +0 -160
- package/src/atomic-write.js +0 -53
- package/src/chrome-client.js +0 -2747
- package/src/chrome-session.js +0 -85
- package/src/chrome.css +0 -652
- package/src/chrome.html +0 -196
- package/src/cli.js +0 -271
- package/src/click-target.js +0 -26
- package/src/comment-anchor.js +0 -22
- package/src/comment-target.js +0 -164
- package/src/comparison-view.js +0 -337
- package/src/document-execution.js +0 -103
- package/src/edit-limits.js +0 -23
- package/src/editing.js +0 -99
- package/src/execution-client.js +0 -63
- package/src/frame-channel.js +0 -44
- package/src/frame-policy.js +0 -54
- package/src/history-client.js +0 -104
- package/src/history-coordinator.js +0 -186
- package/src/history-policy.js +0 -43
- package/src/history-server.js +0 -463
- package/src/html-transform.js +0 -62
- package/src/icons.js +0 -251
- package/src/paths.js +0 -91
- package/src/poll-transport.js +0 -222
- package/src/positioning.js +0 -107
- package/src/review-mode.js +0 -59
- package/src/revision-diff.js +0 -440
- package/src/revision-schema.js +0 -219
- package/src/revision-store.js +0 -177
- package/src/sdk.js +0 -2607
- package/src/semantic-snapshot.js +0 -230
- package/src/serialize.js +0 -32
- package/src/server-entry.js +0 -26
- package/src/server-lock.js +0 -101
- package/src/server.js +0 -1466
- package/src/setup-guidance.js +0 -128
- package/src/state.js +0 -732
- package/src/view-identity.js +0 -99
- /package/{src → lib}/document-trust.js +0 -0
|
@@ -0,0 +1,1700 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* doc-review chrome. Owns the toolbar, contextual surfaces, drawer, and every
|
|
3
|
+
* call to the local server.
|
|
4
|
+
* It never touches the artifact DOM directly — the SDK does that, over
|
|
5
|
+
* postMessage, because the artifact iframe lives on the other loopback
|
|
6
|
+
* hostname: a separate origin that can never reach this page or its token.
|
|
7
|
+
*/
|
|
8
|
+
import { clearOwned, commentControlId as controlId, createCommentUi, migrateCommentUi, mutationIsCurrent, ownConfirmation, ownEdit, newestComments, pageUrl, reconcileCommentUi, replacePage, } from "./chrome-session.js";
|
|
9
|
+
import { sanitizeClientRects, sanitizeClipRect, sanitizeRelation } from "./comment-target.js";
|
|
10
|
+
import { externalHref } from "./editing.js";
|
|
11
|
+
import { framePolicy } from "./frame-policy.js";
|
|
12
|
+
import { createIcon } from "./icons.js";
|
|
13
|
+
import { executionPresentation } from "./execution-client.js";
|
|
14
|
+
import { createToolbarController } from "./toolbar-controller.js";
|
|
15
|
+
import { createRecoveryController } from "./recovery-controller.js";
|
|
16
|
+
import { createCommentsController } from "./comments-controller.js";
|
|
17
|
+
import { createComposerDraft, createContextualController } from "./contextual-controller.js";
|
|
18
|
+
import { alignedCardPosition, placeContextualSurface, visibleViewport } from "./positioning.js";
|
|
19
|
+
import { normalizeReviewMode, reviewConfiguration } from "./review-mode.js";
|
|
20
|
+
import { createCaptureRequests, captureError, sameRender, draftCount, pendingCaptureTarget } from "./history-client.js";
|
|
21
|
+
import { createCaptureCoordinator, createHistoryController, requireCaptureSuccess } from "./history-coordinator.js";
|
|
22
|
+
import { createReviewApi, decodePage } from "./chrome-api.js";
|
|
23
|
+
import { createFrameHost } from "./frame-host.js";
|
|
24
|
+
import { createFrameController } from "./frame-controller.js";
|
|
25
|
+
import { createSaveController } from "./save-controller.js";
|
|
26
|
+
import { createFeedbackController } from "./feedback-controller.js";
|
|
27
|
+
import { createFeedbackPanelController, createNoteDraft } from "./feedback-panel-controller.js";
|
|
28
|
+
import { createChangesController } from "./changes-controller.js";
|
|
29
|
+
import { createReviewController } from "./review-controller.js";
|
|
30
|
+
const $ = (id) => document.getElementById(id);
|
|
31
|
+
const ARTIFACT_HOST = location.hostname === "127.0.0.1" ? "localhost" : "127.0.0.1";
|
|
32
|
+
const ARTIFACT_ORIGIN = `${location.protocol}//${ARTIFACT_HOST}:${location.port}`;
|
|
33
|
+
const frameHost = createFrameHost($("frame"), ARTIFACT_ORIGIN);
|
|
34
|
+
const uiLifetime = new AbortController();
|
|
35
|
+
const uiTimers = new Set();
|
|
36
|
+
const uiFrames = new Set();
|
|
37
|
+
function afterPaint(callback) {
|
|
38
|
+
const id = requestAnimationFrame(() => {
|
|
39
|
+
uiFrames.delete(id);
|
|
40
|
+
if (!uiLifetime.signal.aborted)
|
|
41
|
+
callback();
|
|
42
|
+
});
|
|
43
|
+
uiFrames.add(id);
|
|
44
|
+
}
|
|
45
|
+
function afterDelay(callback, milliseconds) {
|
|
46
|
+
const id = setTimeout(() => {
|
|
47
|
+
uiTimers.delete(id);
|
|
48
|
+
if (!uiLifetime.signal.aborted)
|
|
49
|
+
callback();
|
|
50
|
+
}, milliseconds);
|
|
51
|
+
uiTimers.add(id);
|
|
52
|
+
}
|
|
53
|
+
function listen(target, type, handler, options = {}) {
|
|
54
|
+
target.addEventListener(type, handler, {
|
|
55
|
+
...(typeof options === "boolean" ? { capture: options } : options),
|
|
56
|
+
signal: uiLifetime.signal,
|
|
57
|
+
});
|
|
58
|
+
}
|
|
59
|
+
const state = {
|
|
60
|
+
sessionId: document.body.dataset.session,
|
|
61
|
+
token: document.body.dataset.token,
|
|
62
|
+
page: null,
|
|
63
|
+
compose: null,
|
|
64
|
+
composerDraft: createComposerDraft(),
|
|
65
|
+
composeLifecycle: "closed",
|
|
66
|
+
composePlacement: "hidden",
|
|
67
|
+
target: null,
|
|
68
|
+
activeSavedCommentId: null,
|
|
69
|
+
activeGeometry: new Map(),
|
|
70
|
+
commentUi: createCommentUi(),
|
|
71
|
+
pageEpoch: 0,
|
|
72
|
+
reviewMode: "view",
|
|
73
|
+
savePolicy: "writable",
|
|
74
|
+
modeApplying: false,
|
|
75
|
+
modeMenuOpen: false,
|
|
76
|
+
restoreModeFocus: false,
|
|
77
|
+
theme: "light",
|
|
78
|
+
drawerOpen: false,
|
|
79
|
+
agent: "idle",
|
|
80
|
+
orphans: new Set(),
|
|
81
|
+
pollCommand: "",
|
|
82
|
+
noteDraft: createNoteDraft(),
|
|
83
|
+
others: [],
|
|
84
|
+
scroll: { x: 0, y: 0 },
|
|
85
|
+
comparing: false,
|
|
86
|
+
executionPreference: null,
|
|
87
|
+
ended: false,
|
|
88
|
+
reloadVisible: false,
|
|
89
|
+
reloadMessage: "",
|
|
90
|
+
reloadFailed: false,
|
|
91
|
+
};
|
|
92
|
+
const diagnostic = (event, detail = {}) => {
|
|
93
|
+
console.info("[doc-review]", { event, ...detail });
|
|
94
|
+
};
|
|
95
|
+
const patchFlights = new Map();
|
|
96
|
+
const deleteFlights = new Map();
|
|
97
|
+
let requestedFocus = null;
|
|
98
|
+
let editFocusRequested = false;
|
|
99
|
+
let skipEditCaptureOnce = false;
|
|
100
|
+
function advancePageEpoch(reason) {
|
|
101
|
+
state.pageEpoch += 1;
|
|
102
|
+
diagnostic("page-epoch-advanced", { epoch: state.pageEpoch, reason });
|
|
103
|
+
return state.pageEpoch;
|
|
104
|
+
}
|
|
105
|
+
function commentById(id) {
|
|
106
|
+
return state.page?.comments?.find((comment) => comment.id === id) || null;
|
|
107
|
+
}
|
|
108
|
+
function requestControlFocus(id) {
|
|
109
|
+
if (state.drawerOpen && document.getElementById(id)?.closest("#cards")) {
|
|
110
|
+
commentsRuntime.commands.setSectionOpen(true);
|
|
111
|
+
}
|
|
112
|
+
requestedFocus = id;
|
|
113
|
+
}
|
|
114
|
+
function editTextarea(commentId) {
|
|
115
|
+
const root = state.drawerOpen ? $("cards") : $("alignedCard");
|
|
116
|
+
return root?.querySelector(`textarea[data-comment-edit="${CSS.escape(commentId)}"]`) || null;
|
|
117
|
+
}
|
|
118
|
+
function captureEditState() {
|
|
119
|
+
const edit = state.commentUi.edit;
|
|
120
|
+
if (!edit)
|
|
121
|
+
return false;
|
|
122
|
+
const input = editTextarea(edit.commentId);
|
|
123
|
+
if (!input)
|
|
124
|
+
return false;
|
|
125
|
+
edit.draft = input.value;
|
|
126
|
+
edit.selectionStart = input.selectionStart;
|
|
127
|
+
edit.selectionEnd = input.selectionEnd;
|
|
128
|
+
return document.activeElement === input;
|
|
129
|
+
}
|
|
130
|
+
function restoreTransientFocus(editWasFocused = false) {
|
|
131
|
+
const focusId = requestedFocus;
|
|
132
|
+
requestedFocus = null;
|
|
133
|
+
const shouldFocusEdit = editWasFocused || editFocusRequested;
|
|
134
|
+
afterPaint(() => {
|
|
135
|
+
if (focusId) {
|
|
136
|
+
const target = document.getElementById(focusId);
|
|
137
|
+
if (target?.closest("[hidden], [inert]")) {
|
|
138
|
+
$(state.comparing ? "latestVersion" : state.drawerOpen ? "commentsSection" : "commentsButton").focus();
|
|
139
|
+
}
|
|
140
|
+
else
|
|
141
|
+
target?.focus();
|
|
142
|
+
return;
|
|
143
|
+
}
|
|
144
|
+
const edit = state.commentUi.edit;
|
|
145
|
+
if (!shouldFocusEdit || !edit)
|
|
146
|
+
return;
|
|
147
|
+
const input = editTextarea(edit.commentId);
|
|
148
|
+
if (!input || input.closest("[hidden]")) {
|
|
149
|
+
editFocusRequested = true;
|
|
150
|
+
return;
|
|
151
|
+
}
|
|
152
|
+
editFocusRequested = false;
|
|
153
|
+
// A mounted React editor already owns its live selection; delayed focus must not reset it.
|
|
154
|
+
if (document.activeElement === input)
|
|
155
|
+
return;
|
|
156
|
+
input.focus();
|
|
157
|
+
input.setSelectionRange(edit.selectionStart, edit.selectionEnd);
|
|
158
|
+
});
|
|
159
|
+
}
|
|
160
|
+
function moveTransientSurface(surface) {
|
|
161
|
+
if (state.commentUi.confirmation)
|
|
162
|
+
state.commentUi.confirmation.surface = surface;
|
|
163
|
+
}
|
|
164
|
+
function announce(message) {
|
|
165
|
+
const region = $("liveRegion");
|
|
166
|
+
region.textContent = "";
|
|
167
|
+
afterPaint(() => {
|
|
168
|
+
region.textContent = message;
|
|
169
|
+
});
|
|
170
|
+
}
|
|
171
|
+
function replaceIcon(container, name, title = "") {
|
|
172
|
+
container.textContent = "";
|
|
173
|
+
container.append(createIcon(name, { title }));
|
|
174
|
+
}
|
|
175
|
+
function installStaticIcons() {
|
|
176
|
+
document.querySelectorAll("[data-icon]").forEach((container) => {
|
|
177
|
+
replaceIcon(container, container.dataset.icon);
|
|
178
|
+
});
|
|
179
|
+
}
|
|
180
|
+
function openModeMenu() {
|
|
181
|
+
if (state.modeMenuOpen)
|
|
182
|
+
return;
|
|
183
|
+
recoveryRuntime.commands.setMenuOpen(false, { restoreFocus: false });
|
|
184
|
+
state.modeMenuOpen = true;
|
|
185
|
+
state.restoreModeFocus = true;
|
|
186
|
+
toolbarRuntime.publish();
|
|
187
|
+
toFrame({ type: "eh:modeMenuState", open: true });
|
|
188
|
+
}
|
|
189
|
+
function closeModeMenu(reason = "dismissed", { restoreFocus = false } = {}) {
|
|
190
|
+
if (!state.modeMenuOpen)
|
|
191
|
+
return false;
|
|
192
|
+
state.modeMenuOpen = false;
|
|
193
|
+
state.restoreModeFocus = restoreFocus;
|
|
194
|
+
toolbarRuntime.publish();
|
|
195
|
+
toFrame({ type: "eh:modeMenuState", open: false });
|
|
196
|
+
diagnostic("mode-menu-close", { reason });
|
|
197
|
+
return true;
|
|
198
|
+
}
|
|
199
|
+
function openDrawer() {
|
|
200
|
+
captureEditState();
|
|
201
|
+
skipEditCaptureOnce = true;
|
|
202
|
+
state.drawerOpen = true;
|
|
203
|
+
moveTransientSurface("drawer");
|
|
204
|
+
editFocusRequested = !!state.commentUi.edit;
|
|
205
|
+
render();
|
|
206
|
+
if (!state.commentUi.edit)
|
|
207
|
+
afterPaint(() => $("commentsSection").focus());
|
|
208
|
+
}
|
|
209
|
+
function closeDrawer() {
|
|
210
|
+
if (!state.drawerOpen)
|
|
211
|
+
return;
|
|
212
|
+
captureEditState();
|
|
213
|
+
skipEditCaptureOnce = true;
|
|
214
|
+
state.drawerOpen = false;
|
|
215
|
+
const transientOwner = state.commentUi.edit?.commentId ||
|
|
216
|
+
state.commentUi.confirmation?.commentId ||
|
|
217
|
+
null;
|
|
218
|
+
if (transientOwner && commentById(transientOwner)) {
|
|
219
|
+
state.activeSavedCommentId = transientOwner;
|
|
220
|
+
moveTransientSurface("aligned");
|
|
221
|
+
toFrame({ type: "eh:activate", id: transientOwner, scroll: false });
|
|
222
|
+
}
|
|
223
|
+
else if (state.activeSavedCommentId) {
|
|
224
|
+
moveTransientSurface("aligned");
|
|
225
|
+
}
|
|
226
|
+
editFocusRequested = !!state.commentUi.edit;
|
|
227
|
+
render();
|
|
228
|
+
if (!state.commentUi.edit)
|
|
229
|
+
$("commentsButton").focus();
|
|
230
|
+
}
|
|
231
|
+
function setComposeLifecycle(next, reason) {
|
|
232
|
+
if (state.composeLifecycle === next)
|
|
233
|
+
return;
|
|
234
|
+
const from = state.composeLifecycle;
|
|
235
|
+
state.composeLifecycle = next;
|
|
236
|
+
contextualRuntime.publish();
|
|
237
|
+
diagnostic("composer-lifecycle-transition", { from, to: next, reason });
|
|
238
|
+
}
|
|
239
|
+
function setComposePlacement(next) {
|
|
240
|
+
if (state.composePlacement === next)
|
|
241
|
+
return;
|
|
242
|
+
const from = state.composePlacement;
|
|
243
|
+
state.composePlacement = next;
|
|
244
|
+
contextualRuntime.publish();
|
|
245
|
+
diagnostic("placement-transition", { from, to: next });
|
|
246
|
+
}
|
|
247
|
+
/**
|
|
248
|
+
* Most reviewers drive an agent from a chat (Claude Code, Codex, Cursor), not
|
|
249
|
+
* a bare terminal — so the handoff is a prompt the agent can act on, with the
|
|
250
|
+
* poll command embedded for anyone who does live in a shell.
|
|
251
|
+
*/
|
|
252
|
+
function handoffPrompt(pollCommand) {
|
|
253
|
+
const cmd = String(pollCommand || "").trim();
|
|
254
|
+
if (!cmd)
|
|
255
|
+
return "";
|
|
256
|
+
return `Run \`${cmd} --timeout 600\`, apply the feedback it returns, then run its exact \`next_step\` command with \`--ack <batch_id>\` until I end the review.`;
|
|
257
|
+
}
|
|
258
|
+
// ------------------------------------------------------------------- server
|
|
259
|
+
const transport = createReviewApi({ token: state.token });
|
|
260
|
+
const api = transport.request;
|
|
261
|
+
const frameController = createFrameController({
|
|
262
|
+
sessionId: state.sessionId, host: frameHost, request: api,
|
|
263
|
+
suspended() {
|
|
264
|
+
captures.cancel();
|
|
265
|
+
captureCoordinator.reset();
|
|
266
|
+
manualCaptureController?.abort();
|
|
267
|
+
},
|
|
268
|
+
transitioning() {
|
|
269
|
+
reviewController.invalidate();
|
|
270
|
+
if (state.compose) {
|
|
271
|
+
state.compose.unresolved = true;
|
|
272
|
+
state.compose.relation = "unavailable";
|
|
273
|
+
state.compose.rects = [];
|
|
274
|
+
state.compose.generation = -1;
|
|
275
|
+
}
|
|
276
|
+
state.target = null;
|
|
277
|
+
state.activeGeometry.clear();
|
|
278
|
+
saveController.baseline(null);
|
|
279
|
+
},
|
|
280
|
+
failed(message) {
|
|
281
|
+
state.reloadVisible = true;
|
|
282
|
+
state.reloadFailed = true;
|
|
283
|
+
state.reloadMessage = `${message} Reload keeps your comment drafts.`;
|
|
284
|
+
recoveryRuntime.publish();
|
|
285
|
+
toast(message);
|
|
286
|
+
},
|
|
287
|
+
});
|
|
288
|
+
const saveController = createSaveController({
|
|
289
|
+
sessionId: state.sessionId, current: () => frameController.identity(),
|
|
290
|
+
policy: () => state.savePolicy, request: api,
|
|
291
|
+
flush: (strict) => frameController.flush(strict), send: (message) => frameController.send(message),
|
|
292
|
+
sourceHash: (hash) => frameController.setSourceHash(hash),
|
|
293
|
+
pageChanged(page) { state.page = page; feedbackController.clearSent(); render(); },
|
|
294
|
+
conflict() { holdReload(); announce("Save conflict. Edit mode remains active so you can reload safely."); },
|
|
295
|
+
failed(message) { toast(message); announce("An edit could not be saved. Retry before leaving the page or sending feedback."); },
|
|
296
|
+
diagnostic, sending: () => feedbackController.sending,
|
|
297
|
+
});
|
|
298
|
+
const feedbackController = createFeedbackController({
|
|
299
|
+
sessionId: state.sessionId, current: () => frameController.identity(),
|
|
300
|
+
sourceHash: () => frameController.state.sourceHash, save: saveController,
|
|
301
|
+
policy: () => state.savePolicy, request: api,
|
|
302
|
+
capture: () => captureStablePage({ timeout: 500 }), refresh: () => refreshHistory(),
|
|
303
|
+
note: () => state.noteDraft.text,
|
|
304
|
+
clearNote(sent) {
|
|
305
|
+
if (state.noteDraft.text === sent && !state.noteDraft.composing)
|
|
306
|
+
Object.assign(state.noteDraft, createNoteDraft());
|
|
307
|
+
},
|
|
308
|
+
pauseCapture() {
|
|
309
|
+
captureCoordinator.reset();
|
|
310
|
+
manualCaptureController?.abort();
|
|
311
|
+
},
|
|
312
|
+
resumeCapture: () => scheduleResultCapture(),
|
|
313
|
+
failed(message) { toast(message); announce(message); },
|
|
314
|
+
warning: toast, announce,
|
|
315
|
+
});
|
|
316
|
+
const reviewController = createReviewController({
|
|
317
|
+
sessionId: state.sessionId, key: () => frameController.state.key,
|
|
318
|
+
generation: () => frameController.state.generation, request: api,
|
|
319
|
+
beforeRefresh: () => advancePageEpoch("acknowledgement"),
|
|
320
|
+
refreshPage(page) {
|
|
321
|
+
reconcilePage(page, { syncAnchors: true, reason: "acknowledgement", advance: false });
|
|
322
|
+
feedbackController.clearSent();
|
|
323
|
+
render();
|
|
324
|
+
},
|
|
325
|
+
load: async (key) => { state.scroll = { x: 0, y: 0 }; await loadPage(key); },
|
|
326
|
+
reload: () => reloadLatest(), history: () => refreshHistory(),
|
|
327
|
+
save: () => saveController.barrier(), suspend: () => frameController.suspend(),
|
|
328
|
+
hasDrafts: () => !!draftCount(state),
|
|
329
|
+
agent(value) { state.agent = value; render(); },
|
|
330
|
+
ended: showEnded, failed: (error) => { toast(error.message || String(error)); },
|
|
331
|
+
});
|
|
332
|
+
reviewController.own(frameController.subscribe(renderExecution));
|
|
333
|
+
reviewController.own(saveController.subscribe(renderSave));
|
|
334
|
+
reviewController.own(feedbackController.subscribe(render));
|
|
335
|
+
reviewController.own(() => uiLifetime.abort());
|
|
336
|
+
reviewController.own(() => {
|
|
337
|
+
for (const id of uiTimers)
|
|
338
|
+
clearTimeout(id);
|
|
339
|
+
for (const id of uiFrames)
|
|
340
|
+
cancelAnimationFrame(id);
|
|
341
|
+
uiTimers.clear();
|
|
342
|
+
uiFrames.clear();
|
|
343
|
+
});
|
|
344
|
+
reviewController.own(() => frameController.dispose());
|
|
345
|
+
reviewController.own(() => saveController.dispose());
|
|
346
|
+
reviewController.own(() => feedbackController.dispose());
|
|
347
|
+
reviewController.own(() => transport.dispose());
|
|
348
|
+
export const recoveryRuntime = createRecoveryController({
|
|
349
|
+
request: api,
|
|
350
|
+
sessionId: state.sessionId,
|
|
351
|
+
read: () => ({
|
|
352
|
+
page: state.page, rendered: frameHost.visibleExecution, identity: frameController.identity(),
|
|
353
|
+
comparing: state.comparing, ended: state.ended, loading: frameController.loading,
|
|
354
|
+
pendingReload: frameController.state.pendingReload || !!frameHost.previous,
|
|
355
|
+
frameError: frameController.state.phase.kind === "failed" ? frameController.state.phase.message : null,
|
|
356
|
+
reload: {
|
|
357
|
+
visible: state.reloadVisible, message: state.reloadMessage,
|
|
358
|
+
error: state.reloadFailed || saveController.state.conflict,
|
|
359
|
+
},
|
|
360
|
+
}),
|
|
361
|
+
changed(page) {
|
|
362
|
+
if (!page || page.key !== frameController.state.key)
|
|
363
|
+
return;
|
|
364
|
+
state.executionPreference = page.executionPreference || null;
|
|
365
|
+
state.page = {
|
|
366
|
+
...state.page,
|
|
367
|
+
executionPreference: page.executionPreference,
|
|
368
|
+
...frameController.state.execution,
|
|
369
|
+
};
|
|
370
|
+
state.savePolicy = reviewConfiguration(state.page, state.reviewMode).savePolicy;
|
|
371
|
+
render();
|
|
372
|
+
},
|
|
373
|
+
failed: toast,
|
|
374
|
+
menuChanged(open) {
|
|
375
|
+
if (open) {
|
|
376
|
+
closeModeMenu("recovery-menu");
|
|
377
|
+
}
|
|
378
|
+
toFrame({ type: "eh:modeMenuState", open });
|
|
379
|
+
},
|
|
380
|
+
reload: () => reloadLatest({ explicit: true }),
|
|
381
|
+
keepCurrent() {
|
|
382
|
+
state.reloadVisible = false;
|
|
383
|
+
announce("Keeping the current page. Return to Review to reload when ready.");
|
|
384
|
+
},
|
|
385
|
+
});
|
|
386
|
+
reviewController.own(() => recoveryRuntime.dispose());
|
|
387
|
+
function pendingFeedbackCount() {
|
|
388
|
+
return (state.page?.comments?.length || 0) + (state.page?.edits?.length || 0) +
|
|
389
|
+
(state.others || []).reduce((sum, other) => sum + other.count, 0);
|
|
390
|
+
}
|
|
391
|
+
export const toolbarRuntime = createToolbarController(() => ({
|
|
392
|
+
comparing: state.comparing,
|
|
393
|
+
mode: normalizeReviewMode(state.reviewMode),
|
|
394
|
+
modeDisabled: state.modeApplying || state.comparing || feedbackController.sending || !frameController.state.execution,
|
|
395
|
+
modeMenuOpen: state.modeMenuOpen,
|
|
396
|
+
restoreModeFocus: state.restoreModeFocus,
|
|
397
|
+
editDescription: executionPresentation(state.page, frameHost.visibleExecution).editDescription,
|
|
398
|
+
drawerOpen: state.drawerOpen,
|
|
399
|
+
feedbackCount: pendingFeedbackCount(),
|
|
400
|
+
theme: state.theme === "dark" ? "dark" : "light",
|
|
401
|
+
ended: state.ended,
|
|
402
|
+
}), {
|
|
403
|
+
setComparing: setHistoryView,
|
|
404
|
+
setMode: (mode) => { void setReviewMode(mode); },
|
|
405
|
+
setModeMenu: (open) => {
|
|
406
|
+
if (open)
|
|
407
|
+
openModeMenu();
|
|
408
|
+
else
|
|
409
|
+
closeModeMenu("dismissed", { restoreFocus: true });
|
|
410
|
+
},
|
|
411
|
+
openComments: openDrawer,
|
|
412
|
+
toggleTheme,
|
|
413
|
+
});
|
|
414
|
+
reviewController.own(() => toolbarRuntime.dispose());
|
|
415
|
+
export const commentsRuntime = createCommentsController({
|
|
416
|
+
read: () => ({
|
|
417
|
+
open: state.drawerOpen, ended: state.ended, comparing: state.comparing,
|
|
418
|
+
hasPage: !!state.page, composeOpen: !!state.compose,
|
|
419
|
+
error: frameController.state.phase.kind === "failed" ? frameController.state.phase.message : null,
|
|
420
|
+
comments: state.page?.comments || [], others: state.others || [],
|
|
421
|
+
activeId: state.activeSavedCommentId, orphans: state.orphans, ui: state.commentUi,
|
|
422
|
+
}),
|
|
423
|
+
close: closeDrawer,
|
|
424
|
+
activate: setActive,
|
|
425
|
+
edit: (id, surface) => startCommentEdit(commentById(id), surface),
|
|
426
|
+
save: saveCommentEdit,
|
|
427
|
+
cancelEdit: cancelCommentEdit,
|
|
428
|
+
confirm: confirmCommentDelete,
|
|
429
|
+
dismiss: () => dismissActiveComment("close", { restoreFocus: true }),
|
|
430
|
+
cancelDelete: cancelDeleteConfirmation,
|
|
431
|
+
remove: deleteComment,
|
|
432
|
+
async navigate(key) {
|
|
433
|
+
try {
|
|
434
|
+
await reviewController.navigate({ key });
|
|
435
|
+
}
|
|
436
|
+
catch (err) {
|
|
437
|
+
toast(`${err.message}. Stay on this page and retry.`);
|
|
438
|
+
}
|
|
439
|
+
},
|
|
440
|
+
});
|
|
441
|
+
reviewController.own(() => commentsRuntime.dispose());
|
|
442
|
+
export const contextualRuntime = createContextualController({
|
|
443
|
+
read: () => ({
|
|
444
|
+
open: !!state.compose, disabled: state.ended || state.comparing,
|
|
445
|
+
submitting: state.composeLifecycle === "submitting",
|
|
446
|
+
kind: state.compose?.kind || "selection", quote: state.compose?.quote || "",
|
|
447
|
+
placement: state.composePlacement, draft: state.composerDraft,
|
|
448
|
+
}),
|
|
449
|
+
submit: commitCompose, cancel: cancelCompose,
|
|
450
|
+
reveal() {
|
|
451
|
+
if (!state.compose || state.compose.unresolved)
|
|
452
|
+
return;
|
|
453
|
+
diagnostic("reveal-target-requested");
|
|
454
|
+
toFrame({ type: "eh:revealTarget", targetGeneration: state.compose.generation });
|
|
455
|
+
},
|
|
456
|
+
focus() { $("compose").classList.remove("pass-through"); },
|
|
457
|
+
measure: scheduleContextualPosition,
|
|
458
|
+
});
|
|
459
|
+
reviewController.own(() => contextualRuntime.dispose());
|
|
460
|
+
export const feedbackRuntime = createFeedbackPanelController({
|
|
461
|
+
read: () => ({
|
|
462
|
+
note: state.noteDraft, ended: state.ended,
|
|
463
|
+
available: !!state.page && !frameController.loading && !state.comparing,
|
|
464
|
+
identity: JSON.stringify([frameController.state.key, frameController.state.renderId, frameController.state.generation, state.pageEpoch, frameController.state.pendingReload]),
|
|
465
|
+
source: frameController.state.sourceHash,
|
|
466
|
+
edits: state.page?.edits || [],
|
|
467
|
+
total: pendingFeedbackCount(),
|
|
468
|
+
drafts: draftCount(state), agent: state.agent,
|
|
469
|
+
prompt: handoffPrompt(state.pollCommand || state.page?.pollCommand),
|
|
470
|
+
filename: state.page?.filename || "", kind: state.page?.kind || "", markdown: !!state.page?.markdown,
|
|
471
|
+
save: saveController.getSnapshot(), delivery: feedbackController.getSnapshot(),
|
|
472
|
+
}),
|
|
473
|
+
send: () => feedbackController.send(),
|
|
474
|
+
async flush(action) {
|
|
475
|
+
if (action === "revert") {
|
|
476
|
+
// Revert owns save ordering and can discard edits that failed to persist.
|
|
477
|
+
await frameController.flush(true);
|
|
478
|
+
}
|
|
479
|
+
else {
|
|
480
|
+
await flushFrame({ strict: true });
|
|
481
|
+
await saveController.settled();
|
|
482
|
+
}
|
|
483
|
+
},
|
|
484
|
+
revert: () => saveController.revert(),
|
|
485
|
+
async end() {
|
|
486
|
+
await api(`/api/session/${state.sessionId}/end`, { method: "POST" });
|
|
487
|
+
showEnded();
|
|
488
|
+
},
|
|
489
|
+
copy: (text) => navigator.clipboard.writeText(text),
|
|
490
|
+
failed: toast,
|
|
491
|
+
});
|
|
492
|
+
reviewController.own(() => feedbackRuntime.dispose());
|
|
493
|
+
reviewController.own(frameController.subscribe(() => feedbackRuntime.publish()));
|
|
494
|
+
let contextualFrame = null;
|
|
495
|
+
function scheduleContextualPosition() {
|
|
496
|
+
if (contextualFrame !== null || uiLifetime.signal.aborted)
|
|
497
|
+
return;
|
|
498
|
+
contextualFrame = requestAnimationFrame(() => {
|
|
499
|
+
contextualFrame = null;
|
|
500
|
+
if (uiLifetime.signal.aborted)
|
|
501
|
+
return;
|
|
502
|
+
positionCompose();
|
|
503
|
+
positionAlignedCard();
|
|
504
|
+
});
|
|
505
|
+
}
|
|
506
|
+
const contextualObserver = new ResizeObserver(scheduleContextualPosition);
|
|
507
|
+
for (const host of [$("compose"), $("alignedCard"), $("noticesRoot"), document.querySelector(".document-host")]) {
|
|
508
|
+
contextualObserver.observe(host);
|
|
509
|
+
}
|
|
510
|
+
reviewController.own(() => {
|
|
511
|
+
contextualObserver.disconnect();
|
|
512
|
+
if (contextualFrame !== null)
|
|
513
|
+
cancelAnimationFrame(contextualFrame);
|
|
514
|
+
contextualFrame = null;
|
|
515
|
+
});
|
|
516
|
+
const toolbar = document.querySelector(".toolbar");
|
|
517
|
+
const toolbarObserver = new ResizeObserver(() => {
|
|
518
|
+
document.documentElement.style.setProperty("--toolbar-h", `${toolbar.getBoundingClientRect().height}px`);
|
|
519
|
+
});
|
|
520
|
+
toolbarObserver.observe(toolbar);
|
|
521
|
+
reviewController.own(() => toolbarObserver.disconnect());
|
|
522
|
+
function renderExecution() {
|
|
523
|
+
recoveryRuntime.publish();
|
|
524
|
+
toolbarRuntime.publish();
|
|
525
|
+
commentsRuntime.publish();
|
|
526
|
+
}
|
|
527
|
+
const persistEdit = (key, payload) => saveController.persistEdit(key, payload);
|
|
528
|
+
const settleEditPersistence = (key) => saveController.settleEdits(key);
|
|
529
|
+
function reconcilePage(page, { syncAnchors = false, reason = "mutation", advance = reason !== "mutation" } = {}) {
|
|
530
|
+
const previousIds = new Set((state.page?.comments || []).map((comment) => comment.id));
|
|
531
|
+
if (advance)
|
|
532
|
+
advancePageEpoch(reason);
|
|
533
|
+
replacePage(state, page);
|
|
534
|
+
const commentIds = new Set((page.comments || []).map((comment) => comment.id));
|
|
535
|
+
for (const id of state.activeGeometry.keys()) {
|
|
536
|
+
if (!commentIds.has(id))
|
|
537
|
+
state.activeGeometry.delete(id);
|
|
538
|
+
}
|
|
539
|
+
if (state.activeSavedCommentId && !commentIds.has(state.activeSavedCommentId)) {
|
|
540
|
+
state.activeSavedCommentId = null;
|
|
541
|
+
}
|
|
542
|
+
const removedTransient = reconcileCommentUi(state.commentUi, page.comments || []);
|
|
543
|
+
state.orphans = new Set([...state.orphans].filter((id) => commentIds.has(id)));
|
|
544
|
+
if (syncAnchors && !frameController.loading) {
|
|
545
|
+
toFrame({ type: "eh:anchors", comments: page.comments || [] });
|
|
546
|
+
}
|
|
547
|
+
if (reason === "acknowledgement") {
|
|
548
|
+
const removed = [...previousIds].filter((id) => !commentIds.has(id));
|
|
549
|
+
if (removed.length) {
|
|
550
|
+
announce(`${removed.length === 1 ? "Comment" : "Comments"} delivered and removed`);
|
|
551
|
+
if (removedTransient.size)
|
|
552
|
+
afterPaint(() => (state.drawerOpen ? $("commentsSection") : frameHost.current).focus());
|
|
553
|
+
}
|
|
554
|
+
}
|
|
555
|
+
diagnostic("page-reconciled", { reason, epoch: state.pageEpoch });
|
|
556
|
+
}
|
|
557
|
+
const toFrame = (message) => frameController.send(message);
|
|
558
|
+
function beginFrameTransition(key = frameController.state.key) {
|
|
559
|
+
return frameController.begin(key);
|
|
560
|
+
}
|
|
561
|
+
const registerFrame = (key, generation) => frameController.register(key, generation);
|
|
562
|
+
const failFrame = (message) => frameController.fail(message);
|
|
563
|
+
const flushFrame = ({ strict = false } = {}) => saveController.flush(strict);
|
|
564
|
+
function configureFrame() {
|
|
565
|
+
if (!frameController.state.execution)
|
|
566
|
+
return Promise.resolve(false);
|
|
567
|
+
Object.assign(state.page, frameController.state.execution);
|
|
568
|
+
const configuration = reviewConfiguration(state.page, state.reviewMode);
|
|
569
|
+
state.savePolicy = configuration.savePolicy;
|
|
570
|
+
return frameController.configure(configuration.mode, configuration.savePolicy);
|
|
571
|
+
}
|
|
572
|
+
async function setReviewMode(nextMode) {
|
|
573
|
+
const next = normalizeReviewMode(nextMode);
|
|
574
|
+
closeModeMenu("selection", { restoreFocus: true });
|
|
575
|
+
if (next === state.reviewMode || state.modeApplying)
|
|
576
|
+
return;
|
|
577
|
+
state.modeApplying = true;
|
|
578
|
+
render();
|
|
579
|
+
try {
|
|
580
|
+
if (state.reviewMode === "edit" && next === "view") {
|
|
581
|
+
await fullSaveBarrier();
|
|
582
|
+
if (saveController.state.conflict) {
|
|
583
|
+
toast("Resolve the save conflict before leaving Edit");
|
|
584
|
+
announce("Save conflict. Edit mode remains active.");
|
|
585
|
+
return;
|
|
586
|
+
}
|
|
587
|
+
}
|
|
588
|
+
state.reviewMode = next;
|
|
589
|
+
const applied = await configureFrame();
|
|
590
|
+
if (!applied)
|
|
591
|
+
throw new Error("The reviewed page did not confirm the mode change");
|
|
592
|
+
diagnostic("mode-change", { mode: next, savePolicy: state.savePolicy });
|
|
593
|
+
announce(`${next === "edit" ? "Edit" : "View"} mode active`);
|
|
594
|
+
}
|
|
595
|
+
catch (err) {
|
|
596
|
+
state.reviewMode = next === "edit" ? "view" : "edit";
|
|
597
|
+
toast(`${err.message}. Stay in ${state.reviewMode === "edit" ? "Edit" : "View"} and retry.`);
|
|
598
|
+
}
|
|
599
|
+
finally {
|
|
600
|
+
state.modeApplying = false;
|
|
601
|
+
render();
|
|
602
|
+
}
|
|
603
|
+
}
|
|
604
|
+
async function loadPage(key, { reload = true } = {}) {
|
|
605
|
+
const returning = state.page;
|
|
606
|
+
state.reloadVisible = false;
|
|
607
|
+
state.reloadFailed = false;
|
|
608
|
+
advancePageEpoch(returning ? "navigation" : "reload");
|
|
609
|
+
const generation = beginFrameTransition(key);
|
|
610
|
+
frameController.resetRetries();
|
|
611
|
+
const page = await api(pageUrl(key, state.sessionId), undefined, decodePage);
|
|
612
|
+
if (frameController.state.key !== key || frameController.state.generation !== generation)
|
|
613
|
+
return;
|
|
614
|
+
state.executionPreference = page.executionPreference || null;
|
|
615
|
+
reconcilePage(page, { reason: "reload", advance: false });
|
|
616
|
+
state.savePolicy = reviewConfiguration(state.page, state.reviewMode).savePolicy;
|
|
617
|
+
frameController.setPolicy(framePolicy(state.page, ARTIFACT_ORIGIN));
|
|
618
|
+
state.orphans = new Set();
|
|
619
|
+
state.compose = null;
|
|
620
|
+
state.composerDraft = createComposerDraft();
|
|
621
|
+
setComposeLifecycle("closed", "page-change");
|
|
622
|
+
setComposePlacement("hidden");
|
|
623
|
+
state.target = null;
|
|
624
|
+
state.activeSavedCommentId = null;
|
|
625
|
+
state.activeGeometry.clear();
|
|
626
|
+
state.commentUi = createCommentUi();
|
|
627
|
+
feedbackController.clearSent();
|
|
628
|
+
saveController.reset();
|
|
629
|
+
if (reload) {
|
|
630
|
+
frameController.startReload();
|
|
631
|
+
await registerFrame(key, generation);
|
|
632
|
+
}
|
|
633
|
+
render();
|
|
634
|
+
// Coming back to a dev-server page shows the app's own copy again, without
|
|
635
|
+
// the direct edits — which reads as data loss unless we say what happened.
|
|
636
|
+
const edits = state.page.edits ? state.page.edits.length : 0;
|
|
637
|
+
if (returning && state.page.feedbackOnly && edits > 0) {
|
|
638
|
+
toast(`${edits} ${edits === 1 ? "edit is" : "edits are"} queued for the agent to apply to the source`);
|
|
639
|
+
}
|
|
640
|
+
}
|
|
641
|
+
// -------------------------------------------------------------------- render
|
|
642
|
+
function render() {
|
|
643
|
+
if (state.ended)
|
|
644
|
+
return;
|
|
645
|
+
toolbarRuntime.publish();
|
|
646
|
+
const page = state.page;
|
|
647
|
+
if (!page) {
|
|
648
|
+
commentsRuntime.publish();
|
|
649
|
+
contextualRuntime.publish();
|
|
650
|
+
return;
|
|
651
|
+
}
|
|
652
|
+
// Source updates cannot change the policy of a frame awaiting draft-safe reload.
|
|
653
|
+
if (frameController.state.execution)
|
|
654
|
+
Object.assign(page, frameController.state.execution);
|
|
655
|
+
if (state.executionPreference)
|
|
656
|
+
page.executionPreference = state.executionPreference;
|
|
657
|
+
renderExecution();
|
|
658
|
+
syncFrameInteraction();
|
|
659
|
+
const editWasFocused = skipEditCaptureOnce ? false : captureEditState();
|
|
660
|
+
skipEditCaptureOnce = false;
|
|
661
|
+
commentsRuntime.publish();
|
|
662
|
+
document.title = page.filename || 'doc-review';
|
|
663
|
+
// Stable outer hosts are the bounded geometry adapter; React owns their controls.
|
|
664
|
+
$("compose").hidden = !state.compose;
|
|
665
|
+
contextualRuntime.publish();
|
|
666
|
+
scheduleContextualPosition();
|
|
667
|
+
feedbackRuntime.publish();
|
|
668
|
+
restoreTransientFocus(editWasFocused);
|
|
669
|
+
}
|
|
670
|
+
function positionCompose() {
|
|
671
|
+
if (!state.compose || state.composeLifecycle === "closed") {
|
|
672
|
+
$("compose").hidden = true;
|
|
673
|
+
setComposePlacement("hidden");
|
|
674
|
+
return;
|
|
675
|
+
}
|
|
676
|
+
if (state.compose.unresolved) {
|
|
677
|
+
const surface = $("compose");
|
|
678
|
+
surface.hidden = false;
|
|
679
|
+
surface.classList.add("sheet");
|
|
680
|
+
surface.classList.remove("edge-top", "edge-bottom");
|
|
681
|
+
surface.style.left = "";
|
|
682
|
+
surface.style.top = "";
|
|
683
|
+
surface.style.width = "";
|
|
684
|
+
setComposePlacement("sheet");
|
|
685
|
+
return;
|
|
686
|
+
}
|
|
687
|
+
const viewport = visibleViewport();
|
|
688
|
+
const frameRect = frameHost.current.getBoundingClientRect();
|
|
689
|
+
const surface = $("compose");
|
|
690
|
+
if (state.compose.relation !== "unavailable")
|
|
691
|
+
surface.hidden = false;
|
|
692
|
+
const placement = placeContextualSurface(state.compose, {
|
|
693
|
+
frameRect,
|
|
694
|
+
viewport,
|
|
695
|
+
surfaceWidth: Math.min(340, viewport.width - 24),
|
|
696
|
+
surfaceHeight: Math.max(190, surface.offsetHeight),
|
|
697
|
+
narrow: matchMedia("(max-width: 720px)").matches,
|
|
698
|
+
toolbarHeight: document.querySelector(".toolbar").getBoundingClientRect().height,
|
|
699
|
+
});
|
|
700
|
+
surface.classList.toggle("sheet", placement.kind === "sheet");
|
|
701
|
+
surface.classList.toggle("edge-top", placement.kind === "edge-top");
|
|
702
|
+
surface.classList.toggle("edge-bottom", placement.kind === "edge-bottom");
|
|
703
|
+
setComposePlacement(placement.kind);
|
|
704
|
+
surface.hidden = placement.kind === "hidden";
|
|
705
|
+
if (placement.kind !== "sheet" && placement.kind !== "hidden") {
|
|
706
|
+
surface.style.left = `${placement.left}px`;
|
|
707
|
+
surface.style.top = `${placement.top}px`;
|
|
708
|
+
surface.style.width = `${placement.width}px`;
|
|
709
|
+
}
|
|
710
|
+
else {
|
|
711
|
+
surface.style.left = "";
|
|
712
|
+
surface.style.top = "";
|
|
713
|
+
surface.style.width = "";
|
|
714
|
+
}
|
|
715
|
+
}
|
|
716
|
+
function confirmCommentDelete(id, surface) {
|
|
717
|
+
if (!commentById(id))
|
|
718
|
+
return;
|
|
719
|
+
if (!ownConfirmation(state.commentUi, id, surface)) {
|
|
720
|
+
focusCurrentCommentEdit();
|
|
721
|
+
return;
|
|
722
|
+
}
|
|
723
|
+
requestControlFocus(controlId(id, surface, "confirm-delete"));
|
|
724
|
+
render();
|
|
725
|
+
}
|
|
726
|
+
function positionAlignedCard() {
|
|
727
|
+
const host = $("alignedCard");
|
|
728
|
+
const comment = commentById(state.activeSavedCommentId);
|
|
729
|
+
const geometry = comment ? state.activeGeometry.get(comment.id) : null;
|
|
730
|
+
if (!comment || state.drawerOpen || matchMedia("(max-width: 720px)").matches || !geometry?.visible) {
|
|
731
|
+
host.hidden = true;
|
|
732
|
+
return false;
|
|
733
|
+
}
|
|
734
|
+
const viewport = visibleViewport();
|
|
735
|
+
const position = alignedCardPosition(geometry.rects, {
|
|
736
|
+
frameRect: frameHost.current.getBoundingClientRect(),
|
|
737
|
+
viewport,
|
|
738
|
+
width: host.offsetWidth || 300,
|
|
739
|
+
height: host.offsetHeight || 180,
|
|
740
|
+
});
|
|
741
|
+
if (!position) {
|
|
742
|
+
host.hidden = true;
|
|
743
|
+
return false;
|
|
744
|
+
}
|
|
745
|
+
host.style.left = `${position.left}px`;
|
|
746
|
+
host.style.top = `${position.top}px`;
|
|
747
|
+
host.hidden = false;
|
|
748
|
+
return true;
|
|
749
|
+
}
|
|
750
|
+
async function deleteComment(id) {
|
|
751
|
+
if (deleteFlights.has(id))
|
|
752
|
+
return deleteFlights.get(id);
|
|
753
|
+
const confirmation = state.commentUi.confirmation;
|
|
754
|
+
if (!confirmation || confirmation.commentId !== id || confirmation.status === "deleting")
|
|
755
|
+
return false;
|
|
756
|
+
confirmation.status = "deleting";
|
|
757
|
+
render();
|
|
758
|
+
const startEpoch = state.pageEpoch;
|
|
759
|
+
const index = newestComments(state.page.comments).findIndex((comment) => comment.id === id);
|
|
760
|
+
const flight = (async () => {
|
|
761
|
+
try {
|
|
762
|
+
const result = await api(`/api/page/${frameController.state.key}/comment/${id}`, { method: "DELETE" });
|
|
763
|
+
if (!mutationIsCurrent(startEpoch, state.pageEpoch, state.page?.comments, id))
|
|
764
|
+
return false;
|
|
765
|
+
reconcilePage(result.page, { reason: "mutation" });
|
|
766
|
+
toFrame({ type: "eh:remove", id });
|
|
767
|
+
state.activeGeometry.delete(id);
|
|
768
|
+
if (state.activeSavedCommentId === id)
|
|
769
|
+
state.activeSavedCommentId = null;
|
|
770
|
+
if (state.drawerOpen) {
|
|
771
|
+
const remaining = newestComments(state.page.comments);
|
|
772
|
+
const next = remaining[Math.min(index, remaining.length - 1)];
|
|
773
|
+
requestControlFocus(next ? controlId(next.id, "drawer", "delete") : "commentsSection");
|
|
774
|
+
}
|
|
775
|
+
render();
|
|
776
|
+
return true;
|
|
777
|
+
}
|
|
778
|
+
catch (err) {
|
|
779
|
+
if (mutationIsCurrent(startEpoch, state.pageEpoch, state.page?.comments, id)) {
|
|
780
|
+
if (state.commentUi.confirmation?.commentId === id)
|
|
781
|
+
state.commentUi.confirmation.status = "idle";
|
|
782
|
+
toast(err.message);
|
|
783
|
+
render();
|
|
784
|
+
}
|
|
785
|
+
return false;
|
|
786
|
+
}
|
|
787
|
+
})().finally(() => {
|
|
788
|
+
if (deleteFlights.get(id) === flight)
|
|
789
|
+
deleteFlights.delete(id);
|
|
790
|
+
});
|
|
791
|
+
deleteFlights.set(id, flight);
|
|
792
|
+
return flight;
|
|
793
|
+
}
|
|
794
|
+
function cancelDeleteConfirmation() {
|
|
795
|
+
const confirmation = state.commentUi.confirmation;
|
|
796
|
+
if (!confirmation || confirmation.status === "deleting")
|
|
797
|
+
return false;
|
|
798
|
+
const triggerId = controlId(confirmation.commentId, confirmation.surface, "delete");
|
|
799
|
+
clearOwned(state.commentUi, "confirmation");
|
|
800
|
+
requestControlFocus(triggerId);
|
|
801
|
+
render();
|
|
802
|
+
return true;
|
|
803
|
+
}
|
|
804
|
+
function startCommentEdit(comment, surface) {
|
|
805
|
+
if (!ownEdit(state.commentUi, comment, surface)) {
|
|
806
|
+
focusCurrentCommentEdit();
|
|
807
|
+
return false;
|
|
808
|
+
}
|
|
809
|
+
editFocusRequested = true;
|
|
810
|
+
render();
|
|
811
|
+
return true;
|
|
812
|
+
}
|
|
813
|
+
function focusCurrentCommentEdit() {
|
|
814
|
+
editFocusRequested = true;
|
|
815
|
+
announce("Save or cancel the current comment edit first");
|
|
816
|
+
afterPaint(() => {
|
|
817
|
+
const edit = state.commentUi.edit;
|
|
818
|
+
editTextarea(edit?.commentId)?.focus();
|
|
819
|
+
});
|
|
820
|
+
}
|
|
821
|
+
function cancelCommentEdit() {
|
|
822
|
+
const edit = state.commentUi.edit;
|
|
823
|
+
if (!edit || edit.status === "saving")
|
|
824
|
+
return false;
|
|
825
|
+
const surface = state.drawerOpen ? "drawer" : edit.originSurface;
|
|
826
|
+
const focusId = controlId(edit.commentId, surface, "edit");
|
|
827
|
+
clearOwned(state.commentUi, "edit");
|
|
828
|
+
requestControlFocus(focusId);
|
|
829
|
+
render();
|
|
830
|
+
return true;
|
|
831
|
+
}
|
|
832
|
+
function saveCommentEdit() {
|
|
833
|
+
const edit = state.commentUi.edit;
|
|
834
|
+
if (!edit || edit.status === "saving")
|
|
835
|
+
return edit ? patchFlights.get(edit.commentId) : false;
|
|
836
|
+
captureEditState();
|
|
837
|
+
const feedback = edit.draft.trim();
|
|
838
|
+
if (!feedback) {
|
|
839
|
+
edit.validation = "Comment text is required.";
|
|
840
|
+
editFocusRequested = true;
|
|
841
|
+
render();
|
|
842
|
+
return false;
|
|
843
|
+
}
|
|
844
|
+
if (feedback === edit.original) {
|
|
845
|
+
clearOwned(state.commentUi, "edit");
|
|
846
|
+
requestControlFocus(controlId(edit.commentId, state.drawerOpen ? "drawer" : edit.originSurface, "edit"));
|
|
847
|
+
render();
|
|
848
|
+
return true;
|
|
849
|
+
}
|
|
850
|
+
if (patchFlights.has(edit.commentId))
|
|
851
|
+
return patchFlights.get(edit.commentId);
|
|
852
|
+
edit.status = "saving";
|
|
853
|
+
edit.validation = "";
|
|
854
|
+
const id = edit.commentId;
|
|
855
|
+
const startEpoch = state.pageEpoch;
|
|
856
|
+
editFocusRequested = true;
|
|
857
|
+
render();
|
|
858
|
+
const flight = executeCommentPatch({ ...edit, draft: feedback }, startEpoch).finally(() => {
|
|
859
|
+
if (patchFlights.get(id) === flight)
|
|
860
|
+
patchFlights.delete(id);
|
|
861
|
+
});
|
|
862
|
+
patchFlights.set(id, flight);
|
|
863
|
+
return flight;
|
|
864
|
+
}
|
|
865
|
+
async function executeCommentPatch(edit, startEpoch) {
|
|
866
|
+
const id = edit.commentId;
|
|
867
|
+
try {
|
|
868
|
+
const commentIndex = state.page.comments.findIndex((item) => item.id === id);
|
|
869
|
+
const previousGeometry = state.activeGeometry.get(id);
|
|
870
|
+
const wasOrphaned = state.orphans.has(id);
|
|
871
|
+
const remainsActive = state.activeSavedCommentId === id;
|
|
872
|
+
const result = await api(`/api/page/${frameController.state.key}/comment/${id}`, {
|
|
873
|
+
method: "PATCH",
|
|
874
|
+
body: JSON.stringify({ feedback: edit.draft }),
|
|
875
|
+
});
|
|
876
|
+
if (!mutationIsCurrent(startEpoch, state.pageEpoch, state.page?.comments, id))
|
|
877
|
+
return false;
|
|
878
|
+
const replacement = commentIndex >= 0 ? result.page.comments[commentIndex] : null;
|
|
879
|
+
if (replacement && replacement.id !== id) {
|
|
880
|
+
migrateCommentUi(state.commentUi, id, replacement.id);
|
|
881
|
+
if (remainsActive && previousGeometry)
|
|
882
|
+
state.activeGeometry.set(replacement.id, previousGeometry);
|
|
883
|
+
if (remainsActive)
|
|
884
|
+
state.activeSavedCommentId = replacement.id;
|
|
885
|
+
if (wasOrphaned)
|
|
886
|
+
state.orphans.add(replacement.id);
|
|
887
|
+
}
|
|
888
|
+
reconcilePage(result.page, { reason: "mutation" });
|
|
889
|
+
clearOwned(state.commentUi, "edit");
|
|
890
|
+
if (state.drawerOpen)
|
|
891
|
+
requestControlFocus(controlId(replacement?.id || id, "drawer", "edit"));
|
|
892
|
+
if (result.delivery === "updated-pending") {
|
|
893
|
+
toast("Updated the feedback waiting for your agent");
|
|
894
|
+
}
|
|
895
|
+
else if (result.delivery === "correction") {
|
|
896
|
+
feedbackController.clearSent();
|
|
897
|
+
toFrame({ type: "eh:remove", id });
|
|
898
|
+
toFrame({ type: "eh:anchors", comments: state.page.comments });
|
|
899
|
+
if (remainsActive && replacement)
|
|
900
|
+
toFrame({ type: "eh:activate", id: replacement.id, scroll: false });
|
|
901
|
+
toast("Saved as a correction — send it after the current batch is acknowledged");
|
|
902
|
+
}
|
|
903
|
+
else {
|
|
904
|
+
feedbackController.clearSent();
|
|
905
|
+
}
|
|
906
|
+
render();
|
|
907
|
+
return true;
|
|
908
|
+
}
|
|
909
|
+
catch (err) {
|
|
910
|
+
if (mutationIsCurrent(startEpoch, state.pageEpoch, state.page?.comments, id)) {
|
|
911
|
+
const current = state.commentUi.edit;
|
|
912
|
+
if (current?.commentId === id) {
|
|
913
|
+
current.status = "idle";
|
|
914
|
+
current.validation = "";
|
|
915
|
+
editFocusRequested = true;
|
|
916
|
+
}
|
|
917
|
+
toast(err.message);
|
|
918
|
+
render();
|
|
919
|
+
}
|
|
920
|
+
return false;
|
|
921
|
+
}
|
|
922
|
+
}
|
|
923
|
+
function renderSave() {
|
|
924
|
+
feedbackRuntime.publish();
|
|
925
|
+
}
|
|
926
|
+
function toast(message) {
|
|
927
|
+
const el = document.createElement("div");
|
|
928
|
+
el.className = "toast";
|
|
929
|
+
el.textContent = message;
|
|
930
|
+
document.body.append(el);
|
|
931
|
+
afterDelay(() => el.remove(), 3200);
|
|
932
|
+
}
|
|
933
|
+
function setActive(id, scroll) {
|
|
934
|
+
if (id && state.drawerOpen)
|
|
935
|
+
commentsRuntime.commands.setSectionOpen(true);
|
|
936
|
+
state.activeSavedCommentId = id;
|
|
937
|
+
toFrame({ type: "eh:activate", id, scroll: !!scroll });
|
|
938
|
+
render();
|
|
939
|
+
}
|
|
940
|
+
function dismissActiveComment(reason, { restoreFocus = false } = {}) {
|
|
941
|
+
const id = state.activeSavedCommentId;
|
|
942
|
+
if (!id)
|
|
943
|
+
return false;
|
|
944
|
+
state.activeSavedCommentId = null;
|
|
945
|
+
state.activeGeometry.delete(id);
|
|
946
|
+
toFrame({ type: "eh:deactivateComment" });
|
|
947
|
+
render();
|
|
948
|
+
announce("Comment card closed");
|
|
949
|
+
diagnostic("saved-card-deactivated", { reason });
|
|
950
|
+
if (restoreFocus)
|
|
951
|
+
frameHost.current.focus();
|
|
952
|
+
return true;
|
|
953
|
+
}
|
|
954
|
+
// ------------------------------------------------------------------ compose
|
|
955
|
+
async function openCompose(detail) {
|
|
956
|
+
const identity = currentRender();
|
|
957
|
+
const pageKey = frameController.state.key;
|
|
958
|
+
if (state.compose && state.compose.generation === detail.generation) {
|
|
959
|
+
if (state.composeLifecycle === "closed")
|
|
960
|
+
setComposeLifecycle("open", "accepted");
|
|
961
|
+
$("composeText").focus();
|
|
962
|
+
return true;
|
|
963
|
+
}
|
|
964
|
+
if (state.compose && state.composerDraft.text.trim()) {
|
|
965
|
+
const submitted = await commitCompose();
|
|
966
|
+
if (!submitted) {
|
|
967
|
+
diagnostic("comment-retarget-blocked", { reason: "submit-failed" });
|
|
968
|
+
return false;
|
|
969
|
+
}
|
|
970
|
+
}
|
|
971
|
+
else if (state.compose) {
|
|
972
|
+
cancelCompose({ restoreFocus: false, preserveRetarget: true });
|
|
973
|
+
}
|
|
974
|
+
if (state.ended || pageKey !== frameController.state.key || !sameRender(identity, currentRender()))
|
|
975
|
+
return false;
|
|
976
|
+
dismissActiveComment("composer-open");
|
|
977
|
+
state.compose = detail;
|
|
978
|
+
state.composerDraft = createComposerDraft();
|
|
979
|
+
setComposeLifecycle("open", "accepted");
|
|
980
|
+
render();
|
|
981
|
+
afterPaint(() => {
|
|
982
|
+
positionCompose();
|
|
983
|
+
$("composeText").focus();
|
|
984
|
+
});
|
|
985
|
+
announce(`Comment dialog opened for ${detail.kind === "element" ? "element" : "selected text"}`);
|
|
986
|
+
return true;
|
|
987
|
+
}
|
|
988
|
+
function cancelCompose({ restoreFocus = true, preserveRetarget = false } = {}) {
|
|
989
|
+
if (!state.compose || state.composeLifecycle === "submitting")
|
|
990
|
+
return false;
|
|
991
|
+
const generation = state.compose.generation;
|
|
992
|
+
const discardThroughGeneration = Math.max(generation, Number(state.compose.rejectedTargetGeneration) || generation);
|
|
993
|
+
state.compose = null;
|
|
994
|
+
state.composerDraft = createComposerDraft();
|
|
995
|
+
setComposeLifecycle("closed", "cancel");
|
|
996
|
+
setComposePlacement("hidden");
|
|
997
|
+
toFrame({
|
|
998
|
+
type: "eh:cancel",
|
|
999
|
+
targetGeneration: generation,
|
|
1000
|
+
discardThroughGeneration,
|
|
1001
|
+
restoreFocus,
|
|
1002
|
+
preserveRetarget,
|
|
1003
|
+
});
|
|
1004
|
+
render();
|
|
1005
|
+
return true;
|
|
1006
|
+
}
|
|
1007
|
+
let composeSubmitPromise = null;
|
|
1008
|
+
function commitCompose() {
|
|
1009
|
+
if (composeSubmitPromise) {
|
|
1010
|
+
diagnostic("comment-submit-suppressed", { reason: "in-flight" });
|
|
1011
|
+
return composeSubmitPromise;
|
|
1012
|
+
}
|
|
1013
|
+
composeSubmitPromise = executeComposeSubmit().finally(() => {
|
|
1014
|
+
composeSubmitPromise = null;
|
|
1015
|
+
});
|
|
1016
|
+
return composeSubmitPromise;
|
|
1017
|
+
}
|
|
1018
|
+
async function executeComposeSubmit() {
|
|
1019
|
+
const compose = state.compose;
|
|
1020
|
+
const draft = state.composerDraft;
|
|
1021
|
+
const feedback = draft.text.trim();
|
|
1022
|
+
if (!compose || !feedback || draft.composing || state.ended)
|
|
1023
|
+
return false;
|
|
1024
|
+
const startEpoch = state.pageEpoch;
|
|
1025
|
+
const identity = currentRender();
|
|
1026
|
+
const targetGeneration = compose.generation;
|
|
1027
|
+
const current = () => !state.ended && state.compose === compose && state.composerDraft === draft;
|
|
1028
|
+
draft.error = "";
|
|
1029
|
+
setComposeLifecycle("submitting", "submit");
|
|
1030
|
+
diagnostic("comment-submit-executed");
|
|
1031
|
+
try {
|
|
1032
|
+
const result = await api(`/api/page/${identity.key}/comment`, {
|
|
1033
|
+
method: "POST",
|
|
1034
|
+
body: JSON.stringify({ kind: compose.kind, quote: compose.quote, anchor: compose.unresolved ? null : compose.anchor, feedback }),
|
|
1035
|
+
});
|
|
1036
|
+
if (!current())
|
|
1037
|
+
return false;
|
|
1038
|
+
if (startEpoch !== state.pageEpoch || !sameRender(identity, currentRender())) {
|
|
1039
|
+
draft.error = "The page changed while this comment was saving. Your draft is preserved; check the comments before retrying.";
|
|
1040
|
+
draft.retry = true;
|
|
1041
|
+
setComposeLifecycle("open", "stale-submit");
|
|
1042
|
+
render();
|
|
1043
|
+
return false;
|
|
1044
|
+
}
|
|
1045
|
+
if (!compose.unresolved)
|
|
1046
|
+
toFrame({
|
|
1047
|
+
type: "eh:commit",
|
|
1048
|
+
id: result.comment.id,
|
|
1049
|
+
targetGeneration,
|
|
1050
|
+
restoreFocus: true,
|
|
1051
|
+
});
|
|
1052
|
+
state.compose = null;
|
|
1053
|
+
state.composerDraft = createComposerDraft();
|
|
1054
|
+
setComposeLifecycle("closed", "submitted");
|
|
1055
|
+
setComposePlacement("hidden");
|
|
1056
|
+
state.page = result.page;
|
|
1057
|
+
feedbackController.clearSent();
|
|
1058
|
+
render();
|
|
1059
|
+
announce("Comment added");
|
|
1060
|
+
return true;
|
|
1061
|
+
}
|
|
1062
|
+
catch (err) {
|
|
1063
|
+
if (!current())
|
|
1064
|
+
return false;
|
|
1065
|
+
draft.error = `${err.message}. Retry or cancel.`;
|
|
1066
|
+
draft.retry = true;
|
|
1067
|
+
diagnostic("comment-request-failure", { status: err.status || 0 });
|
|
1068
|
+
announce("Comment could not be saved. Your draft is still here.");
|
|
1069
|
+
setComposeLifecycle("open", "submit-failed");
|
|
1070
|
+
render();
|
|
1071
|
+
$("compose").classList.remove("pass-through");
|
|
1072
|
+
afterPaint(() => $("composeText").focus());
|
|
1073
|
+
return false;
|
|
1074
|
+
}
|
|
1075
|
+
}
|
|
1076
|
+
// ------------------------------------------------------------ review history
|
|
1077
|
+
const currentRender = () => frameController.identity();
|
|
1078
|
+
const captures = createCaptureRequests({ send: toFrame, current: currentRender });
|
|
1079
|
+
const historyUrl = (suffix = "") => `/api/session/${state.sessionId}/history${suffix}`;
|
|
1080
|
+
const historyController = createHistoryController({
|
|
1081
|
+
request: (suffix) => api(historyUrl(suffix)), changed: renderHistory, refreshed: scheduleResultCapture,
|
|
1082
|
+
});
|
|
1083
|
+
const history = historyController.state;
|
|
1084
|
+
let manualCaptureController = null;
|
|
1085
|
+
const captureCoordinator = createCaptureCoordinator({
|
|
1086
|
+
ready: () => !state.ended && !frameController.loading && !frameController.state.pendingReload && !saveController.state.dirty &&
|
|
1087
|
+
!saveController.state.conflict && !feedbackController.sending && !history.captureBusy && !history.finalizing &&
|
|
1088
|
+
(state.page?.kind === "url" || !!saveController.state.baseHash),
|
|
1089
|
+
candidates: () => [...history.rounds].reverse().flatMap((round) => {
|
|
1090
|
+
const target = pendingCaptureTarget(round, frameController.state.key, frameController.state.readyAt);
|
|
1091
|
+
return target ? [{ id: `${roundId(round)}:${frameController.state.renderId}:${frameController.state.generation}`, round, target }] : [];
|
|
1092
|
+
}),
|
|
1093
|
+
capture: ({ round, target }, signal) => captureResult(round, target, true, signal),
|
|
1094
|
+
});
|
|
1095
|
+
const fullSaveBarrier = () => saveController.barrier();
|
|
1096
|
+
const captureStablePage = (options) => saveController.captureStable(() => captures.request(options), options?.signal);
|
|
1097
|
+
function syncFrameInteraction() {
|
|
1098
|
+
frameHost.current.inert = state.comparing || feedbackController.sending;
|
|
1099
|
+
}
|
|
1100
|
+
function roundId(round) { return round?.id || round?.roundId; }
|
|
1101
|
+
function roundTargets(round) { return round?.targets || []; }
|
|
1102
|
+
function selectedTarget() {
|
|
1103
|
+
const targets = roundTargets(history.round);
|
|
1104
|
+
return targets.find((target) => target.key === history.targetKey) || targets[0] || null;
|
|
1105
|
+
}
|
|
1106
|
+
const refreshHistory = () => state.ended ? Promise.resolve(false) : historyController.refresh();
|
|
1107
|
+
export const changesRuntime = createChangesController({
|
|
1108
|
+
read: () => ({
|
|
1109
|
+
history, ended: state.ended, comparing: state.comparing, sending: feedbackController.sending,
|
|
1110
|
+
current: {
|
|
1111
|
+
key: frameController.state.key, kind: state.page?.kind,
|
|
1112
|
+
ready: !frameController.loading && !!frameController.state.readyAt,
|
|
1113
|
+
pendingReload: frameController.state.pendingReload,
|
|
1114
|
+
dirty: saveController.state.dirty || saveController.state.conflict,
|
|
1115
|
+
sourceHash: frameController.state.sourceHash || saveController.state.baseHash,
|
|
1116
|
+
sessionId: state.sessionId, generation: frameController.state.generation,
|
|
1117
|
+
},
|
|
1118
|
+
}),
|
|
1119
|
+
selectRound: (id) => historyController.selectRound(id),
|
|
1120
|
+
selectTarget: (key) => historyController.selectTarget(key),
|
|
1121
|
+
selectMode(mode) {
|
|
1122
|
+
history.preferredMode = mode;
|
|
1123
|
+
history.mode = mode;
|
|
1124
|
+
history.index = 0;
|
|
1125
|
+
renderHistory();
|
|
1126
|
+
},
|
|
1127
|
+
selectIndex(index) { history.index = index; renderHistory(); },
|
|
1128
|
+
capture: () => captureResult(history.round, selectedTarget()),
|
|
1129
|
+
finish: () => finishCapture(),
|
|
1130
|
+
refresh: refreshHistory,
|
|
1131
|
+
failed: toast,
|
|
1132
|
+
});
|
|
1133
|
+
reviewController.own(() => changesRuntime.dispose());
|
|
1134
|
+
reviewController.own(frameController.subscribe(() => changesRuntime.publish()));
|
|
1135
|
+
reviewController.own(saveController.subscribe(() => changesRuntime.publish()));
|
|
1136
|
+
reviewController.own(feedbackController.subscribe(() => changesRuntime.publish()));
|
|
1137
|
+
function renderHistory() {
|
|
1138
|
+
if (state.ended)
|
|
1139
|
+
return;
|
|
1140
|
+
syncFrameInteraction();
|
|
1141
|
+
changesRuntime.publish();
|
|
1142
|
+
}
|
|
1143
|
+
async function captureResult(round, target, automatic = false, signal) {
|
|
1144
|
+
if (history.captureBusy || !round || target?.key !== frameController.state.key)
|
|
1145
|
+
return;
|
|
1146
|
+
const manualController = automatic ? null : new AbortController();
|
|
1147
|
+
if (manualController) {
|
|
1148
|
+
manualCaptureController = manualController;
|
|
1149
|
+
signal = manualController.signal;
|
|
1150
|
+
}
|
|
1151
|
+
const identity = currentRender();
|
|
1152
|
+
history.captureBusy = true;
|
|
1153
|
+
renderHistory();
|
|
1154
|
+
let phase = "saving";
|
|
1155
|
+
try {
|
|
1156
|
+
await fullSaveBarrier();
|
|
1157
|
+
if (signal?.aborted || state.ended || !sameRender(identity, currentRender()))
|
|
1158
|
+
throw captureError("CAPTURE_CANCELLED", "Capture cancelled");
|
|
1159
|
+
phase = "snapshot";
|
|
1160
|
+
const captured = await captureStablePage({ signal });
|
|
1161
|
+
phase = "publishing";
|
|
1162
|
+
const result = await api(historyUrl(`/${roundId(round)}/capture`), {
|
|
1163
|
+
method: "POST",
|
|
1164
|
+
signal,
|
|
1165
|
+
body: JSON.stringify({
|
|
1166
|
+
sessionId: state.sessionId, roundId: roundId(round), key: captured.key,
|
|
1167
|
+
renderId: captured.renderId, generation: captured.generation,
|
|
1168
|
+
semantic: captured.semantic, expectedSourceHash: captured.sourceHash || saveController.state.baseHash || frameController.state.sourceHash,
|
|
1169
|
+
semanticCapturedAt: captured.semanticCapturedAt, view: captured.view,
|
|
1170
|
+
manual: !automatic,
|
|
1171
|
+
}),
|
|
1172
|
+
});
|
|
1173
|
+
requireCaptureSuccess(result, target.key);
|
|
1174
|
+
if (signal?.aborted || state.ended || !sameRender(identity, currentRender()))
|
|
1175
|
+
return;
|
|
1176
|
+
history.failures.delete(`${roundId(round)}:${target.key}`);
|
|
1177
|
+
await refreshHistory();
|
|
1178
|
+
}
|
|
1179
|
+
catch (err) {
|
|
1180
|
+
if (signal?.aborted || state.ended || !sameRender(identity, currentRender()) || err.code === "CAPTURE_CANCELLED")
|
|
1181
|
+
return;
|
|
1182
|
+
history.failures.set(`${roundId(round)}:${target.key}`, err.message);
|
|
1183
|
+
if (phase === "snapshot" && sameRender(identity, currentRender()) && !frameController.loading) {
|
|
1184
|
+
await api(historyUrl(`/${roundId(round)}/capture`), {
|
|
1185
|
+
method: "POST",
|
|
1186
|
+
signal,
|
|
1187
|
+
body: JSON.stringify({
|
|
1188
|
+
key: identity.key, renderId: identity.renderId, generation: identity.generation,
|
|
1189
|
+
manual: !automatic, error: String(err.message).slice(0, 500),
|
|
1190
|
+
}),
|
|
1191
|
+
}).catch((reportError) => {
|
|
1192
|
+
if (!state.ended)
|
|
1193
|
+
diagnostic("capture-failure-report-failed", { message: reportError.message });
|
|
1194
|
+
});
|
|
1195
|
+
}
|
|
1196
|
+
history.failures.set(`${roundId(round)}:${target.key}`, `${err.message} Use Capture result to retry; existing captures are unchanged.`);
|
|
1197
|
+
if (automatic)
|
|
1198
|
+
throw err;
|
|
1199
|
+
}
|
|
1200
|
+
finally {
|
|
1201
|
+
if (manualCaptureController === manualController)
|
|
1202
|
+
manualCaptureController = null;
|
|
1203
|
+
history.captureBusy = false;
|
|
1204
|
+
renderHistory();
|
|
1205
|
+
scheduleResultCapture();
|
|
1206
|
+
}
|
|
1207
|
+
}
|
|
1208
|
+
function scheduleResultCapture() {
|
|
1209
|
+
captureCoordinator.tick();
|
|
1210
|
+
}
|
|
1211
|
+
function setHistoryView(comparing) {
|
|
1212
|
+
captureEditState();
|
|
1213
|
+
state.comparing = comparing;
|
|
1214
|
+
changesRuntime.publish();
|
|
1215
|
+
closeModeMenu("history-switch");
|
|
1216
|
+
document.body.classList.toggle("comparing", comparing);
|
|
1217
|
+
syncFrameInteraction();
|
|
1218
|
+
frameHost.current.setAttribute("aria-hidden", String(comparing));
|
|
1219
|
+
$("historyPanel").hidden = !comparing;
|
|
1220
|
+
if (!comparing && frameController.state.pendingReload)
|
|
1221
|
+
state.reloadVisible = true;
|
|
1222
|
+
if (comparing) {
|
|
1223
|
+
closeDrawer();
|
|
1224
|
+
void refreshHistory();
|
|
1225
|
+
}
|
|
1226
|
+
render();
|
|
1227
|
+
}
|
|
1228
|
+
async function finishCapture() {
|
|
1229
|
+
const target = selectedTarget();
|
|
1230
|
+
const round = history.round;
|
|
1231
|
+
if (!target || !round || history.finalizing || history.captureBusy)
|
|
1232
|
+
return;
|
|
1233
|
+
history.finalizing = true;
|
|
1234
|
+
renderHistory();
|
|
1235
|
+
try {
|
|
1236
|
+
const result = await api(historyUrl(`/${roundId(round)}/capture`), {
|
|
1237
|
+
method: "POST",
|
|
1238
|
+
body: JSON.stringify({ key: target.key, manual: true, finalUnavailable: true }),
|
|
1239
|
+
});
|
|
1240
|
+
requireCaptureSuccess(result, target.key);
|
|
1241
|
+
history.failures.delete(`${roundId(round)}:${target.key}`);
|
|
1242
|
+
await refreshHistory();
|
|
1243
|
+
announce("Finished with available snapshots. Missing Content remains unavailable.");
|
|
1244
|
+
}
|
|
1245
|
+
catch (err) {
|
|
1246
|
+
history.failures.set(`${roundId(round)}:${target.key}`, `${err.message} The capture is still open; you can retry.`);
|
|
1247
|
+
}
|
|
1248
|
+
finally {
|
|
1249
|
+
history.finalizing = false;
|
|
1250
|
+
renderHistory();
|
|
1251
|
+
}
|
|
1252
|
+
}
|
|
1253
|
+
// -------------------------------------------------------------------- saving
|
|
1254
|
+
const saveNow = (html) => saveController.save(html);
|
|
1255
|
+
// ------------------------------------------------------------ frame messages
|
|
1256
|
+
function targetPayload(msg) {
|
|
1257
|
+
const viewport = {
|
|
1258
|
+
width: Number(msg.viewport?.width),
|
|
1259
|
+
height: Number(msg.viewport?.height),
|
|
1260
|
+
};
|
|
1261
|
+
const generation = Number(msg.targetGeneration);
|
|
1262
|
+
const rawRelation = String(msg.relation || "");
|
|
1263
|
+
if (!["visible", "above", "below", "unavailable"].includes(rawRelation))
|
|
1264
|
+
return null;
|
|
1265
|
+
const relation = sanitizeRelation(rawRelation);
|
|
1266
|
+
const clip = sanitizeClipRect(msg.clip, viewport);
|
|
1267
|
+
const rects = sanitizeClientRects(msg.rects, viewport);
|
|
1268
|
+
if (!Number.isSafeInteger(generation) || generation < 1 || !clip)
|
|
1269
|
+
return null;
|
|
1270
|
+
if (relation === "visible" && !rects.length)
|
|
1271
|
+
return null;
|
|
1272
|
+
return {
|
|
1273
|
+
kind: msg.kind === "element" ? "element" : "selection",
|
|
1274
|
+
quote: String(msg.quote || ""),
|
|
1275
|
+
anchor: msg.anchor || null,
|
|
1276
|
+
rects,
|
|
1277
|
+
generation,
|
|
1278
|
+
relation,
|
|
1279
|
+
clip,
|
|
1280
|
+
horizontal: Number.isFinite(msg.horizontal) ? Number(msg.horizontal) : null,
|
|
1281
|
+
};
|
|
1282
|
+
}
|
|
1283
|
+
listen(window, "message", async (event) => {
|
|
1284
|
+
if (!frameController.accepts(event))
|
|
1285
|
+
return;
|
|
1286
|
+
const msg = event.data;
|
|
1287
|
+
switch (msg.type) {
|
|
1288
|
+
case "eh:ready": {
|
|
1289
|
+
const readyIdentity = currentRender();
|
|
1290
|
+
const readyState = await frameController.ready();
|
|
1291
|
+
if (!readyState || !sameRender(readyIdentity, currentRender()))
|
|
1292
|
+
return;
|
|
1293
|
+
state.page = { ...state.page, ...readyState };
|
|
1294
|
+
toFrame({ type: "eh:anchors", comments: state.page ? state.page.comments : [] });
|
|
1295
|
+
if (frameController.state.reloading) {
|
|
1296
|
+
toFrame({ type: "eh:restoreScroll", x: state.scroll.x, y: state.scroll.y });
|
|
1297
|
+
frameController.restoredScroll();
|
|
1298
|
+
}
|
|
1299
|
+
const configuration = reviewConfiguration(state.page, state.reviewMode);
|
|
1300
|
+
state.savePolicy = configuration.savePolicy;
|
|
1301
|
+
void frameController.configure(configuration.mode, configuration.savePolicy, false);
|
|
1302
|
+
render();
|
|
1303
|
+
if (state.page?.kind !== "url") {
|
|
1304
|
+
const rawIdentity = currentRender();
|
|
1305
|
+
void api(`/api/page/${frameController.state.key}/raw`).then((raw) => {
|
|
1306
|
+
if (!sameRender(rawIdentity, currentRender()) || frameController.state.pendingReload)
|
|
1307
|
+
return;
|
|
1308
|
+
if (frameController.state.sourceHash && frameController.state.sourceHash !== raw.hash) {
|
|
1309
|
+
saveController.markConflict();
|
|
1310
|
+
holdReload();
|
|
1311
|
+
return;
|
|
1312
|
+
}
|
|
1313
|
+
saveController.baseline(raw.hash || null);
|
|
1314
|
+
if (configuration.savePolicy === "writable")
|
|
1315
|
+
toFrame({ type: "eh:raw", html: raw.html });
|
|
1316
|
+
scheduleResultCapture();
|
|
1317
|
+
}).catch((error) => {
|
|
1318
|
+
if (sameRender(rawIdentity, currentRender()) && !state.ended) {
|
|
1319
|
+
toast(`Source could not be loaded: ${error.message}. Reload before saving.`);
|
|
1320
|
+
}
|
|
1321
|
+
});
|
|
1322
|
+
}
|
|
1323
|
+
void refreshHistory();
|
|
1324
|
+
break;
|
|
1325
|
+
}
|
|
1326
|
+
case "eh:configurationApplied":
|
|
1327
|
+
if (!frameController.configured(msg.mode, msg.savePolicy))
|
|
1328
|
+
break;
|
|
1329
|
+
scheduleResultCapture();
|
|
1330
|
+
announce(`${state.reviewMode === "edit" ? "Edit" : "View"} mode active`);
|
|
1331
|
+
break;
|
|
1332
|
+
case "eh:target": {
|
|
1333
|
+
const detail = targetPayload(msg);
|
|
1334
|
+
if (!detail)
|
|
1335
|
+
return;
|
|
1336
|
+
if (state.target && detail.generation < state.target.generation)
|
|
1337
|
+
return;
|
|
1338
|
+
state.target = detail;
|
|
1339
|
+
break;
|
|
1340
|
+
}
|
|
1341
|
+
case "eh:openComment": {
|
|
1342
|
+
const requestedGeneration = Number(msg.targetGeneration);
|
|
1343
|
+
const detail = targetPayload(msg);
|
|
1344
|
+
let accepted = false;
|
|
1345
|
+
if (detail && (!state.target || detail.generation >= state.target.generation)) {
|
|
1346
|
+
accepted = await openCompose(detail);
|
|
1347
|
+
}
|
|
1348
|
+
if (!accepted && state.compose) {
|
|
1349
|
+
state.compose.rejectedTargetGeneration = Math.max(Number(state.compose.rejectedTargetGeneration) || 0, requestedGeneration);
|
|
1350
|
+
}
|
|
1351
|
+
toFrame({
|
|
1352
|
+
type: "eh:commentOpenResult",
|
|
1353
|
+
accepted,
|
|
1354
|
+
requestedGeneration,
|
|
1355
|
+
targetGeneration: state.compose?.generation || null,
|
|
1356
|
+
});
|
|
1357
|
+
break;
|
|
1358
|
+
}
|
|
1359
|
+
case "eh:targetGeometry":
|
|
1360
|
+
if (state.compose && msg.targetGeneration !== state.compose.generation) {
|
|
1361
|
+
diagnostic("geometry-rejected", { reason: "stale" });
|
|
1362
|
+
break;
|
|
1363
|
+
}
|
|
1364
|
+
if (state.compose) {
|
|
1365
|
+
const geometry = targetPayload({ ...msg, kind: state.compose.kind });
|
|
1366
|
+
if (!geometry) {
|
|
1367
|
+
diagnostic("geometry-rejected", { reason: "invalid" });
|
|
1368
|
+
break;
|
|
1369
|
+
}
|
|
1370
|
+
state.compose.rects = geometry.rects;
|
|
1371
|
+
state.compose.relation = geometry.relation;
|
|
1372
|
+
state.compose.clip = geometry.clip;
|
|
1373
|
+
state.compose.horizontal = geometry.horizontal ?? state.compose.horizontal;
|
|
1374
|
+
scheduleContextualPosition();
|
|
1375
|
+
}
|
|
1376
|
+
break;
|
|
1377
|
+
case "eh:commentGeometry": {
|
|
1378
|
+
if (!msg.id)
|
|
1379
|
+
break;
|
|
1380
|
+
const id = String(msg.id);
|
|
1381
|
+
if (!state.page?.comments?.some((comment) => comment.id === id)) {
|
|
1382
|
+
state.activeGeometry.delete(id);
|
|
1383
|
+
break;
|
|
1384
|
+
}
|
|
1385
|
+
const rects = sanitizeClientRects(msg.rects, {
|
|
1386
|
+
width: Number(msg.viewport?.width),
|
|
1387
|
+
height: Number(msg.viewport?.height),
|
|
1388
|
+
});
|
|
1389
|
+
state.activeGeometry.set(id, {
|
|
1390
|
+
rects,
|
|
1391
|
+
visible: msg.visible !== false && rects.length > 0,
|
|
1392
|
+
});
|
|
1393
|
+
if (state.activeSavedCommentId === id) {
|
|
1394
|
+
scheduleContextualPosition();
|
|
1395
|
+
restoreTransientFocus(false);
|
|
1396
|
+
}
|
|
1397
|
+
break;
|
|
1398
|
+
}
|
|
1399
|
+
case "eh:revealTargetResult":
|
|
1400
|
+
if (!state.compose || msg.targetGeneration !== state.compose.generation)
|
|
1401
|
+
break;
|
|
1402
|
+
announce(msg.success ? "Selection revealed" : "Selection is no longer available");
|
|
1403
|
+
diagnostic(msg.success ? "reveal-target-succeeded" : "reveal-target-failed");
|
|
1404
|
+
break;
|
|
1405
|
+
case "eh:interaction":
|
|
1406
|
+
closeModeMenu("frame-interaction");
|
|
1407
|
+
recoveryRuntime.commands.setMenuOpen(false, { restoreFocus: false });
|
|
1408
|
+
break;
|
|
1409
|
+
case "eh:dismiss":
|
|
1410
|
+
if (!state.composerDraft.text.trim())
|
|
1411
|
+
cancelCompose();
|
|
1412
|
+
break;
|
|
1413
|
+
case "eh:activate":
|
|
1414
|
+
setActive(msg.id, false);
|
|
1415
|
+
break;
|
|
1416
|
+
case "eh:anchorStatus":
|
|
1417
|
+
state.orphans = new Set(msg.orphaned || []);
|
|
1418
|
+
render();
|
|
1419
|
+
break;
|
|
1420
|
+
case "eh:notInView":
|
|
1421
|
+
toast("That comment is not visible in this view");
|
|
1422
|
+
break;
|
|
1423
|
+
case "eh:formBlocked":
|
|
1424
|
+
toast(String(msg.reason || "This form cannot be submitted from View mode"));
|
|
1425
|
+
break;
|
|
1426
|
+
case "eh:edit":
|
|
1427
|
+
saveController.markEdit();
|
|
1428
|
+
void persistEdit(frameController.state.key, {
|
|
1429
|
+
label: msg.label,
|
|
1430
|
+
kind: msg.kind,
|
|
1431
|
+
before: msg.before,
|
|
1432
|
+
after: msg.after,
|
|
1433
|
+
before_html: msg.before_html,
|
|
1434
|
+
after_html: msg.after_html,
|
|
1435
|
+
moved_after: msg.moved_after,
|
|
1436
|
+
moved_before: msg.moved_before,
|
|
1437
|
+
staged_assets: msg.staged_assets,
|
|
1438
|
+
});
|
|
1439
|
+
break;
|
|
1440
|
+
case "eh:asset": {
|
|
1441
|
+
const identity = currentRender();
|
|
1442
|
+
const query = new URLSearchParams({
|
|
1443
|
+
type: msg.assetType || "", sessionId: state.sessionId,
|
|
1444
|
+
renderId: identity.renderId, generation: String(identity.generation),
|
|
1445
|
+
savePolicy: frameController.state.execution?.savePolicy || "feedback-only",
|
|
1446
|
+
});
|
|
1447
|
+
try {
|
|
1448
|
+
const data = await api(`/api/page/${identity.key}/asset?${query}`, {
|
|
1449
|
+
method: "POST",
|
|
1450
|
+
headers: { "content-type": "application/octet-stream" },
|
|
1451
|
+
body: msg.bytes,
|
|
1452
|
+
});
|
|
1453
|
+
if (!sameRender(identity, currentRender()))
|
|
1454
|
+
break;
|
|
1455
|
+
toFrame({ type: "eh:assetSaved", id: msg.id, src: data.src, stagedId: data.stagedId });
|
|
1456
|
+
}
|
|
1457
|
+
catch (err) {
|
|
1458
|
+
if (!sameRender(identity, currentRender()))
|
|
1459
|
+
break;
|
|
1460
|
+
toast(err.message);
|
|
1461
|
+
toFrame({ type: "eh:assetFailed", id: msg.id });
|
|
1462
|
+
}
|
|
1463
|
+
break;
|
|
1464
|
+
}
|
|
1465
|
+
case "eh:saving":
|
|
1466
|
+
saveController.markSaving();
|
|
1467
|
+
renderSave();
|
|
1468
|
+
break;
|
|
1469
|
+
case "eh:html":
|
|
1470
|
+
await saveNow(msg.html);
|
|
1471
|
+
break;
|
|
1472
|
+
case "eh:clean":
|
|
1473
|
+
saveController.observeClean();
|
|
1474
|
+
break;
|
|
1475
|
+
case "eh:snapshot":
|
|
1476
|
+
captures.receive(msg);
|
|
1477
|
+
break;
|
|
1478
|
+
case "eh:viewChanged": {
|
|
1479
|
+
// Only a deduplicated identity-change signal can reopen this render's
|
|
1480
|
+
// automatic attempt. Snapshot and ordinary mutation messages cannot.
|
|
1481
|
+
captureCoordinator.reset();
|
|
1482
|
+
manualCaptureController?.abort();
|
|
1483
|
+
scheduleResultCapture();
|
|
1484
|
+
break;
|
|
1485
|
+
}
|
|
1486
|
+
case "eh:dynamic":
|
|
1487
|
+
saveController.markDynamic();
|
|
1488
|
+
renderSave();
|
|
1489
|
+
break;
|
|
1490
|
+
case "eh:flushed":
|
|
1491
|
+
frameController.flushed(msg.requestId);
|
|
1492
|
+
break;
|
|
1493
|
+
case "eh:scroll":
|
|
1494
|
+
state.scroll = { x: msg.x, y: msg.y };
|
|
1495
|
+
break;
|
|
1496
|
+
case "eh:external": {
|
|
1497
|
+
// This side is what actually calls window.open, so it re-checks the
|
|
1498
|
+
// scheme rather than trusting the frame: a javascript: or data: URL
|
|
1499
|
+
// arriving here would run on this origin, next to the token.
|
|
1500
|
+
const external = externalHref(msg.href, location.href);
|
|
1501
|
+
if (external)
|
|
1502
|
+
window.open(external, "_blank", "noopener");
|
|
1503
|
+
break;
|
|
1504
|
+
}
|
|
1505
|
+
case "eh:navigate":
|
|
1506
|
+
try {
|
|
1507
|
+
await reviewController.navigate({ href: msg.href });
|
|
1508
|
+
}
|
|
1509
|
+
catch (err) {
|
|
1510
|
+
toast(err.message);
|
|
1511
|
+
}
|
|
1512
|
+
break;
|
|
1513
|
+
default:
|
|
1514
|
+
break;
|
|
1515
|
+
}
|
|
1516
|
+
});
|
|
1517
|
+
// --------------------------------------------------------------- UI wiring
|
|
1518
|
+
/** The session is over: freeze the page and say so. Feedback is already safe. */
|
|
1519
|
+
function showEnded() {
|
|
1520
|
+
if (document.querySelector(".ended"))
|
|
1521
|
+
return;
|
|
1522
|
+
state.ended = true;
|
|
1523
|
+
state.modeMenuOpen = false;
|
|
1524
|
+
toolbarRuntime.publish();
|
|
1525
|
+
recoveryRuntime.publish();
|
|
1526
|
+
commentsRuntime.publish();
|
|
1527
|
+
contextualRuntime.publish();
|
|
1528
|
+
feedbackRuntime.publish();
|
|
1529
|
+
changesRuntime.publish();
|
|
1530
|
+
captureCoordinator.stop();
|
|
1531
|
+
manualCaptureController?.abort();
|
|
1532
|
+
captures.cancel("Review ended");
|
|
1533
|
+
historyController.cancel();
|
|
1534
|
+
reviewController.dispose();
|
|
1535
|
+
const overlay = document.createElement("div");
|
|
1536
|
+
overlay.className = "ended";
|
|
1537
|
+
const title = document.createElement("h2");
|
|
1538
|
+
title.textContent = "Review ended";
|
|
1539
|
+
const line = document.createElement("p");
|
|
1540
|
+
line.textContent = "Unsent feedback is saved and ships next time you review this page. You can close this tab.";
|
|
1541
|
+
overlay.append(title, line);
|
|
1542
|
+
document.body.append(overlay);
|
|
1543
|
+
}
|
|
1544
|
+
function toggleTheme() {
|
|
1545
|
+
const dark = document.documentElement.dataset.theme !== "dark";
|
|
1546
|
+
applyTheme(dark);
|
|
1547
|
+
try {
|
|
1548
|
+
localStorage.setItem("doc-review:theme", dark ? "dark" : "light");
|
|
1549
|
+
}
|
|
1550
|
+
catch (error) {
|
|
1551
|
+
diagnostic("theme-preference-save-failed", { message: error.message });
|
|
1552
|
+
announce("Theme changed for this review, but the preference could not be saved.");
|
|
1553
|
+
}
|
|
1554
|
+
}
|
|
1555
|
+
function applyTheme(dark) {
|
|
1556
|
+
document.documentElement.dataset.theme = dark ? "dark" : "light";
|
|
1557
|
+
state.theme = dark ? "dark" : "light";
|
|
1558
|
+
toolbarRuntime.publish();
|
|
1559
|
+
}
|
|
1560
|
+
listen(document, "keydown", (event) => {
|
|
1561
|
+
if (event.defaultPrevented || feedbackRuntime.getSnapshot().dialog)
|
|
1562
|
+
return;
|
|
1563
|
+
const meta = event.metaKey || event.ctrlKey;
|
|
1564
|
+
// ⌘S is reassurance only: flush pending keystrokes, never a state change.
|
|
1565
|
+
if (meta && event.key.toLowerCase() === "s") {
|
|
1566
|
+
event.preventDefault();
|
|
1567
|
+
toFrame({ type: "eh:flush" });
|
|
1568
|
+
renderSave();
|
|
1569
|
+
return;
|
|
1570
|
+
}
|
|
1571
|
+
if (event.key !== "Escape")
|
|
1572
|
+
return;
|
|
1573
|
+
let handled = false;
|
|
1574
|
+
if (state.commentUi.confirmation)
|
|
1575
|
+
handled = cancelDeleteConfirmation();
|
|
1576
|
+
else if (state.commentUi.edit)
|
|
1577
|
+
handled = cancelCommentEdit();
|
|
1578
|
+
else if (state.compose)
|
|
1579
|
+
handled = cancelCompose();
|
|
1580
|
+
else if (state.modeMenuOpen)
|
|
1581
|
+
handled = closeModeMenu("escape", { restoreFocus: true });
|
|
1582
|
+
else if (state.drawerOpen) {
|
|
1583
|
+
closeDrawer();
|
|
1584
|
+
handled = true;
|
|
1585
|
+
}
|
|
1586
|
+
else if (state.activeSavedCommentId)
|
|
1587
|
+
handled = dismissActiveComment("escape", { restoreFocus: true });
|
|
1588
|
+
if (handled) {
|
|
1589
|
+
event.preventDefault();
|
|
1590
|
+
event.stopPropagation();
|
|
1591
|
+
}
|
|
1592
|
+
});
|
|
1593
|
+
// ------------------------------------------------------------------ events
|
|
1594
|
+
function holdReload() {
|
|
1595
|
+
frameController.holdReload();
|
|
1596
|
+
saveController.hold();
|
|
1597
|
+
state.reloadVisible = true;
|
|
1598
|
+
state.reloadFailed = false;
|
|
1599
|
+
state.reloadMessage = saveController.state.dirty || saveController.state.conflict
|
|
1600
|
+
? "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."
|
|
1601
|
+
: "The source changed. Reload keeps your comment drafts as unresolved excerpts so they cannot attach to the wrong content.";
|
|
1602
|
+
captureCoordinator.reset();
|
|
1603
|
+
manualCaptureController?.abort();
|
|
1604
|
+
captures.cancel();
|
|
1605
|
+
renderExecution();
|
|
1606
|
+
if (state.comparing)
|
|
1607
|
+
renderHistory();
|
|
1608
|
+
}
|
|
1609
|
+
async function reloadLatest({ explicit = false } = {}) {
|
|
1610
|
+
if (!explicit && (draftCount(state) || saveController.state.dirty || saveController.state.conflict || feedbackController.sending)) {
|
|
1611
|
+
holdReload();
|
|
1612
|
+
return;
|
|
1613
|
+
}
|
|
1614
|
+
const key = frameController.state.key;
|
|
1615
|
+
advancePageEpoch("reload");
|
|
1616
|
+
captureEditState();
|
|
1617
|
+
if (state.compose) {
|
|
1618
|
+
state.compose.unresolved = true;
|
|
1619
|
+
state.compose.relation = "unavailable";
|
|
1620
|
+
state.compose.rects = [];
|
|
1621
|
+
state.compose.generation = -1;
|
|
1622
|
+
}
|
|
1623
|
+
const generation = beginFrameTransition();
|
|
1624
|
+
frameController.resetRetries();
|
|
1625
|
+
state.reloadVisible = false;
|
|
1626
|
+
frameController.startReload();
|
|
1627
|
+
saveController.reset();
|
|
1628
|
+
try {
|
|
1629
|
+
const page = await api(pageUrl(key, state.sessionId), undefined, decodePage);
|
|
1630
|
+
if (frameController.state.key !== key || frameController.state.generation !== generation)
|
|
1631
|
+
return;
|
|
1632
|
+
state.executionPreference = page.executionPreference || null;
|
|
1633
|
+
// Keep parent-owned drafts rather than replacing their text on a source update.
|
|
1634
|
+
replacePage(state, page);
|
|
1635
|
+
saveController.reset();
|
|
1636
|
+
if (state.compose) {
|
|
1637
|
+
state.composerDraft.error = "The source changed. This draft keeps its original excerpt; cancel it to select a new target.";
|
|
1638
|
+
}
|
|
1639
|
+
render();
|
|
1640
|
+
await registerFrame(key, generation);
|
|
1641
|
+
}
|
|
1642
|
+
catch (err) {
|
|
1643
|
+
if (frameController.state.key === key && frameController.state.generation === generation)
|
|
1644
|
+
failFrame(err.message);
|
|
1645
|
+
}
|
|
1646
|
+
}
|
|
1647
|
+
listen(window, "beforeunload", (event) => {
|
|
1648
|
+
if (!saveController.state.dirty && !draftCount(state, state.noteDraft.text) && !saveController.queued(frameController.state.key))
|
|
1649
|
+
return;
|
|
1650
|
+
event.preventDefault();
|
|
1651
|
+
event.returnValue = "";
|
|
1652
|
+
});
|
|
1653
|
+
listen(window, "pagehide", (event) => {
|
|
1654
|
+
if (event.persisted)
|
|
1655
|
+
return;
|
|
1656
|
+
state.ended = true;
|
|
1657
|
+
state.modeMenuOpen = false;
|
|
1658
|
+
toolbarRuntime.publish();
|
|
1659
|
+
recoveryRuntime.publish();
|
|
1660
|
+
commentsRuntime.publish();
|
|
1661
|
+
contextualRuntime.publish();
|
|
1662
|
+
changesRuntime.publish();
|
|
1663
|
+
captureCoordinator.stop();
|
|
1664
|
+
manualCaptureController?.abort();
|
|
1665
|
+
captures.cancel("Review closed");
|
|
1666
|
+
historyController.cancel();
|
|
1667
|
+
reviewController.dispose();
|
|
1668
|
+
});
|
|
1669
|
+
const connect = () => reviewController.connect();
|
|
1670
|
+
// -------------------------------------------------------------------- start
|
|
1671
|
+
(async function start() {
|
|
1672
|
+
installStaticIcons();
|
|
1673
|
+
try {
|
|
1674
|
+
applyTheme(localStorage.getItem("doc-review:theme") === "dark");
|
|
1675
|
+
}
|
|
1676
|
+
catch (error) {
|
|
1677
|
+
applyTheme(false);
|
|
1678
|
+
diagnostic("theme-preference-read-failed", { message: error.message });
|
|
1679
|
+
}
|
|
1680
|
+
const bootstrap = await api(`/api/session/${state.sessionId}/page`);
|
|
1681
|
+
if (bootstrap && bootstrap.page)
|
|
1682
|
+
state.pollCommand = bootstrap.page.pollCommand;
|
|
1683
|
+
if (bootstrap && Number.isSafeInteger(bootstrap.generation))
|
|
1684
|
+
frameController.seedGeneration(bootstrap.generation);
|
|
1685
|
+
const key = bootstrap ? bootstrap.key : new URLSearchParams(location.search).get("key");
|
|
1686
|
+
await loadPage(key);
|
|
1687
|
+
connect();
|
|
1688
|
+
void refreshHistory();
|
|
1689
|
+
})().catch((error) => {
|
|
1690
|
+
failFrame(`Review could not start: ${error.message}`);
|
|
1691
|
+
});
|
|
1692
|
+
listen(window, "resize", scheduleContextualPosition);
|
|
1693
|
+
if (window.visualViewport) {
|
|
1694
|
+
listen(window.visualViewport, "resize", scheduleContextualPosition);
|
|
1695
|
+
listen(window.visualViewport, "scroll", scheduleContextualPosition);
|
|
1696
|
+
}
|
|
1697
|
+
reviewController.own(frameHost.onFocus(() => {
|
|
1698
|
+
if (state.compose)
|
|
1699
|
+
$("compose").classList.add("pass-through");
|
|
1700
|
+
}));
|