@erdemtuna/doc-review 0.9.0 → 0.10.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 +52 -227
- package/lib/anchor-text.js +155 -0
- package/lib/atomic-write.js +54 -0
- package/lib/chrome-api.js +78 -0
- package/lib/chrome-client.js +2255 -0
- package/lib/chrome-session.js +76 -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/comparison-view.js +380 -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/document-execution.js +115 -0
- package/lib/edit-limits.js +24 -0
- package/lib/editing.js +107 -0
- package/lib/execution-client.js +69 -0
- package/lib/feedback-controller.js +106 -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 +210 -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 +249 -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/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 +300 -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/view-identity.js +112 -0
- package/package.json +24 -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/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}/SKILL.md +0 -0
- /package/{src → lib}/chrome.css +0 -0
- /package/{src → lib}/chrome.html +0 -0
- /package/{src → lib}/document-trust.js +0 -0
package/src/chrome-client.js
DELETED
|
@@ -1,2747 +0,0 @@
|
|
|
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 { tidyMiddle } from "./anchor-text.js";
|
|
9
|
-
import {
|
|
10
|
-
clearOwned,
|
|
11
|
-
createCommentUi,
|
|
12
|
-
migrateCommentUi,
|
|
13
|
-
mutationIsCurrent,
|
|
14
|
-
ownConfirmation,
|
|
15
|
-
ownEdit,
|
|
16
|
-
ownMenu,
|
|
17
|
-
newestComments,
|
|
18
|
-
pageUrl,
|
|
19
|
-
reconcileCommentUi,
|
|
20
|
-
replacePage,
|
|
21
|
-
} from "./chrome-session.js";
|
|
22
|
-
import { sanitizeClientRects, sanitizeClipRect, sanitizeRelation } from "./comment-target.js";
|
|
23
|
-
import { externalHref } from "./editing.js";
|
|
24
|
-
import { framePolicy } from "./frame-policy.js";
|
|
25
|
-
import { createIcon } from "./icons.js";
|
|
26
|
-
import { createComparisonView } from "./comparison-view.js";
|
|
27
|
-
import { createExecutionControls } from "./execution-client.js";
|
|
28
|
-
import { alignedCardPosition, placeContextualSurface, visibleViewport } from "./positioning.js";
|
|
29
|
-
import { normalizeReviewMode, reviewConfiguration } from "./review-mode.js";
|
|
30
|
-
import { createCaptureRequests, captureError, sameRender, draftCount, excerpt, changeKind, pendingCaptureTarget, comparisonFreshness } from "./history-client.js";
|
|
31
|
-
import { createCaptureCoordinator, createHistoryController, deliverFeedback, historyPresentation, requireCaptureSuccess } from "./history-coordinator.js";
|
|
32
|
-
|
|
33
|
-
const $ = (id) => document.getElementById(id);
|
|
34
|
-
let frame = $("frame");
|
|
35
|
-
let previousFrame = null;
|
|
36
|
-
let replacementTimer = null;
|
|
37
|
-
|
|
38
|
-
const state = {
|
|
39
|
-
sessionId: document.body.dataset.session,
|
|
40
|
-
token: document.body.dataset.token,
|
|
41
|
-
key: null,
|
|
42
|
-
page: null,
|
|
43
|
-
compose: null,
|
|
44
|
-
composeLifecycle: "closed",
|
|
45
|
-
composePlacement: "hidden",
|
|
46
|
-
target: null,
|
|
47
|
-
activeSavedCommentId: null,
|
|
48
|
-
activeGeometry: new Map(),
|
|
49
|
-
commentUi: createCommentUi(),
|
|
50
|
-
pageEpoch: 0,
|
|
51
|
-
reviewMode: "view",
|
|
52
|
-
savePolicy: "writable",
|
|
53
|
-
modeApplying: false,
|
|
54
|
-
modeMenuOpen: false,
|
|
55
|
-
drawerOpen: false,
|
|
56
|
-
agent: "idle",
|
|
57
|
-
save: "idle",
|
|
58
|
-
savedAt: "",
|
|
59
|
-
sent: false,
|
|
60
|
-
orphans: new Set(),
|
|
61
|
-
pollCommand: "",
|
|
62
|
-
editsExpanded: false,
|
|
63
|
-
others: [],
|
|
64
|
-
scroll: { x: 0, y: 0 },
|
|
65
|
-
reloading: false,
|
|
66
|
-
dynamic: false,
|
|
67
|
-
framePolicy: null,
|
|
68
|
-
renderGeneration: 0,
|
|
69
|
-
renderId: null,
|
|
70
|
-
frameCapability: null,
|
|
71
|
-
frameLoading: true,
|
|
72
|
-
frameFailure: null,
|
|
73
|
-
pendingInitialLoad: false,
|
|
74
|
-
readyGeneration: null,
|
|
75
|
-
frameReadyTimer: null,
|
|
76
|
-
frameReadyRetries: 0,
|
|
77
|
-
configurationGeneration: null,
|
|
78
|
-
saveConflict: false,
|
|
79
|
-
comparing: false,
|
|
80
|
-
sending: false,
|
|
81
|
-
domDirty: false,
|
|
82
|
-
pendingReload: false,
|
|
83
|
-
renderSourceHash: null,
|
|
84
|
-
frameReadyAt: 0,
|
|
85
|
-
cleanObservation: null,
|
|
86
|
-
renderExecution: null,
|
|
87
|
-
executionPreference: null,
|
|
88
|
-
ended: false,
|
|
89
|
-
};
|
|
90
|
-
|
|
91
|
-
const diagnostic = (event, detail = {}) => {
|
|
92
|
-
console.info("[doc-review]", { event, ...detail });
|
|
93
|
-
};
|
|
94
|
-
|
|
95
|
-
const patchFlights = new Map();
|
|
96
|
-
const deleteFlights = new Map();
|
|
97
|
-
let requestedFocus = null;
|
|
98
|
-
let editFocusRequested = false;
|
|
99
|
-
let skipEditCaptureOnce = false;
|
|
100
|
-
|
|
101
|
-
function advancePageEpoch(reason) {
|
|
102
|
-
state.pageEpoch += 1;
|
|
103
|
-
diagnostic("page-epoch-advanced", { epoch: state.pageEpoch, reason });
|
|
104
|
-
return state.pageEpoch;
|
|
105
|
-
}
|
|
106
|
-
|
|
107
|
-
function commentById(id) {
|
|
108
|
-
return state.page?.comments?.find((comment) => comment.id === id) || null;
|
|
109
|
-
}
|
|
110
|
-
|
|
111
|
-
function domId(value) {
|
|
112
|
-
return String(value).replace(/[^a-zA-Z0-9_-]/g, (char) => `-${char.codePointAt(0).toString(16)}-`);
|
|
113
|
-
}
|
|
114
|
-
|
|
115
|
-
function controlId(commentId, surface, action) {
|
|
116
|
-
return `comment-${surface}-${domId(commentId)}-${action}`;
|
|
117
|
-
}
|
|
118
|
-
|
|
119
|
-
function requestControlFocus(id) {
|
|
120
|
-
requestedFocus = id;
|
|
121
|
-
}
|
|
122
|
-
|
|
123
|
-
function editTextarea(commentId) {
|
|
124
|
-
const root = state.drawerOpen ? $("cards") : $("alignedCard");
|
|
125
|
-
return root?.querySelector(`textarea[data-comment-edit="${CSS.escape(commentId)}"]`) || null;
|
|
126
|
-
}
|
|
127
|
-
|
|
128
|
-
function captureEditState() {
|
|
129
|
-
const edit = state.commentUi.edit;
|
|
130
|
-
if (!edit) return false;
|
|
131
|
-
const input = editTextarea(edit.commentId);
|
|
132
|
-
if (!input) return false;
|
|
133
|
-
edit.draft = input.value;
|
|
134
|
-
edit.selectionStart = input.selectionStart;
|
|
135
|
-
edit.selectionEnd = input.selectionEnd;
|
|
136
|
-
return document.activeElement === input;
|
|
137
|
-
}
|
|
138
|
-
|
|
139
|
-
function restoreTransientFocus(editWasFocused = false) {
|
|
140
|
-
const focusId = requestedFocus;
|
|
141
|
-
requestedFocus = null;
|
|
142
|
-
const shouldFocusEdit = editWasFocused || editFocusRequested;
|
|
143
|
-
requestAnimationFrame(() => {
|
|
144
|
-
if (focusId) {
|
|
145
|
-
document.getElementById(focusId)?.focus();
|
|
146
|
-
return;
|
|
147
|
-
}
|
|
148
|
-
const edit = state.commentUi.edit;
|
|
149
|
-
if (!shouldFocusEdit || !edit) return;
|
|
150
|
-
const input = editTextarea(edit.commentId);
|
|
151
|
-
if (!input || input.closest("[hidden]")) {
|
|
152
|
-
editFocusRequested = true;
|
|
153
|
-
return;
|
|
154
|
-
}
|
|
155
|
-
editFocusRequested = false;
|
|
156
|
-
input.focus();
|
|
157
|
-
input.setSelectionRange(edit.selectionStart, edit.selectionEnd);
|
|
158
|
-
});
|
|
159
|
-
}
|
|
160
|
-
|
|
161
|
-
function moveTransientSurface(surface) {
|
|
162
|
-
const menu = state.commentUi.menu;
|
|
163
|
-
if (menu) {
|
|
164
|
-
menu.surface = surface;
|
|
165
|
-
menu.triggerId = controlId(menu.commentId, surface, "more");
|
|
166
|
-
}
|
|
167
|
-
if (state.commentUi.confirmation) state.commentUi.confirmation.surface = surface;
|
|
168
|
-
}
|
|
169
|
-
|
|
170
|
-
function announce(message) {
|
|
171
|
-
const region = $("liveRegion");
|
|
172
|
-
region.textContent = "";
|
|
173
|
-
requestAnimationFrame(() => {
|
|
174
|
-
region.textContent = message;
|
|
175
|
-
});
|
|
176
|
-
}
|
|
177
|
-
|
|
178
|
-
function replaceIcon(container, name, title = "") {
|
|
179
|
-
container.textContent = "";
|
|
180
|
-
container.append(createIcon(name, { title }));
|
|
181
|
-
}
|
|
182
|
-
|
|
183
|
-
function installStaticIcons() {
|
|
184
|
-
document.querySelectorAll("[data-icon]").forEach((container) => {
|
|
185
|
-
replaceIcon(container, container.dataset.icon);
|
|
186
|
-
});
|
|
187
|
-
replaceIcon($("modeChevron"), "chevronDown");
|
|
188
|
-
replaceIcon($("composeClose"), "x");
|
|
189
|
-
replaceIcon($("drawerClose"), "x");
|
|
190
|
-
}
|
|
191
|
-
|
|
192
|
-
function openModeMenu() {
|
|
193
|
-
if (state.modeMenuOpen) return;
|
|
194
|
-
state.modeMenuOpen = true;
|
|
195
|
-
$("modeMenu").hidden = false;
|
|
196
|
-
$("modeButton").setAttribute("aria-expanded", "true");
|
|
197
|
-
toFrame({ type: "eh:modeMenuState", open: true });
|
|
198
|
-
$("modeMenu").querySelector(`[data-mode="${state.reviewMode}"]`)?.focus();
|
|
199
|
-
}
|
|
200
|
-
|
|
201
|
-
function closeModeMenu(reason = "dismissed", { restoreFocus = false } = {}) {
|
|
202
|
-
if (!state.modeMenuOpen) return false;
|
|
203
|
-
state.modeMenuOpen = false;
|
|
204
|
-
$("modeMenu").hidden = true;
|
|
205
|
-
$("modeButton").setAttribute("aria-expanded", "false");
|
|
206
|
-
toFrame({ type: "eh:modeMenuState", open: false });
|
|
207
|
-
diagnostic("mode-menu-close", { reason });
|
|
208
|
-
if (restoreFocus) $("modeButton").focus();
|
|
209
|
-
return true;
|
|
210
|
-
}
|
|
211
|
-
|
|
212
|
-
function openDrawer() {
|
|
213
|
-
captureEditState();
|
|
214
|
-
skipEditCaptureOnce = true;
|
|
215
|
-
state.drawerOpen = true;
|
|
216
|
-
moveTransientSurface("drawer");
|
|
217
|
-
editFocusRequested = !!state.commentUi.edit;
|
|
218
|
-
render();
|
|
219
|
-
if (!state.commentUi.edit) requestAnimationFrame(() => $("commentsSection").focus());
|
|
220
|
-
}
|
|
221
|
-
|
|
222
|
-
function closeDrawer() {
|
|
223
|
-
if (!state.drawerOpen) return;
|
|
224
|
-
captureEditState();
|
|
225
|
-
skipEditCaptureOnce = true;
|
|
226
|
-
state.drawerOpen = false;
|
|
227
|
-
const transientOwner =
|
|
228
|
-
state.commentUi.edit?.commentId ||
|
|
229
|
-
state.commentUi.confirmation?.commentId ||
|
|
230
|
-
state.commentUi.menu?.commentId ||
|
|
231
|
-
null;
|
|
232
|
-
if (transientOwner && commentById(transientOwner)) {
|
|
233
|
-
state.activeSavedCommentId = transientOwner;
|
|
234
|
-
moveTransientSurface("aligned");
|
|
235
|
-
toFrame({ type: "eh:activate", id: transientOwner, scroll: false });
|
|
236
|
-
} else if (state.activeSavedCommentId) {
|
|
237
|
-
moveTransientSurface("aligned");
|
|
238
|
-
}
|
|
239
|
-
editFocusRequested = !!state.commentUi.edit;
|
|
240
|
-
render();
|
|
241
|
-
if (!state.commentUi.edit) $("commentsButton").focus();
|
|
242
|
-
}
|
|
243
|
-
|
|
244
|
-
function setComposeLifecycle(next, reason) {
|
|
245
|
-
if (state.composeLifecycle === next) return;
|
|
246
|
-
const from = state.composeLifecycle;
|
|
247
|
-
state.composeLifecycle = next;
|
|
248
|
-
const submitting = next === "submitting";
|
|
249
|
-
$("composeCancel").disabled = submitting;
|
|
250
|
-
$("composeClose").disabled = submitting;
|
|
251
|
-
diagnostic("composer-lifecycle-transition", { from, to: next, reason });
|
|
252
|
-
}
|
|
253
|
-
|
|
254
|
-
function setComposePlacement(next) {
|
|
255
|
-
if (state.composePlacement === next) return;
|
|
256
|
-
const from = state.composePlacement;
|
|
257
|
-
state.composePlacement = next;
|
|
258
|
-
diagnostic("placement-transition", { from, to: next });
|
|
259
|
-
}
|
|
260
|
-
|
|
261
|
-
/**
|
|
262
|
-
* Most reviewers drive an agent from a chat (Claude Code, Codex, Cursor), not
|
|
263
|
-
* a bare terminal — so the handoff is a prompt the agent can act on, with the
|
|
264
|
-
* poll command embedded for anyone who does live in a shell.
|
|
265
|
-
*/
|
|
266
|
-
function handoffPrompt(pollCommand) {
|
|
267
|
-
const cmd = String(pollCommand || "").trim();
|
|
268
|
-
if (!cmd) return "";
|
|
269
|
-
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.`;
|
|
270
|
-
}
|
|
271
|
-
|
|
272
|
-
// ------------------------------------------------------------------- server
|
|
273
|
-
|
|
274
|
-
async function api(path, options) {
|
|
275
|
-
const res = await fetch(path, {
|
|
276
|
-
...options,
|
|
277
|
-
headers: { "content-type": "application/json", "x-doc-review-token": state.token, ...(options && options.headers) },
|
|
278
|
-
});
|
|
279
|
-
if (!res.ok) {
|
|
280
|
-
const detail = await res.json().catch(() => ({}));
|
|
281
|
-
const error = new Error(detail.error || `Request failed (${res.status})`);
|
|
282
|
-
error.status = res.status;
|
|
283
|
-
error.code = detail.code;
|
|
284
|
-
error.targets = detail.targets || [];
|
|
285
|
-
throw error;
|
|
286
|
-
}
|
|
287
|
-
return res.json();
|
|
288
|
-
}
|
|
289
|
-
|
|
290
|
-
const executionControls = createExecutionControls({
|
|
291
|
-
api,
|
|
292
|
-
sessionId: state.sessionId,
|
|
293
|
-
elements: {
|
|
294
|
-
static: $("executionStatic"), auto: $("executionAuto"), status: $("executionStatus"),
|
|
295
|
-
details: $("executionDetails"), menu: $("reviewDetails"),
|
|
296
|
-
editDescription: $("modeMenu").querySelector('[data-mode="edit"] small'),
|
|
297
|
-
},
|
|
298
|
-
changed(page) {
|
|
299
|
-
if (!page || page.key !== state.key) return;
|
|
300
|
-
state.executionPreference = page.executionPreference || null;
|
|
301
|
-
state.page = {
|
|
302
|
-
...state.page,
|
|
303
|
-
executionPreference: page.executionPreference,
|
|
304
|
-
...state.renderExecution,
|
|
305
|
-
};
|
|
306
|
-
state.savePolicy = reviewConfiguration(state.page, state.reviewMode).savePolicy;
|
|
307
|
-
render();
|
|
308
|
-
},
|
|
309
|
-
failed(error) { toast(error.message || String(error)); },
|
|
310
|
-
});
|
|
311
|
-
|
|
312
|
-
const toolbar = document.querySelector(".toolbar");
|
|
313
|
-
new ResizeObserver(() => {
|
|
314
|
-
document.documentElement.style.setProperty("--toolbar-h", `${toolbar.getBoundingClientRect().height}px`);
|
|
315
|
-
}).observe(toolbar);
|
|
316
|
-
|
|
317
|
-
function renderExecution() {
|
|
318
|
-
const visibleFrame = previousFrame || frame;
|
|
319
|
-
const rendered = visibleFrame.dataset.savePolicy ? {
|
|
320
|
-
executionMode: visibleFrame.dataset.executionMode,
|
|
321
|
-
savePolicy: visibleFrame.dataset.savePolicy,
|
|
322
|
-
executionNotice: visibleFrame.dataset.executionNotice || null,
|
|
323
|
-
} : null;
|
|
324
|
-
executionControls.render(state.page, rendered, {
|
|
325
|
-
pendingReload: state.pendingReload || !!previousFrame,
|
|
326
|
-
loading: state.frameLoading,
|
|
327
|
-
error: state.frameFailure,
|
|
328
|
-
});
|
|
329
|
-
}
|
|
330
|
-
|
|
331
|
-
const editPipelines = new Map();
|
|
332
|
-
const editBacklogs = new Map();
|
|
333
|
-
const editErrors = new Map();
|
|
334
|
-
let editMutationSequence = 0;
|
|
335
|
-
|
|
336
|
-
function editIdentity(payload) {
|
|
337
|
-
return `${String(payload.label || "")}\u0000${String(payload.kind || "")}`;
|
|
338
|
-
}
|
|
339
|
-
|
|
340
|
-
function editBacklog(key) {
|
|
341
|
-
let backlog = editBacklogs.get(key);
|
|
342
|
-
if (!backlog) {
|
|
343
|
-
backlog = new Map();
|
|
344
|
-
editBacklogs.set(key, backlog);
|
|
345
|
-
}
|
|
346
|
-
return backlog;
|
|
347
|
-
}
|
|
348
|
-
|
|
349
|
-
function persistEdit(key, payload) {
|
|
350
|
-
if (!payload.renderId && key === state.key && state.renderId) {
|
|
351
|
-
payload = {
|
|
352
|
-
...payload,
|
|
353
|
-
sessionId: state.sessionId, renderId: state.renderId, generation: state.renderGeneration,
|
|
354
|
-
savePolicy: state.renderExecution?.savePolicy || "feedback-only",
|
|
355
|
-
};
|
|
356
|
-
}
|
|
357
|
-
const identity = editIdentity(payload);
|
|
358
|
-
const backlog = editBacklog(key);
|
|
359
|
-
backlog.set(identity, payload);
|
|
360
|
-
const previous = editPipelines.get(key) || Promise.resolve();
|
|
361
|
-
const current = previous
|
|
362
|
-
.catch(() => {})
|
|
363
|
-
.then(async () => {
|
|
364
|
-
try {
|
|
365
|
-
const result = await api(`/api/page/${key}/edit`, {
|
|
366
|
-
method: "POST",
|
|
367
|
-
body: JSON.stringify(payload),
|
|
368
|
-
});
|
|
369
|
-
if (backlog.get(identity) === payload) backlog.delete(identity);
|
|
370
|
-
editErrors.delete(key);
|
|
371
|
-
if (state.key === key) {
|
|
372
|
-
state.page = result.page;
|
|
373
|
-
state.sent = false;
|
|
374
|
-
render();
|
|
375
|
-
}
|
|
376
|
-
return true;
|
|
377
|
-
} catch (err) {
|
|
378
|
-
editErrors.set(key, err);
|
|
379
|
-
if (state.key === key) {
|
|
380
|
-
toast(`${err.message}. Your edit is still queued locally.`);
|
|
381
|
-
announce("An edit could not be saved. Retry before leaving the page or sending feedback.");
|
|
382
|
-
}
|
|
383
|
-
return false;
|
|
384
|
-
}
|
|
385
|
-
});
|
|
386
|
-
editPipelines.set(key, current);
|
|
387
|
-
void current.finally(() => {
|
|
388
|
-
if (editPipelines.get(key) === current) editPipelines.delete(key);
|
|
389
|
-
applyCleanObservation();
|
|
390
|
-
});
|
|
391
|
-
return current;
|
|
392
|
-
}
|
|
393
|
-
|
|
394
|
-
async function settleEditPersistence(key) {
|
|
395
|
-
const inFlight = editPipelines.get(key);
|
|
396
|
-
if (inFlight) await inFlight;
|
|
397
|
-
const backlog = editBacklogs.get(key);
|
|
398
|
-
if (backlog?.size) {
|
|
399
|
-
const retry = [...backlog.values()];
|
|
400
|
-
editErrors.delete(key);
|
|
401
|
-
for (const payload of retry) persistEdit(key, payload);
|
|
402
|
-
const retried = editPipelines.get(key);
|
|
403
|
-
if (retried) await retried;
|
|
404
|
-
}
|
|
405
|
-
if (editBacklogs.get(key)?.size) {
|
|
406
|
-
throw editErrors.get(key) || new Error("An edit could not be saved");
|
|
407
|
-
}
|
|
408
|
-
}
|
|
409
|
-
|
|
410
|
-
function reconcilePage(page, { syncAnchors = false, reason = "mutation", advance = reason !== "mutation" } = {}) {
|
|
411
|
-
const previousIds = new Set((state.page?.comments || []).map((comment) => comment.id));
|
|
412
|
-
if (advance) advancePageEpoch(reason);
|
|
413
|
-
replacePage(state, page);
|
|
414
|
-
const commentIds = new Set((page.comments || []).map((comment) => comment.id));
|
|
415
|
-
for (const id of state.activeGeometry.keys()) {
|
|
416
|
-
if (!commentIds.has(id)) state.activeGeometry.delete(id);
|
|
417
|
-
}
|
|
418
|
-
if (state.activeSavedCommentId && !commentIds.has(state.activeSavedCommentId)) {
|
|
419
|
-
state.activeSavedCommentId = null;
|
|
420
|
-
}
|
|
421
|
-
const removedTransient = reconcileCommentUi(state.commentUi, page.comments || []);
|
|
422
|
-
state.orphans = new Set([...state.orphans].filter((id) => commentIds.has(id)));
|
|
423
|
-
if (syncAnchors && !state.frameLoading) {
|
|
424
|
-
toFrame({ type: "eh:anchors", comments: page.comments || [] });
|
|
425
|
-
}
|
|
426
|
-
if (reason === "acknowledgement") {
|
|
427
|
-
const removed = [...previousIds].filter((id) => !commentIds.has(id));
|
|
428
|
-
if (removed.length) {
|
|
429
|
-
announce(`${removed.length === 1 ? "Comment" : "Comments"} delivered and removed`);
|
|
430
|
-
if (removedTransient.size) requestAnimationFrame(() => (state.drawerOpen ? $("commentsSection") : frame).focus());
|
|
431
|
-
}
|
|
432
|
-
}
|
|
433
|
-
diagnostic("page-reconciled", { reason, epoch: state.pageEpoch });
|
|
434
|
-
}
|
|
435
|
-
|
|
436
|
-
// Keep the reviewed app on a different loopback origin from the review shell.
|
|
437
|
-
// This gives route-aware frameworks a real origin without exposing the parent UI.
|
|
438
|
-
const ARTIFACT_HOST = location.hostname === "127.0.0.1" ? "localhost" : "127.0.0.1";
|
|
439
|
-
const ARTIFACT_ORIGIN = `${location.protocol}//${ARTIFACT_HOST}:${location.port}`;
|
|
440
|
-
const FRAME_READY_TIMEOUT_MS = 5000;
|
|
441
|
-
|
|
442
|
-
// URL reviews keep a real origin. File reviews use an opaque sandbox origin,
|
|
443
|
-
// so postMessage requires "*" while the source-window check remains exact.
|
|
444
|
-
const toFrame = (message) => {
|
|
445
|
-
if (state.frameLoading || !state.frameCapability || !frame.contentWindow) return;
|
|
446
|
-
frame.contentWindow.postMessage(
|
|
447
|
-
{
|
|
448
|
-
...message,
|
|
449
|
-
capability: state.frameCapability,
|
|
450
|
-
generation: state.renderGeneration,
|
|
451
|
-
pageKey: state.key,
|
|
452
|
-
},
|
|
453
|
-
state.framePolicy?.targetOrigin || ARTIFACT_ORIGIN
|
|
454
|
-
);
|
|
455
|
-
};
|
|
456
|
-
|
|
457
|
-
function suspendFrame() {
|
|
458
|
-
captures.cancel();
|
|
459
|
-
captureCoordinator.reset();
|
|
460
|
-
manualCaptureController?.abort();
|
|
461
|
-
for (const request of flushRequests.values()) request.settle(false);
|
|
462
|
-
clearTimeout(state.frameReadyTimer);
|
|
463
|
-
state.frameReadyTimer = null;
|
|
464
|
-
state.frameCapability = null;
|
|
465
|
-
state.renderId = null;
|
|
466
|
-
state.frameLoading = true;
|
|
467
|
-
state.readyGeneration = null;
|
|
468
|
-
state.configurationGeneration = null;
|
|
469
|
-
state.renderExecution = null;
|
|
470
|
-
frame.removeAttribute("data-sdk-ready");
|
|
471
|
-
}
|
|
472
|
-
|
|
473
|
-
function beginFrameTransition() {
|
|
474
|
-
state.frameFailure = null;
|
|
475
|
-
if (state.compose) {
|
|
476
|
-
state.compose.unresolved = true;
|
|
477
|
-
state.compose.relation = "unavailable";
|
|
478
|
-
state.compose.rects = [];
|
|
479
|
-
state.compose.generation = -1;
|
|
480
|
-
}
|
|
481
|
-
state.target = null;
|
|
482
|
-
state.activeGeometry.clear();
|
|
483
|
-
state.baseHash = null;
|
|
484
|
-
state.renderSourceHash = null;
|
|
485
|
-
state.frameReadyAt = 0;
|
|
486
|
-
state.renderGeneration += 1;
|
|
487
|
-
suspendFrame();
|
|
488
|
-
return state.renderGeneration;
|
|
489
|
-
}
|
|
490
|
-
|
|
491
|
-
async function registerFrame(key, generation) {
|
|
492
|
-
const registered = await api(`/api/session/${state.sessionId}/render`, {
|
|
493
|
-
method: "POST",
|
|
494
|
-
body: JSON.stringify({ key, generation }),
|
|
495
|
-
});
|
|
496
|
-
if (state.key !== key || state.renderGeneration !== generation) return false;
|
|
497
|
-
state.renderId = registered.renderId;
|
|
498
|
-
state.renderSourceHash = registered.sourceHash || null;
|
|
499
|
-
state.frameCapability = registered.capability;
|
|
500
|
-
state.frameLoading = true;
|
|
501
|
-
state.pendingInitialLoad = true;
|
|
502
|
-
if (state.reloading && frame.hasAttribute("src")) {
|
|
503
|
-
const next = frame.cloneNode(false);
|
|
504
|
-
next.src = `${ARTIFACT_ORIGIN}${registered.path}`;
|
|
505
|
-
next.removeAttribute("data-sdk-ready");
|
|
506
|
-
delete next.dataset.executionMode;
|
|
507
|
-
delete next.dataset.savePolicy;
|
|
508
|
-
delete next.dataset.executionNotice;
|
|
509
|
-
if (previousFrame) frame.remove();
|
|
510
|
-
else {
|
|
511
|
-
previousFrame = frame;
|
|
512
|
-
previousFrame.id = "previousFrame";
|
|
513
|
-
previousFrame.inert = true;
|
|
514
|
-
previousFrame.setAttribute("aria-hidden", "true");
|
|
515
|
-
previousFrame.tabIndex = -1;
|
|
516
|
-
}
|
|
517
|
-
frame = next;
|
|
518
|
-
frame.dataset.replacing = "true";
|
|
519
|
-
attachFrameEvents(frame);
|
|
520
|
-
previousFrame.after(frame);
|
|
521
|
-
}
|
|
522
|
-
if (frame.src !== `${ARTIFACT_ORIGIN}${registered.path}`) frame.src = `${ARTIFACT_ORIGIN}${registered.path}`;
|
|
523
|
-
renderExecution();
|
|
524
|
-
state.frameReadyTimer = setTimeout(() => {
|
|
525
|
-
if (state.key !== key || state.renderGeneration !== generation || !state.frameLoading) return;
|
|
526
|
-
if (state.frameReadyRetries < 1) {
|
|
527
|
-
state.frameReadyRetries += 1;
|
|
528
|
-
rotateCurrentFrame();
|
|
529
|
-
return;
|
|
530
|
-
}
|
|
531
|
-
failFrame("The reviewed page did not finish loading. Fix the source or local server, then reload.");
|
|
532
|
-
}, FRAME_READY_TIMEOUT_MS);
|
|
533
|
-
return true;
|
|
534
|
-
}
|
|
535
|
-
|
|
536
|
-
function finishFrameReplacement() {
|
|
537
|
-
clearTimeout(replacementTimer);
|
|
538
|
-
replacementTimer = null;
|
|
539
|
-
previousFrame?.remove();
|
|
540
|
-
previousFrame = null;
|
|
541
|
-
delete frame.dataset.replacing;
|
|
542
|
-
renderExecution();
|
|
543
|
-
}
|
|
544
|
-
|
|
545
|
-
function failFrame(message) {
|
|
546
|
-
suspendFrame();
|
|
547
|
-
state.frameFailure = message;
|
|
548
|
-
state.pendingReload = true;
|
|
549
|
-
finishFrameReplacement();
|
|
550
|
-
$("reloadNotice").hidden = false;
|
|
551
|
-
$("reloadMessage").textContent = `${message} Reload keeps your comment drafts.`;
|
|
552
|
-
toast(message);
|
|
553
|
-
}
|
|
554
|
-
|
|
555
|
-
function rotateCurrentFrame() {
|
|
556
|
-
if (!state.key) return;
|
|
557
|
-
const key = state.key;
|
|
558
|
-
const generation = beginFrameTransition();
|
|
559
|
-
void registerFrame(key, generation).catch((err) => {
|
|
560
|
-
if (state.key !== key || state.renderGeneration !== generation) return;
|
|
561
|
-
failFrame(err.message);
|
|
562
|
-
});
|
|
563
|
-
}
|
|
564
|
-
|
|
565
|
-
/**
|
|
566
|
-
* Ask the SDK to ship anything still sitting in its debounce windows, and
|
|
567
|
-
* wait until it has. Navigating away without this drops the last moments of
|
|
568
|
-
* typing. The timeout covers a torn-down or never-booted frame.
|
|
569
|
-
*/
|
|
570
|
-
const flushRequests = new Map();
|
|
571
|
-
let flushSequence = 0;
|
|
572
|
-
let configurationWaiter = null;
|
|
573
|
-
async function flushFrame({ strict = false } = {}) {
|
|
574
|
-
const key = state.key;
|
|
575
|
-
if (!state.frameLoading && state.frameCapability) {
|
|
576
|
-
await new Promise((resolve, reject) => {
|
|
577
|
-
const requestId = `flush-${++flushSequence}`;
|
|
578
|
-
const settle = (confirmed = true) => {
|
|
579
|
-
if (!flushRequests.has(requestId)) return;
|
|
580
|
-
flushRequests.delete(requestId);
|
|
581
|
-
clearTimeout(timer);
|
|
582
|
-
if (!confirmed && strict) reject(new Error("The page did not finish saving. Wait for it to load, then retry."));
|
|
583
|
-
else resolve();
|
|
584
|
-
};
|
|
585
|
-
const timer = setTimeout(() => settle(false), strict ? 6000 : 400);
|
|
586
|
-
flushRequests.set(requestId, { settle, strict });
|
|
587
|
-
toFrame({ type: "eh:flush", requestId });
|
|
588
|
-
});
|
|
589
|
-
} else if (strict) throw new Error("The page is not ready. Wait for it to load, then retry.");
|
|
590
|
-
if (key) await settleEditPersistence(key);
|
|
591
|
-
}
|
|
592
|
-
|
|
593
|
-
function configureFrame() {
|
|
594
|
-
if (state.frameLoading || !state.frameCapability || !state.renderExecution) return Promise.resolve(false);
|
|
595
|
-
Object.assign(state.page, state.renderExecution);
|
|
596
|
-
const configuration = reviewConfiguration(state.page, state.reviewMode);
|
|
597
|
-
state.savePolicy = configuration.savePolicy;
|
|
598
|
-
return new Promise((resolve) => {
|
|
599
|
-
const settle = (applied) => {
|
|
600
|
-
if (configurationWaiter?.settle !== settle) return;
|
|
601
|
-
configurationWaiter = null;
|
|
602
|
-
resolve(applied);
|
|
603
|
-
};
|
|
604
|
-
configurationWaiter = { ...configuration, settle };
|
|
605
|
-
toFrame({ type: "eh:configureReview", ...configuration });
|
|
606
|
-
if (previousFrame) {
|
|
607
|
-
clearTimeout(replacementTimer);
|
|
608
|
-
const configuringFrame = frame;
|
|
609
|
-
replacementTimer = setTimeout(() => {
|
|
610
|
-
if (frame !== configuringFrame || !previousFrame) return;
|
|
611
|
-
failFrame("The page did not confirm its review settings. Reload before continuing.");
|
|
612
|
-
}, FRAME_READY_TIMEOUT_MS);
|
|
613
|
-
}
|
|
614
|
-
setTimeout(() => settle(false), 3000);
|
|
615
|
-
});
|
|
616
|
-
}
|
|
617
|
-
|
|
618
|
-
async function setReviewMode(nextMode) {
|
|
619
|
-
const next = normalizeReviewMode(nextMode);
|
|
620
|
-
closeModeMenu("selection");
|
|
621
|
-
if (next === state.reviewMode || state.modeApplying) return;
|
|
622
|
-
state.modeApplying = true;
|
|
623
|
-
render();
|
|
624
|
-
try {
|
|
625
|
-
if (state.reviewMode === "edit" && next === "view") {
|
|
626
|
-
await fullSaveBarrier();
|
|
627
|
-
if (state.saveConflict) {
|
|
628
|
-
toast("Resolve the save conflict before leaving Edit");
|
|
629
|
-
announce("Save conflict. Edit mode remains active.");
|
|
630
|
-
return;
|
|
631
|
-
}
|
|
632
|
-
}
|
|
633
|
-
state.reviewMode = next;
|
|
634
|
-
const applied = await configureFrame();
|
|
635
|
-
if (!applied) throw new Error("The reviewed page did not confirm the mode change");
|
|
636
|
-
diagnostic("mode-change", { mode: next, savePolicy: state.savePolicy });
|
|
637
|
-
announce(`${next === "edit" ? "Edit" : "View"} mode active`);
|
|
638
|
-
} catch (err) {
|
|
639
|
-
state.reviewMode = next === "edit" ? "view" : "edit";
|
|
640
|
-
toast(`${err.message}. Stay in ${state.reviewMode === "edit" ? "Edit" : "View"} and retry.`);
|
|
641
|
-
} finally {
|
|
642
|
-
state.modeApplying = false;
|
|
643
|
-
render();
|
|
644
|
-
}
|
|
645
|
-
}
|
|
646
|
-
|
|
647
|
-
async function loadPage(key, { reload = true } = {}) {
|
|
648
|
-
const returning = state.page;
|
|
649
|
-
advancePageEpoch(returning ? "navigation" : "reload");
|
|
650
|
-
const generation = beginFrameTransition();
|
|
651
|
-
state.frameReadyRetries = 0;
|
|
652
|
-
state.key = key;
|
|
653
|
-
const page = await api(pageUrl(key, state.sessionId));
|
|
654
|
-
if (state.key !== key || state.renderGeneration !== generation) return;
|
|
655
|
-
state.executionPreference = page.executionPreference || null;
|
|
656
|
-
reconcilePage(page, { reason: "reload", advance: false });
|
|
657
|
-
state.savePolicy = reviewConfiguration(state.page, state.reviewMode).savePolicy;
|
|
658
|
-
state.framePolicy = framePolicy(state.page, ARTIFACT_ORIGIN);
|
|
659
|
-
frame.setAttribute("sandbox", state.framePolicy.sandbox);
|
|
660
|
-
state.orphans = new Set();
|
|
661
|
-
state.compose = null;
|
|
662
|
-
setComposeLifecycle("closed", "page-change");
|
|
663
|
-
setComposePlacement("hidden");
|
|
664
|
-
state.target = null;
|
|
665
|
-
state.activeSavedCommentId = null;
|
|
666
|
-
state.activeGeometry.clear();
|
|
667
|
-
state.commentUi = createCommentUi();
|
|
668
|
-
state.sent = false;
|
|
669
|
-
state.dynamic = false;
|
|
670
|
-
state.baseHash = null;
|
|
671
|
-
state.saveConflict = false;
|
|
672
|
-
state.domDirty = false;
|
|
673
|
-
clearTimeout(retryTimer);
|
|
674
|
-
if (reload) {
|
|
675
|
-
state.reloading = true;
|
|
676
|
-
await registerFrame(key, generation);
|
|
677
|
-
}
|
|
678
|
-
render();
|
|
679
|
-
// Coming back to a dev-server page shows the app's own copy again, without
|
|
680
|
-
// the direct edits — which reads as data loss unless we say what happened.
|
|
681
|
-
const edits = state.page.edits ? state.page.edits.length : 0;
|
|
682
|
-
if (returning && state.page.feedbackOnly && edits > 0) {
|
|
683
|
-
toast(`${edits} ${edits === 1 ? "edit is" : "edits are"} queued for the agent to apply to the source`);
|
|
684
|
-
}
|
|
685
|
-
}
|
|
686
|
-
|
|
687
|
-
// -------------------------------------------------------------------- clock
|
|
688
|
-
|
|
689
|
-
function ago(ts) {
|
|
690
|
-
const secs = Math.max(0, Math.round((Date.now() - ts) / 1000));
|
|
691
|
-
if (secs < 45) return "just now";
|
|
692
|
-
const mins = Math.round(secs / 60);
|
|
693
|
-
if (mins < 60) return `${mins}m`;
|
|
694
|
-
const hours = Math.round(mins / 60);
|
|
695
|
-
if (hours < 24) return `${hours}h`;
|
|
696
|
-
return `${Math.round(hours / 24)}d`;
|
|
697
|
-
}
|
|
698
|
-
|
|
699
|
-
const clock = () => new Date().toLocaleTimeString([], { hour: "numeric", minute: "2-digit" });
|
|
700
|
-
|
|
701
|
-
// -------------------------------------------------------------------- render
|
|
702
|
-
|
|
703
|
-
function render() {
|
|
704
|
-
const page = state.page;
|
|
705
|
-
if (!page) return;
|
|
706
|
-
// Source updates cannot change the policy of a frame awaiting draft-safe reload.
|
|
707
|
-
if (state.renderExecution) Object.assign(page, state.renderExecution);
|
|
708
|
-
if (state.executionPreference) page.executionPreference = state.executionPreference;
|
|
709
|
-
renderExecution();
|
|
710
|
-
syncFrameInteraction();
|
|
711
|
-
const editWasFocused = skipEditCaptureOnce ? false : captureEditState();
|
|
712
|
-
skipEditCaptureOnce = false;
|
|
713
|
-
document.title = page.filename || 'doc-review';
|
|
714
|
-
|
|
715
|
-
const comments = newestComments(page.comments);
|
|
716
|
-
const edits = page.edits || [];
|
|
717
|
-
|
|
718
|
-
$("count").textContent = String(comments.length);
|
|
719
|
-
$("toolbarCount").textContent = String(comments.length);
|
|
720
|
-
$("empty").hidden = comments.length > 0 || !!state.compose;
|
|
721
|
-
$("modeLabel").textContent = state.reviewMode === "edit" ? "Edit" : "View";
|
|
722
|
-
replaceIcon($("modeIcon"), state.reviewMode === "edit" ? "pencil" : "eye");
|
|
723
|
-
$("modeButton").disabled = state.modeApplying || state.comparing || state.sending || !state.renderExecution;
|
|
724
|
-
for (const item of $("modeMenu").querySelectorAll("[data-mode]")) {
|
|
725
|
-
const checked = item.dataset.mode === state.reviewMode;
|
|
726
|
-
item.setAttribute("aria-checked", String(checked));
|
|
727
|
-
const check = item.querySelector(".menu-check");
|
|
728
|
-
check.textContent = "";
|
|
729
|
-
if (checked) check.append(createIcon("check"));
|
|
730
|
-
}
|
|
731
|
-
$("drawer").classList.toggle("open", state.drawerOpen);
|
|
732
|
-
$("drawer").setAttribute("aria-hidden", String(!state.drawerOpen));
|
|
733
|
-
$("commentsButton").setAttribute("aria-expanded", String(state.drawerOpen));
|
|
734
|
-
$("drawerBackdrop").hidden = !state.drawerOpen;
|
|
735
|
-
|
|
736
|
-
// --- compose
|
|
737
|
-
const composeWrap = $("compose");
|
|
738
|
-
if (state.compose) {
|
|
739
|
-
composeWrap.hidden = false;
|
|
740
|
-
$("composeKind").textContent = state.compose.kind === "element" ? "Element" : "Selection";
|
|
741
|
-
$("composeQuote").textContent = tidyMiddle(state.compose.quote, 260);
|
|
742
|
-
requestAnimationFrame(positionCompose);
|
|
743
|
-
} else {
|
|
744
|
-
composeWrap.hidden = true;
|
|
745
|
-
$("composeText").value = "";
|
|
746
|
-
$("composeError").hidden = true;
|
|
747
|
-
}
|
|
748
|
-
|
|
749
|
-
// --- comment cards
|
|
750
|
-
const list = $("cards");
|
|
751
|
-
list.textContent = "";
|
|
752
|
-
for (const comment of comments) {
|
|
753
|
-
list.append(renderCommentCard(comment, "drawer"));
|
|
754
|
-
}
|
|
755
|
-
renderAlignedCard(comments);
|
|
756
|
-
|
|
757
|
-
// --- your edits
|
|
758
|
-
const box = $("editsBox");
|
|
759
|
-
box.hidden = edits.length === 0;
|
|
760
|
-
if (edits.length) {
|
|
761
|
-
$("editCount").textContent = String(edits.length);
|
|
762
|
-
const rows = $("editList");
|
|
763
|
-
rows.textContent = "";
|
|
764
|
-
const LIMIT = 5;
|
|
765
|
-
const shown = state.editsExpanded ? edits : edits.slice(0, LIMIT);
|
|
766
|
-
for (const edit of shown) {
|
|
767
|
-
const row = document.createElement("div");
|
|
768
|
-
row.className = `edit-row${edit.kind === "deleted" ? " deleted" : ""}`;
|
|
769
|
-
const pip = document.createElement("span");
|
|
770
|
-
pip.className = "pip";
|
|
771
|
-
const label = document.createElement("span");
|
|
772
|
-
label.className = "label";
|
|
773
|
-
label.textContent = edit.label;
|
|
774
|
-
const kind = document.createElement("span");
|
|
775
|
-
kind.className = "kind";
|
|
776
|
-
kind.textContent = edit.kind;
|
|
777
|
-
row.append(pip, label, kind);
|
|
778
|
-
rows.append(row);
|
|
779
|
-
}
|
|
780
|
-
if (edits.length > LIMIT) {
|
|
781
|
-
const more = document.createElement("button");
|
|
782
|
-
more.type = "button";
|
|
783
|
-
more.className = "edit-more";
|
|
784
|
-
more.textContent = state.editsExpanded ? "Show fewer" : `${edits.length - LIMIT} more…`;
|
|
785
|
-
more.addEventListener("click", () => {
|
|
786
|
-
state.editsExpanded = !state.editsExpanded;
|
|
787
|
-
render();
|
|
788
|
-
});
|
|
789
|
-
rows.append(more);
|
|
790
|
-
}
|
|
791
|
-
renderSave();
|
|
792
|
-
}
|
|
793
|
-
|
|
794
|
-
// --- pages you left feedback on but are not looking at
|
|
795
|
-
const others = state.others || [];
|
|
796
|
-
const othersBox = $("othersBox");
|
|
797
|
-
othersBox.hidden = others.length === 0;
|
|
798
|
-
if (others.length) {
|
|
799
|
-
$("othersCount").textContent = String(others.length);
|
|
800
|
-
const list = $("othersList");
|
|
801
|
-
list.textContent = "";
|
|
802
|
-
for (const other of others) {
|
|
803
|
-
const row = document.createElement("button");
|
|
804
|
-
row.type = "button";
|
|
805
|
-
row.className = "edit-row other-row";
|
|
806
|
-
const label = document.createElement("span");
|
|
807
|
-
label.className = "label";
|
|
808
|
-
label.textContent = other.filename;
|
|
809
|
-
const count = document.createElement("span");
|
|
810
|
-
count.className = "kind";
|
|
811
|
-
count.textContent = String(other.count);
|
|
812
|
-
row.append(label, count);
|
|
813
|
-
row.addEventListener("click", async () => {
|
|
814
|
-
try {
|
|
815
|
-
if (draftCount(state)) throw new Error("Save or cancel open comment drafts before changing pages");
|
|
816
|
-
await fullSaveBarrier();
|
|
817
|
-
suspendFrame();
|
|
818
|
-
await api(`/api/session/${state.sessionId}/goto`, {
|
|
819
|
-
method: "POST",
|
|
820
|
-
body: JSON.stringify({ key: other.key }),
|
|
821
|
-
});
|
|
822
|
-
state.scroll = { x: 0, y: 0 };
|
|
823
|
-
await loadPage(other.key);
|
|
824
|
-
} catch (err) {
|
|
825
|
-
toast(`${err.message}. Stay on this page and retry.`);
|
|
826
|
-
}
|
|
827
|
-
});
|
|
828
|
-
list.append(row);
|
|
829
|
-
}
|
|
830
|
-
}
|
|
831
|
-
|
|
832
|
-
// --- send
|
|
833
|
-
const otherTotal = others.reduce((sum, o) => sum + o.count, 0);
|
|
834
|
-
const total = comments.length + edits.length + otherTotal;
|
|
835
|
-
// An overall note is sendable on its own — the server already accepts
|
|
836
|
-
// note-only batches; the button must not stay dead while one is typed.
|
|
837
|
-
const hasNote = $("note").value.trim().length > 0;
|
|
838
|
-
const send = $("send");
|
|
839
|
-
const delivered = state.agent === "working";
|
|
840
|
-
const stranded = state.agent === "stranded";
|
|
841
|
-
const busy = delivered || stranded || state.sent;
|
|
842
|
-
send.disabled = (total === 0 && !hasNote) || busy || state.sending;
|
|
843
|
-
send.textContent = state.sending ? "Sending feedback…" : delivered
|
|
844
|
-
? "Feedback delivered"
|
|
845
|
-
: stranded
|
|
846
|
-
? "Sent — agent is not listening"
|
|
847
|
-
: state.sent
|
|
848
|
-
? "Sent — waiting for agent"
|
|
849
|
-
: total
|
|
850
|
-
? `Send ${total} to agent`
|
|
851
|
-
: hasNote
|
|
852
|
-
? "Send note to agent"
|
|
853
|
-
: "Nothing to send yet";
|
|
854
|
-
// After sending, say what happens next. If nothing is polling, the loop would
|
|
855
|
-
// otherwise dead-end silently, so hand over the exact command to run.
|
|
856
|
-
$("agentLine").hidden = !delivered;
|
|
857
|
-
$("agentText").textContent = "Feedback delivered — page reloads when fixes land";
|
|
858
|
-
|
|
859
|
-
// Server-authoritative, so it survives a browser refresh.
|
|
860
|
-
$("handoff").hidden = !stranded;
|
|
861
|
-
if (stranded) $("handoffCmd").textContent = handoffPrompt(state.pollCommand || page.pollCommand);
|
|
862
|
-
const excluded = draftCount(state);
|
|
863
|
-
$("draftWarning").hidden = excluded === 0;
|
|
864
|
-
$("draftWarning").textContent = `${excluded} open ${excluded === 1 ? "draft is" : "drafts are"} not included. Save comments explicitly before sending.`;
|
|
865
|
-
restoreTransientFocus(editWasFocused);
|
|
866
|
-
}
|
|
867
|
-
|
|
868
|
-
function positionCompose() {
|
|
869
|
-
if (!state.compose || state.composeLifecycle === "closed") {
|
|
870
|
-
setComposePlacement("hidden");
|
|
871
|
-
return;
|
|
872
|
-
}
|
|
873
|
-
if (state.compose.unresolved) {
|
|
874
|
-
const surface = $("compose");
|
|
875
|
-
surface.hidden = false;
|
|
876
|
-
surface.classList.add("sheet");
|
|
877
|
-
surface.classList.remove("edge-top", "edge-bottom");
|
|
878
|
-
$("composeDirection").hidden = true;
|
|
879
|
-
setComposePlacement("sheet");
|
|
880
|
-
return;
|
|
881
|
-
}
|
|
882
|
-
const viewport = visibleViewport();
|
|
883
|
-
const frameRect = frame.getBoundingClientRect();
|
|
884
|
-
const surface = $("compose");
|
|
885
|
-
if (state.compose.relation !== "unavailable") surface.hidden = false;
|
|
886
|
-
const placement = placeContextualSurface(state.compose, {
|
|
887
|
-
frameRect,
|
|
888
|
-
viewport,
|
|
889
|
-
surfaceWidth: Math.min(340, viewport.width - 24),
|
|
890
|
-
surfaceHeight: Math.max(190, surface.offsetHeight),
|
|
891
|
-
narrow: matchMedia("(max-width: 720px)").matches,
|
|
892
|
-
toolbarHeight: document.querySelector(".toolbar").getBoundingClientRect().height,
|
|
893
|
-
});
|
|
894
|
-
surface.classList.toggle("sheet", placement.kind === "sheet");
|
|
895
|
-
surface.classList.toggle("edge-top", placement.kind === "edge-top");
|
|
896
|
-
surface.classList.toggle("edge-bottom", placement.kind === "edge-bottom");
|
|
897
|
-
setComposePlacement(placement.kind);
|
|
898
|
-
const edge = placement.kind === "edge-top" || placement.kind === "edge-bottom";
|
|
899
|
-
$("composeDirection").hidden = !edge;
|
|
900
|
-
$("composeDirectionText").textContent = placement.kind === "edge-top"
|
|
901
|
-
? "Selection is above"
|
|
902
|
-
: placement.kind === "edge-bottom"
|
|
903
|
-
? "Selection is below"
|
|
904
|
-
: "";
|
|
905
|
-
surface.hidden = placement.kind === "hidden";
|
|
906
|
-
if (placement.kind !== "sheet" && placement.kind !== "hidden") {
|
|
907
|
-
surface.style.left = `${placement.left}px`;
|
|
908
|
-
surface.style.top = `${placement.top}px`;
|
|
909
|
-
surface.style.width = `${placement.width}px`;
|
|
910
|
-
} else {
|
|
911
|
-
surface.style.left = "";
|
|
912
|
-
surface.style.top = "";
|
|
913
|
-
surface.style.width = "";
|
|
914
|
-
}
|
|
915
|
-
}
|
|
916
|
-
|
|
917
|
-
function makeAction(label, className = "card-action") {
|
|
918
|
-
const button = document.createElement("button");
|
|
919
|
-
button.type = "button";
|
|
920
|
-
button.className = className;
|
|
921
|
-
button.textContent = label;
|
|
922
|
-
return button;
|
|
923
|
-
}
|
|
924
|
-
|
|
925
|
-
function makeWho(comment, surface) {
|
|
926
|
-
const who = document.createElement("span");
|
|
927
|
-
who.className = "who";
|
|
928
|
-
who.append("You");
|
|
929
|
-
if (surface === "drawer") {
|
|
930
|
-
const sep = document.createElement("span");
|
|
931
|
-
sep.className = "sep";
|
|
932
|
-
sep.textContent = "·";
|
|
933
|
-
const when = document.createElement("span");
|
|
934
|
-
when.className = "when";
|
|
935
|
-
when.textContent = ago(comment.updatedAt || comment.createdAt);
|
|
936
|
-
who.append(sep, when);
|
|
937
|
-
}
|
|
938
|
-
if (comment.correction) {
|
|
939
|
-
const badge = document.createElement("span");
|
|
940
|
-
badge.className = "badge correction";
|
|
941
|
-
badge.textContent = "correction";
|
|
942
|
-
who.append(badge);
|
|
943
|
-
} else if (comment.updatedAt) {
|
|
944
|
-
const badge = document.createElement("span");
|
|
945
|
-
badge.className = "badge";
|
|
946
|
-
badge.textContent = "edited";
|
|
947
|
-
who.append(badge);
|
|
948
|
-
}
|
|
949
|
-
if (state.orphans.has(comment.id)) {
|
|
950
|
-
const badge = document.createElement("span");
|
|
951
|
-
badge.className = "badge";
|
|
952
|
-
badge.textContent = "orphaned";
|
|
953
|
-
who.append(badge);
|
|
954
|
-
}
|
|
955
|
-
return who;
|
|
956
|
-
}
|
|
957
|
-
|
|
958
|
-
function commentUiKind(commentId) {
|
|
959
|
-
if (state.commentUi.edit?.commentId === commentId) return "edit";
|
|
960
|
-
if (state.commentUi.confirmation?.commentId === commentId) return "confirmation";
|
|
961
|
-
if (state.commentUi.menu?.commentId === commentId) return "menu";
|
|
962
|
-
return "normal";
|
|
963
|
-
}
|
|
964
|
-
|
|
965
|
-
function renderMore(comment, surface, open, disabled = false) {
|
|
966
|
-
const triggerId = controlId(comment.id, surface, "more");
|
|
967
|
-
const menuId = controlId(comment.id, surface, "menu");
|
|
968
|
-
const trigger = makeAction("More", "card-action more-trigger");
|
|
969
|
-
trigger.id = triggerId;
|
|
970
|
-
trigger.setAttribute("aria-haspopup", "menu");
|
|
971
|
-
trigger.setAttribute("aria-expanded", String(open));
|
|
972
|
-
trigger.setAttribute("aria-controls", menuId);
|
|
973
|
-
trigger.disabled = disabled;
|
|
974
|
-
trigger.prepend(createIcon("moreHorizontal", { size: 14 }));
|
|
975
|
-
trigger.addEventListener("click", (event) => {
|
|
976
|
-
event.stopPropagation();
|
|
977
|
-
if (open) {
|
|
978
|
-
closeCommentMenu({ restoreFocus: true });
|
|
979
|
-
} else {
|
|
980
|
-
if (!ownMenu(state.commentUi, comment.id, surface, triggerId)) {
|
|
981
|
-
focusCurrentCommentEdit();
|
|
982
|
-
return;
|
|
983
|
-
}
|
|
984
|
-
toFrame({ type: "eh:modeMenuState", open: true });
|
|
985
|
-
requestControlFocus(controlId(comment.id, surface, "delete-item"));
|
|
986
|
-
render();
|
|
987
|
-
}
|
|
988
|
-
});
|
|
989
|
-
return trigger;
|
|
990
|
-
}
|
|
991
|
-
|
|
992
|
-
function renderCommentCard(comment, surface) {
|
|
993
|
-
const card = document.createElement(surface === "aligned" ? "article" : "div");
|
|
994
|
-
if (surface === "drawer") {
|
|
995
|
-
card.className = `comment${state.activeSavedCommentId === comment.id ? " active" : ""}`;
|
|
996
|
-
}
|
|
997
|
-
card.dataset.id = comment.id;
|
|
998
|
-
card.dataset.surface = surface;
|
|
999
|
-
|
|
1000
|
-
const kind = commentUiKind(comment.id);
|
|
1001
|
-
const editOwnedElsewhere = !!(
|
|
1002
|
-
state.commentUi.edit &&
|
|
1003
|
-
state.commentUi.edit.commentId !== comment.id
|
|
1004
|
-
);
|
|
1005
|
-
const head = document.createElement("div");
|
|
1006
|
-
head.className = "comment-head";
|
|
1007
|
-
head.append(makeWho(comment, surface));
|
|
1008
|
-
|
|
1009
|
-
if (kind === "normal") {
|
|
1010
|
-
if (surface === "drawer") {
|
|
1011
|
-
const jump = makeAction("Jump to");
|
|
1012
|
-
jump.disabled = editOwnedElsewhere;
|
|
1013
|
-
jump.addEventListener("click", (event) => {
|
|
1014
|
-
event.stopPropagation();
|
|
1015
|
-
setActive(comment.id, true);
|
|
1016
|
-
});
|
|
1017
|
-
head.append(jump);
|
|
1018
|
-
}
|
|
1019
|
-
const edit = makeAction("Edit");
|
|
1020
|
-
edit.id = controlId(comment.id, surface, "edit");
|
|
1021
|
-
edit.setAttribute("aria-label", "Edit comment");
|
|
1022
|
-
edit.disabled = editOwnedElsewhere;
|
|
1023
|
-
edit.addEventListener("click", (event) => {
|
|
1024
|
-
event.stopPropagation();
|
|
1025
|
-
startCommentEdit(comment, surface);
|
|
1026
|
-
});
|
|
1027
|
-
head.append(edit);
|
|
1028
|
-
if (surface === "aligned") {
|
|
1029
|
-
const close = makeAction("Close");
|
|
1030
|
-
close.setAttribute("aria-label", "Close comment card");
|
|
1031
|
-
close.addEventListener("click", (event) => {
|
|
1032
|
-
event.stopPropagation();
|
|
1033
|
-
dismissActiveComment("close", { restoreFocus: true });
|
|
1034
|
-
});
|
|
1035
|
-
head.append(close);
|
|
1036
|
-
}
|
|
1037
|
-
head.append(renderMore(comment, surface, false, editOwnedElsewhere));
|
|
1038
|
-
} else if (kind === "menu") {
|
|
1039
|
-
head.append(renderMore(comment, surface, true));
|
|
1040
|
-
}
|
|
1041
|
-
|
|
1042
|
-
const quote = document.createElement("p");
|
|
1043
|
-
quote.className = "quote";
|
|
1044
|
-
quote.textContent = tidyMiddle(comment.quote, 140);
|
|
1045
|
-
card.append(head, quote);
|
|
1046
|
-
|
|
1047
|
-
if (kind === "edit") {
|
|
1048
|
-
const edit = state.commentUi.edit;
|
|
1049
|
-
const input = document.createElement("textarea");
|
|
1050
|
-
input.className = "body-edit";
|
|
1051
|
-
input.rows = 3;
|
|
1052
|
-
input.value = edit.draft;
|
|
1053
|
-
input.readOnly = edit.status === "saving";
|
|
1054
|
-
input.dataset.commentEdit = comment.id;
|
|
1055
|
-
input.setAttribute("aria-label", "Edit comment text");
|
|
1056
|
-
input.addEventListener("input", () => {
|
|
1057
|
-
edit.draft = input.value;
|
|
1058
|
-
edit.selectionStart = input.selectionStart;
|
|
1059
|
-
edit.selectionEnd = input.selectionEnd;
|
|
1060
|
-
edit.validation = "";
|
|
1061
|
-
});
|
|
1062
|
-
const rememberSelection = () => {
|
|
1063
|
-
edit.selectionStart = input.selectionStart;
|
|
1064
|
-
edit.selectionEnd = input.selectionEnd;
|
|
1065
|
-
};
|
|
1066
|
-
input.addEventListener("select", rememberSelection);
|
|
1067
|
-
input.addEventListener("keyup", rememberSelection);
|
|
1068
|
-
input.addEventListener("click", (event) => {
|
|
1069
|
-
event.stopPropagation();
|
|
1070
|
-
rememberSelection();
|
|
1071
|
-
});
|
|
1072
|
-
input.addEventListener("compositionstart", () => {
|
|
1073
|
-
edit.composing = true;
|
|
1074
|
-
});
|
|
1075
|
-
input.addEventListener("compositionend", () => {
|
|
1076
|
-
edit.composing = false;
|
|
1077
|
-
rememberSelection();
|
|
1078
|
-
});
|
|
1079
|
-
input.addEventListener("keydown", (event) => {
|
|
1080
|
-
event.stopPropagation();
|
|
1081
|
-
rememberSelection();
|
|
1082
|
-
if (event.key === "Enter" && !event.shiftKey) {
|
|
1083
|
-
if (edit.composing || event.isComposing) return;
|
|
1084
|
-
event.preventDefault();
|
|
1085
|
-
void saveCommentEdit();
|
|
1086
|
-
} else if (event.key === "Escape") {
|
|
1087
|
-
event.preventDefault();
|
|
1088
|
-
cancelCommentEdit();
|
|
1089
|
-
}
|
|
1090
|
-
});
|
|
1091
|
-
card.append(input);
|
|
1092
|
-
|
|
1093
|
-
const helper = document.createElement("p");
|
|
1094
|
-
helper.className = "edit-help";
|
|
1095
|
-
helper.textContent = "Enter to save · Shift+Enter for new line";
|
|
1096
|
-
card.append(helper);
|
|
1097
|
-
if (edit.validation) {
|
|
1098
|
-
const validation = document.createElement("p");
|
|
1099
|
-
validation.className = "comment-validation";
|
|
1100
|
-
validation.setAttribute("role", "alert");
|
|
1101
|
-
validation.textContent = edit.validation;
|
|
1102
|
-
card.append(validation);
|
|
1103
|
-
}
|
|
1104
|
-
const actions = document.createElement("div");
|
|
1105
|
-
actions.className = "comment-edit-actions";
|
|
1106
|
-
const save = makeAction(edit.status === "saving" ? "Saving…" : "Save", "btn-primary");
|
|
1107
|
-
save.disabled = edit.status === "saving";
|
|
1108
|
-
save.addEventListener("click", (event) => {
|
|
1109
|
-
event.stopPropagation();
|
|
1110
|
-
void saveCommentEdit();
|
|
1111
|
-
});
|
|
1112
|
-
const cancel = makeAction("Cancel", "btn-ghost");
|
|
1113
|
-
cancel.disabled = edit.status === "saving";
|
|
1114
|
-
cancel.addEventListener("click", (event) => {
|
|
1115
|
-
event.stopPropagation();
|
|
1116
|
-
cancelCommentEdit();
|
|
1117
|
-
});
|
|
1118
|
-
actions.append(save, cancel);
|
|
1119
|
-
card.append(actions);
|
|
1120
|
-
} else if (kind === "confirmation") {
|
|
1121
|
-
const confirmation = state.commentUi.confirmation;
|
|
1122
|
-
const prompt = document.createElement("p");
|
|
1123
|
-
prompt.className = "delete-prompt";
|
|
1124
|
-
prompt.textContent = "Delete this comment?";
|
|
1125
|
-
const actions = document.createElement("div");
|
|
1126
|
-
actions.className = "delete-actions";
|
|
1127
|
-
const cancel = makeAction("Cancel", "btn-ghost");
|
|
1128
|
-
cancel.disabled = confirmation.status === "deleting";
|
|
1129
|
-
cancel.addEventListener("click", (event) => {
|
|
1130
|
-
event.stopPropagation();
|
|
1131
|
-
cancelDeleteConfirmation();
|
|
1132
|
-
});
|
|
1133
|
-
const remove = makeAction(confirmation.status === "deleting" ? "Deleting…" : "Delete", "btn-danger");
|
|
1134
|
-
remove.id = controlId(comment.id, surface, "confirm-delete");
|
|
1135
|
-
remove.disabled = confirmation.status === "deleting";
|
|
1136
|
-
remove.addEventListener("click", (event) => {
|
|
1137
|
-
event.stopPropagation();
|
|
1138
|
-
void deleteComment(comment.id);
|
|
1139
|
-
});
|
|
1140
|
-
actions.append(cancel, remove);
|
|
1141
|
-
card.append(prompt, actions);
|
|
1142
|
-
} else {
|
|
1143
|
-
const body = document.createElement("p");
|
|
1144
|
-
body.className = "body";
|
|
1145
|
-
body.textContent = comment.feedback;
|
|
1146
|
-
body.title = "Click to edit";
|
|
1147
|
-
if (!editOwnedElsewhere) {
|
|
1148
|
-
body.addEventListener("click", (event) => {
|
|
1149
|
-
event.stopPropagation();
|
|
1150
|
-
startCommentEdit(comment, surface);
|
|
1151
|
-
});
|
|
1152
|
-
}
|
|
1153
|
-
card.append(body);
|
|
1154
|
-
}
|
|
1155
|
-
|
|
1156
|
-
if (kind === "menu") {
|
|
1157
|
-
const menu = document.createElement("div");
|
|
1158
|
-
menu.id = controlId(comment.id, surface, "menu");
|
|
1159
|
-
menu.className = "comment-menu";
|
|
1160
|
-
menu.setAttribute("role", "menu");
|
|
1161
|
-
menu.setAttribute("aria-labelledby", controlId(comment.id, surface, "more"));
|
|
1162
|
-
const remove = makeAction("Delete", "comment-menu-item");
|
|
1163
|
-
remove.id = controlId(comment.id, surface, "delete-item");
|
|
1164
|
-
remove.setAttribute("role", "menuitem");
|
|
1165
|
-
remove.prepend(createIcon("trash", { size: 14 }));
|
|
1166
|
-
remove.addEventListener("click", (event) => {
|
|
1167
|
-
event.stopPropagation();
|
|
1168
|
-
toFrame({ type: "eh:modeMenuState", open: false });
|
|
1169
|
-
if (!ownConfirmation(state.commentUi, comment.id, surface)) {
|
|
1170
|
-
focusCurrentCommentEdit();
|
|
1171
|
-
return;
|
|
1172
|
-
}
|
|
1173
|
-
requestControlFocus(controlId(comment.id, surface, "confirm-delete"));
|
|
1174
|
-
render();
|
|
1175
|
-
});
|
|
1176
|
-
menu.append(remove);
|
|
1177
|
-
card.append(menu);
|
|
1178
|
-
}
|
|
1179
|
-
|
|
1180
|
-
if (surface === "drawer") {
|
|
1181
|
-
card.addEventListener("click", () => {
|
|
1182
|
-
if (kind === "normal" && !editOwnedElsewhere) setActive(comment.id, false);
|
|
1183
|
-
});
|
|
1184
|
-
}
|
|
1185
|
-
return card;
|
|
1186
|
-
}
|
|
1187
|
-
|
|
1188
|
-
function positionAlignedCard() {
|
|
1189
|
-
const host = $("alignedCard");
|
|
1190
|
-
const comment = commentById(state.activeSavedCommentId);
|
|
1191
|
-
const geometry = comment ? state.activeGeometry.get(comment.id) : null;
|
|
1192
|
-
if (!comment || state.drawerOpen || matchMedia("(max-width: 720px)").matches || !geometry?.visible) {
|
|
1193
|
-
host.hidden = true;
|
|
1194
|
-
return false;
|
|
1195
|
-
}
|
|
1196
|
-
const viewport = visibleViewport();
|
|
1197
|
-
const position = alignedCardPosition(geometry.rects, {
|
|
1198
|
-
frameRect: frame.getBoundingClientRect(),
|
|
1199
|
-
viewport,
|
|
1200
|
-
});
|
|
1201
|
-
if (!position) {
|
|
1202
|
-
host.hidden = true;
|
|
1203
|
-
return false;
|
|
1204
|
-
}
|
|
1205
|
-
host.style.left = `${position.left}px`;
|
|
1206
|
-
host.style.top = `${position.top}px`;
|
|
1207
|
-
host.hidden = false;
|
|
1208
|
-
return true;
|
|
1209
|
-
}
|
|
1210
|
-
|
|
1211
|
-
function renderAlignedCard(comments) {
|
|
1212
|
-
const host = $("alignedCard");
|
|
1213
|
-
host.textContent = "";
|
|
1214
|
-
const comment = comments.find((item) => item.id === state.activeSavedCommentId);
|
|
1215
|
-
if (!comment) {
|
|
1216
|
-
host.hidden = true;
|
|
1217
|
-
return;
|
|
1218
|
-
}
|
|
1219
|
-
const card = renderCommentCard(comment, "aligned");
|
|
1220
|
-
while (card.firstChild) host.append(card.firstChild);
|
|
1221
|
-
positionAlignedCard();
|
|
1222
|
-
}
|
|
1223
|
-
|
|
1224
|
-
async function deleteComment(id) {
|
|
1225
|
-
if (deleteFlights.has(id)) return deleteFlights.get(id);
|
|
1226
|
-
const confirmation = state.commentUi.confirmation;
|
|
1227
|
-
if (!confirmation || confirmation.commentId !== id || confirmation.status === "deleting") return false;
|
|
1228
|
-
confirmation.status = "deleting";
|
|
1229
|
-
render();
|
|
1230
|
-
const startEpoch = state.pageEpoch;
|
|
1231
|
-
const flight = (async () => {
|
|
1232
|
-
try {
|
|
1233
|
-
const result = await api(`/api/page/${state.key}/comment/${id}`, { method: "DELETE" });
|
|
1234
|
-
if (!mutationIsCurrent(startEpoch, state.pageEpoch, state.page?.comments, id)) return false;
|
|
1235
|
-
reconcilePage(result.page, { reason: "mutation" });
|
|
1236
|
-
toFrame({ type: "eh:remove", id });
|
|
1237
|
-
state.activeGeometry.delete(id);
|
|
1238
|
-
if (state.activeSavedCommentId === id) state.activeSavedCommentId = null;
|
|
1239
|
-
render();
|
|
1240
|
-
return true;
|
|
1241
|
-
} catch (err) {
|
|
1242
|
-
if (mutationIsCurrent(startEpoch, state.pageEpoch, state.page?.comments, id)) {
|
|
1243
|
-
if (state.commentUi.confirmation?.commentId === id) state.commentUi.confirmation.status = "idle";
|
|
1244
|
-
toast(err.message);
|
|
1245
|
-
render();
|
|
1246
|
-
}
|
|
1247
|
-
return false;
|
|
1248
|
-
}
|
|
1249
|
-
})().finally(() => {
|
|
1250
|
-
if (deleteFlights.get(id) === flight) deleteFlights.delete(id);
|
|
1251
|
-
});
|
|
1252
|
-
deleteFlights.set(id, flight);
|
|
1253
|
-
return flight;
|
|
1254
|
-
}
|
|
1255
|
-
|
|
1256
|
-
function cancelDeleteConfirmation() {
|
|
1257
|
-
const confirmation = state.commentUi.confirmation;
|
|
1258
|
-
if (!confirmation || confirmation.status === "deleting") return false;
|
|
1259
|
-
const triggerId = controlId(confirmation.commentId, confirmation.surface, "more");
|
|
1260
|
-
clearOwned(state.commentUi, "confirmation");
|
|
1261
|
-
requestControlFocus(triggerId);
|
|
1262
|
-
render();
|
|
1263
|
-
return true;
|
|
1264
|
-
}
|
|
1265
|
-
|
|
1266
|
-
function startCommentEdit(comment, surface) {
|
|
1267
|
-
if (!ownEdit(state.commentUi, comment, surface)) {
|
|
1268
|
-
focusCurrentCommentEdit();
|
|
1269
|
-
return false;
|
|
1270
|
-
}
|
|
1271
|
-
editFocusRequested = true;
|
|
1272
|
-
render();
|
|
1273
|
-
return true;
|
|
1274
|
-
}
|
|
1275
|
-
|
|
1276
|
-
function focusCurrentCommentEdit() {
|
|
1277
|
-
editFocusRequested = true;
|
|
1278
|
-
announce("Save or cancel the current comment edit first");
|
|
1279
|
-
requestAnimationFrame(() => {
|
|
1280
|
-
const edit = state.commentUi.edit;
|
|
1281
|
-
editTextarea(edit?.commentId)?.focus();
|
|
1282
|
-
});
|
|
1283
|
-
}
|
|
1284
|
-
|
|
1285
|
-
function cancelCommentEdit() {
|
|
1286
|
-
const edit = state.commentUi.edit;
|
|
1287
|
-
if (!edit || edit.status === "saving") return false;
|
|
1288
|
-
const surface = state.drawerOpen ? "drawer" : edit.originSurface;
|
|
1289
|
-
const focusId = controlId(edit.commentId, surface, "edit");
|
|
1290
|
-
clearOwned(state.commentUi, "edit");
|
|
1291
|
-
requestControlFocus(focusId);
|
|
1292
|
-
render();
|
|
1293
|
-
return true;
|
|
1294
|
-
}
|
|
1295
|
-
|
|
1296
|
-
function saveCommentEdit() {
|
|
1297
|
-
const edit = state.commentUi.edit;
|
|
1298
|
-
if (!edit || edit.status === "saving") return edit ? patchFlights.get(edit.commentId) : false;
|
|
1299
|
-
captureEditState();
|
|
1300
|
-
const feedback = edit.draft.trim();
|
|
1301
|
-
if (!feedback) {
|
|
1302
|
-
edit.validation = "Comment text is required.";
|
|
1303
|
-
editFocusRequested = true;
|
|
1304
|
-
render();
|
|
1305
|
-
return false;
|
|
1306
|
-
}
|
|
1307
|
-
if (feedback === edit.original) {
|
|
1308
|
-
clearOwned(state.commentUi, "edit");
|
|
1309
|
-
requestControlFocus(controlId(edit.commentId, state.drawerOpen ? "drawer" : edit.originSurface, "edit"));
|
|
1310
|
-
render();
|
|
1311
|
-
return true;
|
|
1312
|
-
}
|
|
1313
|
-
if (patchFlights.has(edit.commentId)) return patchFlights.get(edit.commentId);
|
|
1314
|
-
edit.status = "saving";
|
|
1315
|
-
edit.validation = "";
|
|
1316
|
-
const id = edit.commentId;
|
|
1317
|
-
const startEpoch = state.pageEpoch;
|
|
1318
|
-
editFocusRequested = true;
|
|
1319
|
-
render();
|
|
1320
|
-
const flight = executeCommentPatch({ ...edit, draft: feedback }, startEpoch).finally(() => {
|
|
1321
|
-
if (patchFlights.get(id) === flight) patchFlights.delete(id);
|
|
1322
|
-
});
|
|
1323
|
-
patchFlights.set(id, flight);
|
|
1324
|
-
return flight;
|
|
1325
|
-
}
|
|
1326
|
-
|
|
1327
|
-
async function executeCommentPatch(edit, startEpoch) {
|
|
1328
|
-
const id = edit.commentId;
|
|
1329
|
-
try {
|
|
1330
|
-
const commentIndex = state.page.comments.findIndex((item) => item.id === id);
|
|
1331
|
-
const previousGeometry = state.activeGeometry.get(id);
|
|
1332
|
-
const wasOrphaned = state.orphans.has(id);
|
|
1333
|
-
const remainsActive = state.activeSavedCommentId === id;
|
|
1334
|
-
const result = await api(`/api/page/${state.key}/comment/${id}`, {
|
|
1335
|
-
method: "PATCH",
|
|
1336
|
-
body: JSON.stringify({ feedback: edit.draft }),
|
|
1337
|
-
});
|
|
1338
|
-
if (!mutationIsCurrent(startEpoch, state.pageEpoch, state.page?.comments, id)) return false;
|
|
1339
|
-
const replacement = commentIndex >= 0 ? result.page.comments[commentIndex] : null;
|
|
1340
|
-
if (replacement && replacement.id !== id) {
|
|
1341
|
-
migrateCommentUi(state.commentUi, id, replacement.id);
|
|
1342
|
-
if (remainsActive && previousGeometry) state.activeGeometry.set(replacement.id, previousGeometry);
|
|
1343
|
-
if (remainsActive) state.activeSavedCommentId = replacement.id;
|
|
1344
|
-
if (wasOrphaned) state.orphans.add(replacement.id);
|
|
1345
|
-
}
|
|
1346
|
-
reconcilePage(result.page, { reason: "mutation" });
|
|
1347
|
-
clearOwned(state.commentUi, "edit");
|
|
1348
|
-
if (result.delivery === "updated-pending") {
|
|
1349
|
-
toast("Updated the feedback waiting for your agent");
|
|
1350
|
-
} else if (result.delivery === "correction") {
|
|
1351
|
-
state.sent = false;
|
|
1352
|
-
toFrame({ type: "eh:remove", id });
|
|
1353
|
-
toFrame({ type: "eh:anchors", comments: state.page.comments });
|
|
1354
|
-
if (remainsActive && replacement) toFrame({ type: "eh:activate", id: replacement.id, scroll: false });
|
|
1355
|
-
toast("Saved as a correction — send it after the current batch is acknowledged");
|
|
1356
|
-
} else {
|
|
1357
|
-
state.sent = false;
|
|
1358
|
-
}
|
|
1359
|
-
render();
|
|
1360
|
-
return true;
|
|
1361
|
-
} catch (err) {
|
|
1362
|
-
if (mutationIsCurrent(startEpoch, state.pageEpoch, state.page?.comments, id)) {
|
|
1363
|
-
const current = state.commentUi.edit;
|
|
1364
|
-
if (current?.commentId === id) {
|
|
1365
|
-
current.status = "idle";
|
|
1366
|
-
current.validation = "";
|
|
1367
|
-
editFocusRequested = true;
|
|
1368
|
-
}
|
|
1369
|
-
toast(err.message);
|
|
1370
|
-
render();
|
|
1371
|
-
}
|
|
1372
|
-
return false;
|
|
1373
|
-
}
|
|
1374
|
-
}
|
|
1375
|
-
|
|
1376
|
-
function renderSave() {
|
|
1377
|
-
const line = $("saveLine");
|
|
1378
|
-
if (state.saveConflict) {
|
|
1379
|
-
line.className = "save-line failed";
|
|
1380
|
-
$("saveText").textContent = "Source changed — reload latest before saving. Your page edits remain in this tab.";
|
|
1381
|
-
return;
|
|
1382
|
-
}
|
|
1383
|
-
if (state.page && state.page.kind === "url") {
|
|
1384
|
-
line.className = "save-line dynamic";
|
|
1385
|
-
$("saveText").textContent = "Localhost page — your direct edits go to the agent for source updates";
|
|
1386
|
-
return;
|
|
1387
|
-
}
|
|
1388
|
-
if (state.page && state.page.markdown) {
|
|
1389
|
-
line.className = "save-line dynamic";
|
|
1390
|
-
$("saveText").textContent = "Markdown source — edits go to the agent as feedback";
|
|
1391
|
-
return;
|
|
1392
|
-
}
|
|
1393
|
-
if (state.dynamic) {
|
|
1394
|
-
// The page's own scripts render it, so writing the live DOM back would
|
|
1395
|
-
// corrupt the file. Edits still reach the agent as feedback.
|
|
1396
|
-
line.className = "save-line dynamic";
|
|
1397
|
-
$("saveText").textContent = "Live page — edits go to the agent, the file is left alone";
|
|
1398
|
-
return;
|
|
1399
|
-
}
|
|
1400
|
-
line.className = `save-line ${state.save === "saving" ? "saving" : state.save === "failed" ? "failed" : ""}`;
|
|
1401
|
-
const name = state.page ? state.page.filename : "";
|
|
1402
|
-
if (state.save === "saving") $("saveText").textContent = `Saving to ${name}…`;
|
|
1403
|
-
else if (state.save === "failed") $("saveText").textContent = "Couldn't save — retry before sending";
|
|
1404
|
-
else $("saveText").textContent = state.savedAt ? `Saved to ${name} · ${state.savedAt}` : `Saved to ${name}`;
|
|
1405
|
-
}
|
|
1406
|
-
|
|
1407
|
-
function toast(message) {
|
|
1408
|
-
const el = document.createElement("div");
|
|
1409
|
-
el.className = "toast";
|
|
1410
|
-
el.textContent = message;
|
|
1411
|
-
document.body.append(el);
|
|
1412
|
-
setTimeout(() => el.remove(), 3200);
|
|
1413
|
-
}
|
|
1414
|
-
|
|
1415
|
-
function setActive(id, scroll) {
|
|
1416
|
-
state.activeSavedCommentId = id;
|
|
1417
|
-
toFrame({ type: "eh:activate", id, scroll: !!scroll });
|
|
1418
|
-
render();
|
|
1419
|
-
}
|
|
1420
|
-
|
|
1421
|
-
function dismissActiveComment(reason, { restoreFocus = false } = {}) {
|
|
1422
|
-
const id = state.activeSavedCommentId;
|
|
1423
|
-
if (!id) return false;
|
|
1424
|
-
state.activeSavedCommentId = null;
|
|
1425
|
-
state.activeGeometry.delete(id);
|
|
1426
|
-
toFrame({ type: "eh:deactivateComment" });
|
|
1427
|
-
render();
|
|
1428
|
-
announce("Comment card closed");
|
|
1429
|
-
diagnostic("saved-card-deactivated", { reason });
|
|
1430
|
-
if (restoreFocus) frame.focus();
|
|
1431
|
-
return true;
|
|
1432
|
-
}
|
|
1433
|
-
|
|
1434
|
-
// ------------------------------------------------------------------ compose
|
|
1435
|
-
|
|
1436
|
-
async function openCompose(detail) {
|
|
1437
|
-
if (state.compose && state.compose.generation === detail.generation) {
|
|
1438
|
-
if (state.composeLifecycle === "closed") setComposeLifecycle("open", "accepted");
|
|
1439
|
-
$("composeText").focus();
|
|
1440
|
-
return true;
|
|
1441
|
-
}
|
|
1442
|
-
if (state.compose && $("composeText").value.trim()) {
|
|
1443
|
-
const submitted = await commitCompose();
|
|
1444
|
-
if (!submitted) {
|
|
1445
|
-
diagnostic("comment-retarget-blocked", { reason: "submit-failed" });
|
|
1446
|
-
return false;
|
|
1447
|
-
}
|
|
1448
|
-
} else if (state.compose) {
|
|
1449
|
-
cancelCompose({ restoreFocus: false, preserveRetarget: true });
|
|
1450
|
-
}
|
|
1451
|
-
dismissActiveComment("composer-open");
|
|
1452
|
-
state.compose = detail;
|
|
1453
|
-
setComposeLifecycle("open", "accepted");
|
|
1454
|
-
$("composeText").value = "";
|
|
1455
|
-
$("composeError").hidden = true;
|
|
1456
|
-
$("composeAddLabel").textContent = "Comment";
|
|
1457
|
-
render();
|
|
1458
|
-
requestAnimationFrame(() => {
|
|
1459
|
-
positionCompose();
|
|
1460
|
-
$("composeText").focus();
|
|
1461
|
-
});
|
|
1462
|
-
announce(`Comment dialog opened for ${detail.kind === "element" ? "element" : "selected text"}`);
|
|
1463
|
-
return true;
|
|
1464
|
-
}
|
|
1465
|
-
|
|
1466
|
-
function cancelCompose({ restoreFocus = true, preserveRetarget = false } = {}) {
|
|
1467
|
-
if (!state.compose || state.composeLifecycle === "submitting") return false;
|
|
1468
|
-
const generation = state.compose.generation;
|
|
1469
|
-
const discardThroughGeneration = Math.max(
|
|
1470
|
-
generation,
|
|
1471
|
-
Number(state.compose.rejectedTargetGeneration) || generation
|
|
1472
|
-
);
|
|
1473
|
-
state.compose = null;
|
|
1474
|
-
setComposeLifecycle("closed", "cancel");
|
|
1475
|
-
setComposePlacement("hidden");
|
|
1476
|
-
toFrame({
|
|
1477
|
-
type: "eh:cancel",
|
|
1478
|
-
targetGeneration: generation,
|
|
1479
|
-
discardThroughGeneration,
|
|
1480
|
-
restoreFocus,
|
|
1481
|
-
preserveRetarget,
|
|
1482
|
-
});
|
|
1483
|
-
render();
|
|
1484
|
-
return true;
|
|
1485
|
-
}
|
|
1486
|
-
|
|
1487
|
-
let composeSubmitPromise = null;
|
|
1488
|
-
function commitCompose() {
|
|
1489
|
-
if (composeSubmitPromise) {
|
|
1490
|
-
diagnostic("comment-submit-suppressed", { reason: "in-flight" });
|
|
1491
|
-
return composeSubmitPromise;
|
|
1492
|
-
}
|
|
1493
|
-
composeSubmitPromise = executeComposeSubmit().finally(() => {
|
|
1494
|
-
composeSubmitPromise = null;
|
|
1495
|
-
});
|
|
1496
|
-
return composeSubmitPromise;
|
|
1497
|
-
}
|
|
1498
|
-
|
|
1499
|
-
async function executeComposeSubmit() {
|
|
1500
|
-
const compose = state.compose;
|
|
1501
|
-
const feedback = $("composeText").value.trim();
|
|
1502
|
-
if (!compose || !feedback) return false;
|
|
1503
|
-
const button = $("composeAdd");
|
|
1504
|
-
button.disabled = true;
|
|
1505
|
-
setComposeLifecycle("submitting", "submit");
|
|
1506
|
-
diagnostic("comment-submit-executed");
|
|
1507
|
-
$("composeError").hidden = true;
|
|
1508
|
-
try {
|
|
1509
|
-
const result = await api(`/api/page/${state.key}/comment`, {
|
|
1510
|
-
method: "POST",
|
|
1511
|
-
body: JSON.stringify({ kind: compose.kind, quote: compose.quote, anchor: compose.unresolved ? null : compose.anchor, feedback }),
|
|
1512
|
-
});
|
|
1513
|
-
toFrame({
|
|
1514
|
-
type: "eh:commit",
|
|
1515
|
-
id: result.comment.id,
|
|
1516
|
-
targetGeneration: compose.generation,
|
|
1517
|
-
restoreFocus: true,
|
|
1518
|
-
});
|
|
1519
|
-
state.compose = null;
|
|
1520
|
-
setComposeLifecycle("closed", "submitted");
|
|
1521
|
-
setComposePlacement("hidden");
|
|
1522
|
-
state.page = result.page;
|
|
1523
|
-
state.sent = false;
|
|
1524
|
-
render();
|
|
1525
|
-
announce("Comment added");
|
|
1526
|
-
return true;
|
|
1527
|
-
} catch (err) {
|
|
1528
|
-
$("composeError").textContent = `${err.message}. Retry or cancel.`;
|
|
1529
|
-
$("composeError").hidden = false;
|
|
1530
|
-
$("composeAddLabel").textContent = "Retry";
|
|
1531
|
-
diagnostic("comment-request-failure", { status: err.status || 0 });
|
|
1532
|
-
announce("Comment could not be saved. Your draft is still here.");
|
|
1533
|
-
setComposeLifecycle("open", "submit-failed");
|
|
1534
|
-
$("compose").classList.remove("pass-through");
|
|
1535
|
-
requestAnimationFrame(() => $("composeText").focus());
|
|
1536
|
-
return false;
|
|
1537
|
-
} finally {
|
|
1538
|
-
button.disabled = false;
|
|
1539
|
-
}
|
|
1540
|
-
}
|
|
1541
|
-
|
|
1542
|
-
// ------------------------------------------------------------ review history
|
|
1543
|
-
|
|
1544
|
-
const currentRender = () => ({
|
|
1545
|
-
key: state.key, renderId: state.renderId, generation: state.renderGeneration,
|
|
1546
|
-
loading: state.frameLoading,
|
|
1547
|
-
});
|
|
1548
|
-
const captures = createCaptureRequests({ send: toFrame, current: currentRender });
|
|
1549
|
-
const comparisonView = createComparisonView($("changeDetail"));
|
|
1550
|
-
const historyUrl = (suffix = "") => `/api/session/${state.sessionId}/history${suffix}`;
|
|
1551
|
-
const historyController = createHistoryController({
|
|
1552
|
-
request: (suffix) => api(historyUrl(suffix)), changed: renderHistory, refreshed: scheduleResultCapture,
|
|
1553
|
-
});
|
|
1554
|
-
const history = historyController.state;
|
|
1555
|
-
let manualCaptureController = null;
|
|
1556
|
-
const captureCoordinator = createCaptureCoordinator({
|
|
1557
|
-
ready: () => !state.ended && !state.frameLoading && !state.pendingReload && !state.domDirty &&
|
|
1558
|
-
!state.saveConflict && !state.sending && !history.captureBusy && !history.finalizing &&
|
|
1559
|
-
(state.page?.kind === "url" || !!state.baseHash),
|
|
1560
|
-
candidates: () => [...history.rounds].reverse().flatMap((round) => {
|
|
1561
|
-
const target = pendingCaptureTarget(round, state.key, state.frameReadyAt);
|
|
1562
|
-
return target ? [{ id: `${roundId(round)}:${state.renderId}:${state.renderGeneration}`, round, target }] : [];
|
|
1563
|
-
}),
|
|
1564
|
-
capture: ({ round, target }, signal) => captureResult(round, target, true, signal),
|
|
1565
|
-
});
|
|
1566
|
-
|
|
1567
|
-
async function fullSaveBarrier() {
|
|
1568
|
-
const identity = currentRender();
|
|
1569
|
-
await flushFrame({ strict: true });
|
|
1570
|
-
await activeSavePromise;
|
|
1571
|
-
if (state.save === "failed" && !state.saveConflict && lastSave?.key === state.key && lastSave.generation === state.renderGeneration) {
|
|
1572
|
-
await saveNow(lastSave.html);
|
|
1573
|
-
}
|
|
1574
|
-
await settleEditPersistence(identity.key);
|
|
1575
|
-
applyCleanObservation();
|
|
1576
|
-
if (!sameRender(identity, currentRender())) throw new Error("The page changed while saving. Retry on the latest page.");
|
|
1577
|
-
if (state.saveConflict) throw new Error("Resolve the save conflict: the source changed outside this review. Reload latest; your comment drafts will be kept.");
|
|
1578
|
-
if (state.save === "failed" || (state.savePolicy === "writable" && !state.dynamic && state.domDirty)) {
|
|
1579
|
-
throw new Error("Your page edits have not finished saving. They have not been sent.");
|
|
1580
|
-
}
|
|
1581
|
-
if (state.savePolicy !== "writable" || state.dynamic) state.domDirty = false;
|
|
1582
|
-
}
|
|
1583
|
-
|
|
1584
|
-
async function captureStablePage(options) {
|
|
1585
|
-
const identity = currentRender();
|
|
1586
|
-
const captured = await captures.request(options);
|
|
1587
|
-
const capturedSaveSequence = saveSequence;
|
|
1588
|
-
const capturedEditSequence = editMutationSequence;
|
|
1589
|
-
// The SDK flushes its debounces before capture; their server writes must finish too.
|
|
1590
|
-
await activeSavePromise;
|
|
1591
|
-
await settleEditPersistence(identity.key);
|
|
1592
|
-
applyCleanObservation();
|
|
1593
|
-
if (options?.signal?.aborted) throw captureError("CAPTURE_CANCELLED", "Capture cancelled");
|
|
1594
|
-
if (!sameRender(identity, currentRender()) || state.saveConflict || state.save === "failed") {
|
|
1595
|
-
throw new Error("The page changed or could not be saved during capture. Recapture the latest version.");
|
|
1596
|
-
}
|
|
1597
|
-
if (saveSequence !== capturedSaveSequence || editMutationSequence !== capturedEditSequence) {
|
|
1598
|
-
throw new Error("The page changed while its captured edits were saving. Recapture the latest version.");
|
|
1599
|
-
}
|
|
1600
|
-
return captured;
|
|
1601
|
-
}
|
|
1602
|
-
|
|
1603
|
-
function syncFrameInteraction() {
|
|
1604
|
-
frame.inert = state.comparing || state.sending;
|
|
1605
|
-
}
|
|
1606
|
-
|
|
1607
|
-
function roundId(round) { return round?.id || round?.roundId; }
|
|
1608
|
-
function roundTargets(round) { return round?.targets || []; }
|
|
1609
|
-
function selectedTarget() {
|
|
1610
|
-
const targets = roundTargets(history.round);
|
|
1611
|
-
return targets.find((target) => target.key === history.targetKey) || targets[0] || null;
|
|
1612
|
-
}
|
|
1613
|
-
function targetComparison(target) { return target?.comparison || {}; }
|
|
1614
|
-
function comparisonItems() {
|
|
1615
|
-
const comparison = targetComparison(selectedTarget());
|
|
1616
|
-
const value = history.mode === "source" ? comparison.source : comparison.content;
|
|
1617
|
-
return value?.changes || value?.items || (history.mode === "content" ? comparison.changes || comparison.items : []) || [];
|
|
1618
|
-
}
|
|
1619
|
-
|
|
1620
|
-
const refreshHistory = () => state.ended ? Promise.resolve(false) : historyController.refresh();
|
|
1621
|
-
|
|
1622
|
-
function renderHistory() {
|
|
1623
|
-
syncFrameInteraction();
|
|
1624
|
-
const focusedMode = document.activeElement?.dataset.comparisonMode;
|
|
1625
|
-
const picker = $("roundPicker");
|
|
1626
|
-
picker.replaceChildren();
|
|
1627
|
-
for (const [index, round] of history.rounds.entries()) {
|
|
1628
|
-
const option = document.createElement("option");
|
|
1629
|
-
option.value = roundId(round);
|
|
1630
|
-
option.textContent = `Round ${round.ordinal || round.number || history.rounds.length - index} · ${round.captureStatus === "ready" ? "completed" : round.captureStatus || round.status || round.feedbackStatus || "pending"}`;
|
|
1631
|
-
picker.append(option);
|
|
1632
|
-
}
|
|
1633
|
-
picker.value = history.selectedId || "";
|
|
1634
|
-
picker.disabled = !history.rounds.length;
|
|
1635
|
-
const targets = roundTargets(history.round);
|
|
1636
|
-
const target = selectedTarget();
|
|
1637
|
-
if (history.round) history.targetKey = target?.key || null;
|
|
1638
|
-
$("historyTarget").replaceChildren();
|
|
1639
|
-
for (const item of targets) {
|
|
1640
|
-
const option = document.createElement("option");
|
|
1641
|
-
option.value = item.key;
|
|
1642
|
-
option.textContent = item.filename || item.label || item.key;
|
|
1643
|
-
$("historyTarget").append(option);
|
|
1644
|
-
}
|
|
1645
|
-
$("historyTarget").value = history.targetKey || "";
|
|
1646
|
-
$("historyTargetLabel").hidden = targets.length < 2;
|
|
1647
|
-
const comparison = targetComparison(target);
|
|
1648
|
-
const failedCapture = history.failures.get(`${roundId(history.round)}:${target?.key}`);
|
|
1649
|
-
const presentation = historyPresentation({ ...history, target, comparison, failure: failedCapture });
|
|
1650
|
-
const modes = presentation.modes;
|
|
1651
|
-
history.mode = modes.includes(history.preferredMode) ? history.preferredMode : modes[0] || "content";
|
|
1652
|
-
const displayedComparison = comparison[history.mode] || {};
|
|
1653
|
-
$("historyUnavailable").replaceChildren();
|
|
1654
|
-
for (const mode of ["content", "source"]) {
|
|
1655
|
-
const representation = comparison[mode];
|
|
1656
|
-
if (representation && representation.available === false) {
|
|
1657
|
-
const reason = document.createElement("p");
|
|
1658
|
-
reason.textContent = `${mode === "content" ? "Content" : "Source"} comparison unavailable: ${representation.reason || "This representation could not be compared."}`;
|
|
1659
|
-
$("historyUnavailable").append(reason);
|
|
1660
|
-
}
|
|
1661
|
-
}
|
|
1662
|
-
$("historyUnavailable").hidden = !$("historyUnavailable").childElementCount;
|
|
1663
|
-
$("historyTiming").replaceChildren();
|
|
1664
|
-
for (const [label, timestamp] of [
|
|
1665
|
-
["Before captured", displayedComparison.beforeCapturedAt],
|
|
1666
|
-
["Agent acknowledged", history.round?.acknowledgedAt],
|
|
1667
|
-
["After captured", displayedComparison.afterCapturedAt],
|
|
1668
|
-
]) {
|
|
1669
|
-
const row = document.createElement("div");
|
|
1670
|
-
const term = document.createElement("dt");
|
|
1671
|
-
term.textContent = label;
|
|
1672
|
-
const value = document.createElement("dd");
|
|
1673
|
-
const date = timestamp == null ? null : new Date(timestamp);
|
|
1674
|
-
value.textContent = date && Number.isFinite(date.getTime()) ? date.toLocaleString() : "Not available";
|
|
1675
|
-
row.append(term, value);
|
|
1676
|
-
$("historyTiming").append(row);
|
|
1677
|
-
}
|
|
1678
|
-
$("historyTiming").hidden = !history.round;
|
|
1679
|
-
const afterAt = displayedComparison.afterCapturedAt == null ? NaN : new Date(displayedComparison.afterCapturedAt).getTime();
|
|
1680
|
-
const acknowledgedAt = history.round?.acknowledgedAt == null ? NaN : new Date(history.round.acknowledgedAt).getTime();
|
|
1681
|
-
const delay = afterAt - acknowledgedAt;
|
|
1682
|
-
$("historyCaptureDelay").hidden = !Number.isFinite(delay);
|
|
1683
|
-
$("historyCaptureDelay").textContent = delay >= 0
|
|
1684
|
-
? `Result captured ${Math.round(delay / 1000)} seconds after acknowledgment—not at the acknowledgment instant. A delayed capture can include later changes.`
|
|
1685
|
-
: "This result snapshot predates acknowledgment. It is not proof of the page state at acknowledgment.";
|
|
1686
|
-
$("historyCurrentStatus").hidden = !target?.resultRevisionId && !displayedComparison.afterCapturedAt;
|
|
1687
|
-
$("historyCurrentStatus").textContent = comparisonFreshness(displayedComparison, target?.key, {
|
|
1688
|
-
key: state.key, kind: state.page?.kind, ready: !state.frameLoading && !!state.frameReadyAt,
|
|
1689
|
-
pendingReload: state.pendingReload, dirty: state.domDirty || state.saveConflict,
|
|
1690
|
-
sourceHash: state.renderSourceHash || state.baseHash,
|
|
1691
|
-
sessionId: state.sessionId, generation: state.renderGeneration,
|
|
1692
|
-
});
|
|
1693
|
-
const viewComparison = history.mode === "content" ? displayedComparison.viewComparison : null;
|
|
1694
|
-
$("historyViewCoverage").hidden = !viewComparison?.message;
|
|
1695
|
-
$("historyViewCoverage").textContent = viewComparison?.message || "";
|
|
1696
|
-
$("historyViewCoverage").dataset.status = viewComparison?.status || "";
|
|
1697
|
-
$("comparisonModes").replaceChildren();
|
|
1698
|
-
for (const mode of modes) {
|
|
1699
|
-
const button = makeAction(mode === "source" ? "Source" : "Content", "btn-ghost");
|
|
1700
|
-
button.dataset.comparisonMode = mode;
|
|
1701
|
-
button.setAttribute("aria-pressed", String(history.mode === mode));
|
|
1702
|
-
button.addEventListener("click", () => {
|
|
1703
|
-
history.preferredMode = mode;
|
|
1704
|
-
history.mode = mode;
|
|
1705
|
-
history.index = 0;
|
|
1706
|
-
renderHistory();
|
|
1707
|
-
$("comparisonModes").querySelector(`[data-comparison-mode="${mode}"]`)?.focus({ preventScroll: true });
|
|
1708
|
-
});
|
|
1709
|
-
$("comparisonModes").append(button);
|
|
1710
|
-
}
|
|
1711
|
-
if (focusedMode) $("comparisonModes").querySelector(`[data-comparison-mode="${focusedMode}"]`)?.focus({ preventScroll: true });
|
|
1712
|
-
if ($("historyStatus").textContent !== presentation.message) $("historyStatus").textContent = presentation.message;
|
|
1713
|
-
$("historyStatus").dataset.state = presentation.state;
|
|
1714
|
-
const detail = history.error?.message || failedCapture || target?.capture?.error;
|
|
1715
|
-
$("historyError").hidden = !detail;
|
|
1716
|
-
$("historyError").textContent = detail || "";
|
|
1717
|
-
const captureAllowed = target && !target.resultRevisionId && target.capture?.status !== "unavailable" && history.round?.feedbackStatus === "acknowledged";
|
|
1718
|
-
$("captureResult").hidden = !captureAllowed;
|
|
1719
|
-
$("captureResult").disabled = history.captureBusy || history.finalizing || target?.key !== state.key || state.frameLoading;
|
|
1720
|
-
$("captureResult").textContent = history.captureBusy ? "Capturing result…" : "Capture result";
|
|
1721
|
-
const canFinalize = target && !target.resultRevisionId && history.round?.feedbackStatus === "acknowledged" &&
|
|
1722
|
-
["pending", "failed"].includes(target.capture?.status || target.captureStatus || "pending");
|
|
1723
|
-
$("finishCapture").hidden = !canFinalize;
|
|
1724
|
-
$("finishCaptureHelp").hidden = !canFinalize;
|
|
1725
|
-
$("finishCapture").disabled = history.finalizing || history.captureBusy;
|
|
1726
|
-
$("finishCapture").setAttribute("aria-describedby", "finishCaptureHelp");
|
|
1727
|
-
$("finishCapture").textContent = history.finalizing ? "Finishing…" : "Finish with available snapshots";
|
|
1728
|
-
const items = comparisonItems();
|
|
1729
|
-
const limitations = [...new Set([
|
|
1730
|
-
...(comparison.content?.limitations || []),
|
|
1731
|
-
...(comparison.source?.limitations || []),
|
|
1732
|
-
])];
|
|
1733
|
-
$("historyLimitations").hidden = limitations.length === 0;
|
|
1734
|
-
$("historyLimitationsList").replaceChildren();
|
|
1735
|
-
for (const limitation of limitations) {
|
|
1736
|
-
const line = document.createElement("li");
|
|
1737
|
-
line.textContent = String(limitation).replaceAll("-", " ").replaceAll("_", " ");
|
|
1738
|
-
$("historyLimitationsList").append(line);
|
|
1739
|
-
}
|
|
1740
|
-
const counts = displayedComparison.counts;
|
|
1741
|
-
$("historyCounts").textContent = modes.length && counts
|
|
1742
|
-
? `${counts.added} Added · ${counts.modified} Modified · ${counts.removed} Removed`
|
|
1743
|
-
: "";
|
|
1744
|
-
$("historyCounts").hidden = !counts;
|
|
1745
|
-
history.index = Math.max(0, Math.min(history.index, items.length - 1));
|
|
1746
|
-
$("changeDetail").hidden = !modes.length;
|
|
1747
|
-
const comparisonHeader = $("historyPanel").querySelector(".comparison-header");
|
|
1748
|
-
if (comparisonHeader) comparisonHeader.hidden = !modes.length;
|
|
1749
|
-
if (modes.length) {
|
|
1750
|
-
comparisonView.render({ ...displayedComparison, changes: items }, {
|
|
1751
|
-
mode: history.mode, key: `${history.selectedId}:${history.targetKey}`,
|
|
1752
|
-
});
|
|
1753
|
-
comparisonView.select(history.index);
|
|
1754
|
-
}
|
|
1755
|
-
$("changeJump").replaceChildren();
|
|
1756
|
-
for (const [index, item] of items.entries()) {
|
|
1757
|
-
const option = document.createElement("option");
|
|
1758
|
-
option.value = index;
|
|
1759
|
-
option.textContent = `${index + 1}. ${changeKind(item)} · ${tidyMiddle(item.label || excerpt(item.after) || excerpt(item.before) || "Structure", 65)}`;
|
|
1760
|
-
$("changeJump").append(option);
|
|
1761
|
-
}
|
|
1762
|
-
$("changeJump").value = String(history.index);
|
|
1763
|
-
$("changeNavigation").hidden = items.length < 2;
|
|
1764
|
-
$("previousChange").disabled = history.index === 0;
|
|
1765
|
-
$("nextChange").disabled = history.index >= items.length - 1;
|
|
1766
|
-
$("changePosition").textContent = `${items.length ? history.index + 1 : 0} of ${items.length}`;
|
|
1767
|
-
}
|
|
1768
|
-
|
|
1769
|
-
async function captureResult(round, target, automatic = false, signal) {
|
|
1770
|
-
if (history.captureBusy || !round || target?.key !== state.key) return;
|
|
1771
|
-
const manualController = automatic ? null : new AbortController();
|
|
1772
|
-
if (manualController) {
|
|
1773
|
-
manualCaptureController = manualController;
|
|
1774
|
-
signal = manualController.signal;
|
|
1775
|
-
}
|
|
1776
|
-
const identity = currentRender();
|
|
1777
|
-
history.captureBusy = true;
|
|
1778
|
-
renderHistory();
|
|
1779
|
-
let phase = "saving";
|
|
1780
|
-
try {
|
|
1781
|
-
await fullSaveBarrier();
|
|
1782
|
-
if (signal?.aborted || state.ended || !sameRender(identity, currentRender())) throw captureError("CAPTURE_CANCELLED", "Capture cancelled");
|
|
1783
|
-
phase = "snapshot";
|
|
1784
|
-
const captured = await captureStablePage({ signal });
|
|
1785
|
-
phase = "publishing";
|
|
1786
|
-
const result = await api(historyUrl(`/${roundId(round)}/capture`), {
|
|
1787
|
-
method: "POST",
|
|
1788
|
-
signal,
|
|
1789
|
-
body: JSON.stringify({
|
|
1790
|
-
sessionId: state.sessionId, roundId: roundId(round), key: captured.key,
|
|
1791
|
-
renderId: captured.renderId, generation: captured.generation,
|
|
1792
|
-
semantic: captured.semantic, expectedSourceHash: captured.sourceHash || state.baseHash || state.renderSourceHash,
|
|
1793
|
-
semanticCapturedAt: captured.semanticCapturedAt, view: captured.view,
|
|
1794
|
-
manual: !automatic,
|
|
1795
|
-
}),
|
|
1796
|
-
});
|
|
1797
|
-
requireCaptureSuccess(result, target.key);
|
|
1798
|
-
if (signal?.aborted || state.ended || !sameRender(identity, currentRender())) return;
|
|
1799
|
-
history.failures.delete(`${roundId(round)}:${target.key}`);
|
|
1800
|
-
await refreshHistory();
|
|
1801
|
-
} catch (err) {
|
|
1802
|
-
if (signal?.aborted || state.ended || !sameRender(identity, currentRender()) || err.code === "CAPTURE_CANCELLED") return;
|
|
1803
|
-
history.failures.set(`${roundId(round)}:${target.key}`, err.message);
|
|
1804
|
-
if (phase === "snapshot" && sameRender(identity, currentRender()) && !state.frameLoading) {
|
|
1805
|
-
await api(historyUrl(`/${roundId(round)}/capture`), {
|
|
1806
|
-
method: "POST",
|
|
1807
|
-
signal,
|
|
1808
|
-
body: JSON.stringify({
|
|
1809
|
-
key: identity.key, renderId: identity.renderId, generation: identity.generation,
|
|
1810
|
-
manual: !automatic, error: String(err.message).slice(0, 500),
|
|
1811
|
-
}),
|
|
1812
|
-
}).catch(() => {});
|
|
1813
|
-
}
|
|
1814
|
-
$("historyError").hidden = false;
|
|
1815
|
-
$("historyError").textContent = `${err.message} Use Capture result to retry; existing captures are unchanged.`;
|
|
1816
|
-
if (automatic) throw err;
|
|
1817
|
-
} finally {
|
|
1818
|
-
if (manualCaptureController === manualController) manualCaptureController = null;
|
|
1819
|
-
history.captureBusy = false;
|
|
1820
|
-
renderHistory();
|
|
1821
|
-
scheduleResultCapture();
|
|
1822
|
-
}
|
|
1823
|
-
}
|
|
1824
|
-
|
|
1825
|
-
function scheduleResultCapture() {
|
|
1826
|
-
captureCoordinator.tick();
|
|
1827
|
-
}
|
|
1828
|
-
|
|
1829
|
-
function setHistoryView(comparing) {
|
|
1830
|
-
captureEditState();
|
|
1831
|
-
state.comparing = comparing;
|
|
1832
|
-
closeModeMenu("history-switch");
|
|
1833
|
-
document.body.classList.toggle("comparing", comparing);
|
|
1834
|
-
syncFrameInteraction();
|
|
1835
|
-
frame.setAttribute("aria-hidden", String(comparing));
|
|
1836
|
-
$("historyPanel").hidden = !comparing;
|
|
1837
|
-
$("latestVersion").setAttribute("aria-pressed", String(!comparing));
|
|
1838
|
-
$("seeChanges").setAttribute("aria-pressed", String(comparing));
|
|
1839
|
-
if (!comparing && state.pendingReload) $("reloadNotice").hidden = false;
|
|
1840
|
-
if (comparing) {
|
|
1841
|
-
closeDrawer();
|
|
1842
|
-
void refreshHistory();
|
|
1843
|
-
}
|
|
1844
|
-
render();
|
|
1845
|
-
}
|
|
1846
|
-
$("latestVersion").addEventListener("click", () => setHistoryView(false));
|
|
1847
|
-
$("seeChanges").addEventListener("click", () => setHistoryView(true));
|
|
1848
|
-
$("roundPicker").addEventListener("change", () => void historyController.selectRound($("roundPicker").value));
|
|
1849
|
-
$("historyTarget").addEventListener("change", () => void historyController.selectTarget($("historyTarget").value));
|
|
1850
|
-
function jumpToChange(index) {
|
|
1851
|
-
history.index = index;
|
|
1852
|
-
renderHistory();
|
|
1853
|
-
comparisonView.select(history.index, { scroll: true });
|
|
1854
|
-
}
|
|
1855
|
-
$("previousChange").addEventListener("click", () => jumpToChange(history.index - 1));
|
|
1856
|
-
$("nextChange").addEventListener("click", () => jumpToChange(history.index + 1));
|
|
1857
|
-
$("changeJump").addEventListener("change", (event) => jumpToChange(Number(event.target.value)));
|
|
1858
|
-
$("captureResult").addEventListener("click", () => void captureResult(history.round, selectedTarget()));
|
|
1859
|
-
$("finishCapture").addEventListener("click", async () => {
|
|
1860
|
-
const target = selectedTarget();
|
|
1861
|
-
const round = history.round;
|
|
1862
|
-
if (!target || !round || history.finalizing || history.captureBusy) return;
|
|
1863
|
-
history.finalizing = true;
|
|
1864
|
-
renderHistory();
|
|
1865
|
-
try {
|
|
1866
|
-
await api(historyUrl(`/${roundId(round)}/capture`), {
|
|
1867
|
-
method: "POST",
|
|
1868
|
-
body: JSON.stringify({ key: target.key, manual: true, finalUnavailable: true }),
|
|
1869
|
-
});
|
|
1870
|
-
history.failures.delete(`${roundId(round)}:${target.key}`);
|
|
1871
|
-
await refreshHistory();
|
|
1872
|
-
announce("Finished with available snapshots. Missing Content remains unavailable.");
|
|
1873
|
-
} catch (err) {
|
|
1874
|
-
$("historyError").hidden = false;
|
|
1875
|
-
$("historyError").textContent = `${err.message} The capture is still open; you can retry.`;
|
|
1876
|
-
} finally {
|
|
1877
|
-
history.finalizing = false;
|
|
1878
|
-
renderHistory();
|
|
1879
|
-
}
|
|
1880
|
-
});
|
|
1881
|
-
|
|
1882
|
-
// -------------------------------------------------------------------- saving
|
|
1883
|
-
|
|
1884
|
-
let retryTimer = null;
|
|
1885
|
-
let saveAttempts = 0;
|
|
1886
|
-
let activeSavePromise = Promise.resolve(true);
|
|
1887
|
-
let saveSequence = 0;
|
|
1888
|
-
let savePending = 0;
|
|
1889
|
-
let lastSave = null;
|
|
1890
|
-
|
|
1891
|
-
function applyCleanObservation() {
|
|
1892
|
-
const clean = state.cleanObservation;
|
|
1893
|
-
if (!clean || clean.key !== state.key || clean.generation !== state.renderGeneration ||
|
|
1894
|
-
clean.saveSequence !== saveSequence || clean.editSequence !== editMutationSequence ||
|
|
1895
|
-
savePending || state.save === "failed" || state.saveConflict ||
|
|
1896
|
-
editBacklogs.get(state.key)?.size || editPipelines.has(state.key)) return;
|
|
1897
|
-
state.domDirty = false;
|
|
1898
|
-
state.save = state.savedAt ? "saved" : "idle";
|
|
1899
|
-
renderSave();
|
|
1900
|
-
}
|
|
1901
|
-
|
|
1902
|
-
/** A fresh serialization from the SDK always starts a fresh attempt budget. */
|
|
1903
|
-
function saveNow(html) {
|
|
1904
|
-
const sequence = ++saveSequence;
|
|
1905
|
-
const key = state.key;
|
|
1906
|
-
const generation = state.renderGeneration;
|
|
1907
|
-
lastSave = { html, key, generation };
|
|
1908
|
-
savePending += 1;
|
|
1909
|
-
state.domDirty = true;
|
|
1910
|
-
saveAttempts = 0;
|
|
1911
|
-
activeSavePromise = activeSavePromise.catch(() => false).then(async () => {
|
|
1912
|
-
if (state.saveConflict || key !== state.key || generation !== state.renderGeneration) return false;
|
|
1913
|
-
const saved = await saveHtml(html, key, generation);
|
|
1914
|
-
if (saved && saveSequence === sequence) state.domDirty = false;
|
|
1915
|
-
return saved;
|
|
1916
|
-
}).finally(() => {
|
|
1917
|
-
savePending -= 1;
|
|
1918
|
-
applyCleanObservation();
|
|
1919
|
-
});
|
|
1920
|
-
return activeSavePromise;
|
|
1921
|
-
}
|
|
1922
|
-
|
|
1923
|
-
async function saveHtml(html, key, generation = state.renderGeneration) {
|
|
1924
|
-
clearTimeout(retryTimer);
|
|
1925
|
-
// A retry that outlived a page switch must never write into the new page.
|
|
1926
|
-
if (key !== state.key || generation !== state.renderGeneration || state.saveConflict) return false;
|
|
1927
|
-
if (!state.baseHash) {
|
|
1928
|
-
// The on-disk baseline hasn't arrived yet; wait for it rather than write blind.
|
|
1929
|
-
saveAttempts += 1;
|
|
1930
|
-
if (saveAttempts <= 10) {
|
|
1931
|
-
await new Promise((resolve) => setTimeout(resolve, 300));
|
|
1932
|
-
return saveHtml(html, key, generation);
|
|
1933
|
-
}
|
|
1934
|
-
else {
|
|
1935
|
-
state.save = "failed";
|
|
1936
|
-
renderSave();
|
|
1937
|
-
}
|
|
1938
|
-
return false;
|
|
1939
|
-
}
|
|
1940
|
-
try {
|
|
1941
|
-
const result = await api(`/api/page/${key}/save`, {
|
|
1942
|
-
method: "POST",
|
|
1943
|
-
body: JSON.stringify({
|
|
1944
|
-
html, baseHash: state.baseHash, renderId: state.renderId,
|
|
1945
|
-
generation, sessionId: state.sessionId,
|
|
1946
|
-
}),
|
|
1947
|
-
});
|
|
1948
|
-
if (key !== state.key || generation !== state.renderGeneration) return false;
|
|
1949
|
-
state.baseHash = result.hash || null;
|
|
1950
|
-
state.renderSourceHash = result.hash || state.renderSourceHash;
|
|
1951
|
-
state.save = "saved";
|
|
1952
|
-
state.savedAt = clock();
|
|
1953
|
-
saveAttempts = 0;
|
|
1954
|
-
renderSave();
|
|
1955
|
-
return true;
|
|
1956
|
-
} catch (err) {
|
|
1957
|
-
if (key !== state.key || generation !== state.renderGeneration) return false;
|
|
1958
|
-
if (err.status === 409) {
|
|
1959
|
-
// Someone else — usually the agent — wrote the file first. Their version
|
|
1960
|
-
// arrives via the reload event; this save is abandoned, not retried.
|
|
1961
|
-
state.baseHash = null;
|
|
1962
|
-
state.save = "idle";
|
|
1963
|
-
state.saveConflict = true;
|
|
1964
|
-
saveAttempts = 0;
|
|
1965
|
-
renderSave();
|
|
1966
|
-
holdReload();
|
|
1967
|
-
announce("Save conflict. Edit mode remains active so you can reload safely.");
|
|
1968
|
-
diagnostic("save-conflict");
|
|
1969
|
-
return false;
|
|
1970
|
-
}
|
|
1971
|
-
saveAttempts += 1;
|
|
1972
|
-
state.save = "failed";
|
|
1973
|
-
if (saveAttempts < 3) {
|
|
1974
|
-
await new Promise((resolve) => setTimeout(resolve, 500));
|
|
1975
|
-
return saveHtml(html, key, generation);
|
|
1976
|
-
}
|
|
1977
|
-
else if (!state.sending) toast("Couldn't save. Retry before sending feedback; your edits remain on this page.");
|
|
1978
|
-
return false;
|
|
1979
|
-
}
|
|
1980
|
-
}
|
|
1981
|
-
|
|
1982
|
-
// ------------------------------------------------------------ frame messages
|
|
1983
|
-
|
|
1984
|
-
function targetPayload(msg) {
|
|
1985
|
-
const viewport = {
|
|
1986
|
-
width: Number(msg.viewport?.width),
|
|
1987
|
-
height: Number(msg.viewport?.height),
|
|
1988
|
-
};
|
|
1989
|
-
const generation = Number(msg.targetGeneration);
|
|
1990
|
-
const rawRelation = String(msg.relation || "");
|
|
1991
|
-
if (!["visible", "above", "below", "unavailable"].includes(rawRelation)) return null;
|
|
1992
|
-
const relation = sanitizeRelation(rawRelation);
|
|
1993
|
-
const clip = sanitizeClipRect(msg.clip, viewport);
|
|
1994
|
-
const rects = sanitizeClientRects(msg.rects, viewport);
|
|
1995
|
-
if (!Number.isSafeInteger(generation) || generation < 1 || !clip) return null;
|
|
1996
|
-
if (relation === "visible" && !rects.length) return null;
|
|
1997
|
-
return {
|
|
1998
|
-
kind: msg.kind === "element" ? "element" : "selection",
|
|
1999
|
-
quote: String(msg.quote || ""),
|
|
2000
|
-
anchor: msg.anchor || null,
|
|
2001
|
-
rects,
|
|
2002
|
-
generation,
|
|
2003
|
-
relation,
|
|
2004
|
-
clip,
|
|
2005
|
-
horizontal: Number.isFinite(msg.horizontal) ? Number(msg.horizontal) : null,
|
|
2006
|
-
};
|
|
2007
|
-
}
|
|
2008
|
-
|
|
2009
|
-
window.addEventListener("message", async (event) => {
|
|
2010
|
-
if (!frame.contentWindow || event.source !== frame.contentWindow) return;
|
|
2011
|
-
if (!state.framePolicy || event.origin !== state.framePolicy.incomingOrigin) return;
|
|
2012
|
-
const msg = event.data || {};
|
|
2013
|
-
if (
|
|
2014
|
-
!state.frameCapability ||
|
|
2015
|
-
msg.capability !== state.frameCapability ||
|
|
2016
|
-
msg.generation !== state.renderGeneration ||
|
|
2017
|
-
msg.pageKey !== state.key
|
|
2018
|
-
) return;
|
|
2019
|
-
if (state.frameLoading && msg.type !== "eh:ready") return;
|
|
2020
|
-
|
|
2021
|
-
switch (msg.type) {
|
|
2022
|
-
case "eh:ready": {
|
|
2023
|
-
if (state.readyGeneration === state.renderGeneration) return;
|
|
2024
|
-
clearTimeout(state.frameReadyTimer);
|
|
2025
|
-
state.frameReadyTimer = null;
|
|
2026
|
-
state.frameReadyRetries = 0;
|
|
2027
|
-
state.readyGeneration = state.renderGeneration;
|
|
2028
|
-
state.frameReadyAt = Date.now();
|
|
2029
|
-
const readyCapability = state.frameCapability;
|
|
2030
|
-
const readyIdentity = currentRender();
|
|
2031
|
-
const readySaveSequence = saveSequence;
|
|
2032
|
-
const ready = api(`/api/session/${state.sessionId}/render/${state.renderId}/ready`, {
|
|
2033
|
-
method: "POST",
|
|
2034
|
-
body: JSON.stringify({
|
|
2035
|
-
capability: readyCapability,
|
|
2036
|
-
generation: state.renderGeneration,
|
|
2037
|
-
pageKey: state.key,
|
|
2038
|
-
}),
|
|
2039
|
-
}).then((result) => {
|
|
2040
|
-
if (!sameRender(readyIdentity, currentRender())) return null;
|
|
2041
|
-
if (saveSequence === readySaveSequence) state.renderSourceHash = result.sourceHash || null;
|
|
2042
|
-
return result;
|
|
2043
|
-
}).catch(() => null);
|
|
2044
|
-
const readyState = await ready;
|
|
2045
|
-
if (!sameRender(readyIdentity, currentRender())) return;
|
|
2046
|
-
if (!readyState || !["static", "interactive", "application"].includes(readyState.executionMode) ||
|
|
2047
|
-
!["writable", "feedback-only"].includes(readyState.savePolicy)) {
|
|
2048
|
-
failFrame("The review could not confirm this document's execution policy. Reload before editing.");
|
|
2049
|
-
return;
|
|
2050
|
-
}
|
|
2051
|
-
state.renderExecution = {
|
|
2052
|
-
executionMode: readyState.executionMode,
|
|
2053
|
-
savePolicy: readyState.savePolicy,
|
|
2054
|
-
feedbackOnly: readyState.savePolicy === "feedback-only",
|
|
2055
|
-
executionNotice: readyState.executionNotice || null,
|
|
2056
|
-
};
|
|
2057
|
-
state.page = { ...state.page, ...state.renderExecution };
|
|
2058
|
-
state.frameLoading = false;
|
|
2059
|
-
frame.dataset.sdkReady = "true";
|
|
2060
|
-
frame.dataset.executionMode = readyState.executionMode;
|
|
2061
|
-
frame.dataset.savePolicy = readyState.savePolicy;
|
|
2062
|
-
frame.dataset.executionNotice = readyState.executionNotice || "";
|
|
2063
|
-
toFrame({ type: "eh:anchors", comments: state.page ? state.page.comments : [] });
|
|
2064
|
-
if (state.reloading) {
|
|
2065
|
-
toFrame({ type: "eh:restoreScroll", x: state.scroll.x, y: state.scroll.y });
|
|
2066
|
-
state.reloading = false;
|
|
2067
|
-
}
|
|
2068
|
-
const configuration = reviewConfiguration(state.page, state.reviewMode);
|
|
2069
|
-
state.savePolicy = configuration.savePolicy;
|
|
2070
|
-
toFrame({ type: "eh:configureReview", ...configuration });
|
|
2071
|
-
render();
|
|
2072
|
-
if (state.page?.kind !== "url") {
|
|
2073
|
-
// Hand the SDK the on-disk HTML so it can spot self-rendering pages.
|
|
2074
|
-
const rawIdentity = currentRender();
|
|
2075
|
-
ready.then(() => {
|
|
2076
|
-
if (!sameRender(rawIdentity, currentRender())) return null;
|
|
2077
|
-
return api(`/api/page/${state.key}/raw`);
|
|
2078
|
-
})
|
|
2079
|
-
.then((raw) => {
|
|
2080
|
-
if (!raw || !sameRender(rawIdentity, currentRender()) || state.pendingReload) return;
|
|
2081
|
-
if (state.renderSourceHash && state.renderSourceHash !== raw.hash) {
|
|
2082
|
-
state.saveConflict = true;
|
|
2083
|
-
holdReload();
|
|
2084
|
-
return;
|
|
2085
|
-
}
|
|
2086
|
-
state.baseHash = raw.hash || null;
|
|
2087
|
-
if (configuration.savePolicy === "writable") toFrame({ type: "eh:raw", html: raw.html });
|
|
2088
|
-
scheduleResultCapture();
|
|
2089
|
-
})
|
|
2090
|
-
.catch(() => {});
|
|
2091
|
-
}
|
|
2092
|
-
void ready.then(() => refreshHistory());
|
|
2093
|
-
break;
|
|
2094
|
-
}
|
|
2095
|
-
case "eh:configurationApplied":
|
|
2096
|
-
state.configurationGeneration = state.renderGeneration;
|
|
2097
|
-
if (previousFrame) {
|
|
2098
|
-
const configuredFrame = frame;
|
|
2099
|
-
requestAnimationFrame(() => requestAnimationFrame(() => {
|
|
2100
|
-
if (frame === configuredFrame && !state.frameLoading) finishFrameReplacement();
|
|
2101
|
-
}));
|
|
2102
|
-
}
|
|
2103
|
-
scheduleResultCapture();
|
|
2104
|
-
if (
|
|
2105
|
-
configurationWaiter &&
|
|
2106
|
-
msg.mode === configurationWaiter.mode &&
|
|
2107
|
-
msg.savePolicy === configurationWaiter.savePolicy
|
|
2108
|
-
) configurationWaiter.settle(true);
|
|
2109
|
-
announce(`${state.reviewMode === "edit" ? "Edit" : "View"} mode active`);
|
|
2110
|
-
break;
|
|
2111
|
-
case "eh:target": {
|
|
2112
|
-
const detail = targetPayload(msg);
|
|
2113
|
-
if (!detail) return;
|
|
2114
|
-
if (state.target && detail.generation < state.target.generation) return;
|
|
2115
|
-
state.target = detail;
|
|
2116
|
-
break;
|
|
2117
|
-
}
|
|
2118
|
-
case "eh:openComment": {
|
|
2119
|
-
const requestedGeneration = Number(msg.targetGeneration);
|
|
2120
|
-
const detail = targetPayload(msg);
|
|
2121
|
-
let accepted = false;
|
|
2122
|
-
if (detail && (!state.target || detail.generation >= state.target.generation)) {
|
|
2123
|
-
accepted = await openCompose(detail);
|
|
2124
|
-
}
|
|
2125
|
-
if (!accepted && state.compose) {
|
|
2126
|
-
state.compose.rejectedTargetGeneration = Math.max(
|
|
2127
|
-
Number(state.compose.rejectedTargetGeneration) || 0,
|
|
2128
|
-
requestedGeneration
|
|
2129
|
-
);
|
|
2130
|
-
}
|
|
2131
|
-
toFrame({
|
|
2132
|
-
type: "eh:commentOpenResult",
|
|
2133
|
-
accepted,
|
|
2134
|
-
requestedGeneration,
|
|
2135
|
-
targetGeneration: state.compose?.generation || null,
|
|
2136
|
-
});
|
|
2137
|
-
break;
|
|
2138
|
-
}
|
|
2139
|
-
case "eh:targetGeometry":
|
|
2140
|
-
if (state.compose && msg.targetGeneration !== state.compose.generation) {
|
|
2141
|
-
diagnostic("geometry-rejected", { reason: "stale" });
|
|
2142
|
-
break;
|
|
2143
|
-
}
|
|
2144
|
-
if (state.compose) {
|
|
2145
|
-
const geometry = targetPayload({ ...msg, kind: state.compose.kind });
|
|
2146
|
-
if (!geometry) {
|
|
2147
|
-
diagnostic("geometry-rejected", { reason: "invalid" });
|
|
2148
|
-
break;
|
|
2149
|
-
}
|
|
2150
|
-
state.compose.rects = geometry.rects;
|
|
2151
|
-
state.compose.relation = geometry.relation;
|
|
2152
|
-
state.compose.clip = geometry.clip;
|
|
2153
|
-
state.compose.horizontal = geometry.horizontal ?? state.compose.horizontal;
|
|
2154
|
-
positionCompose();
|
|
2155
|
-
}
|
|
2156
|
-
break;
|
|
2157
|
-
case "eh:commentGeometry": {
|
|
2158
|
-
if (!msg.id) break;
|
|
2159
|
-
const id = String(msg.id);
|
|
2160
|
-
if (!state.page?.comments?.some((comment) => comment.id === id)) {
|
|
2161
|
-
state.activeGeometry.delete(id);
|
|
2162
|
-
break;
|
|
2163
|
-
}
|
|
2164
|
-
const rects = sanitizeClientRects(msg.rects, {
|
|
2165
|
-
width: Number(msg.viewport?.width),
|
|
2166
|
-
height: Number(msg.viewport?.height),
|
|
2167
|
-
});
|
|
2168
|
-
state.activeGeometry.set(id, {
|
|
2169
|
-
rects,
|
|
2170
|
-
visible: msg.visible !== false && rects.length > 0,
|
|
2171
|
-
});
|
|
2172
|
-
if (state.activeSavedCommentId === id) {
|
|
2173
|
-
positionAlignedCard();
|
|
2174
|
-
restoreTransientFocus(false);
|
|
2175
|
-
}
|
|
2176
|
-
break;
|
|
2177
|
-
}
|
|
2178
|
-
case "eh:revealTargetResult":
|
|
2179
|
-
if (!state.compose || msg.targetGeneration !== state.compose.generation) break;
|
|
2180
|
-
announce(msg.success ? "Selection revealed" : "Selection is no longer available");
|
|
2181
|
-
diagnostic(msg.success ? "reveal-target-succeeded" : "reveal-target-failed");
|
|
2182
|
-
break;
|
|
2183
|
-
case "eh:interaction":
|
|
2184
|
-
closeModeMenu("frame-interaction");
|
|
2185
|
-
closeCommentMenu();
|
|
2186
|
-
break;
|
|
2187
|
-
case "eh:dismiss":
|
|
2188
|
-
if (!$("composeText").value.trim()) cancelCompose();
|
|
2189
|
-
break;
|
|
2190
|
-
case "eh:activate":
|
|
2191
|
-
setActive(msg.id, false);
|
|
2192
|
-
break;
|
|
2193
|
-
case "eh:anchorStatus":
|
|
2194
|
-
state.orphans = new Set(msg.orphaned || []);
|
|
2195
|
-
render();
|
|
2196
|
-
break;
|
|
2197
|
-
case "eh:notInView":
|
|
2198
|
-
toast("That comment is not visible in this view");
|
|
2199
|
-
break;
|
|
2200
|
-
case "eh:formBlocked":
|
|
2201
|
-
toast(String(msg.reason || "This form cannot be submitted from View mode"));
|
|
2202
|
-
break;
|
|
2203
|
-
case "eh:edit":
|
|
2204
|
-
editMutationSequence += 1;
|
|
2205
|
-
void persistEdit(state.key, {
|
|
2206
|
-
label: msg.label,
|
|
2207
|
-
kind: msg.kind,
|
|
2208
|
-
before: msg.before,
|
|
2209
|
-
after: msg.after,
|
|
2210
|
-
before_html: msg.before_html,
|
|
2211
|
-
after_html: msg.after_html,
|
|
2212
|
-
moved_after: msg.moved_after,
|
|
2213
|
-
moved_before: msg.moved_before,
|
|
2214
|
-
staged_assets: msg.staged_assets,
|
|
2215
|
-
});
|
|
2216
|
-
break;
|
|
2217
|
-
case "eh:asset": {
|
|
2218
|
-
const identity = currentRender();
|
|
2219
|
-
const query = new URLSearchParams({
|
|
2220
|
-
type: msg.assetType || "", sessionId: state.sessionId,
|
|
2221
|
-
renderId: identity.renderId, generation: String(identity.generation),
|
|
2222
|
-
savePolicy: state.renderExecution?.savePolicy || "feedback-only",
|
|
2223
|
-
});
|
|
2224
|
-
try {
|
|
2225
|
-
const saved = await fetch(`/api/page/${identity.key}/asset?${query}`, {
|
|
2226
|
-
method: "POST",
|
|
2227
|
-
headers: { "content-type": "application/octet-stream", "x-doc-review-token": state.token },
|
|
2228
|
-
body: msg.bytes,
|
|
2229
|
-
});
|
|
2230
|
-
const data = await saved.json();
|
|
2231
|
-
if (!sameRender(identity, currentRender())) break;
|
|
2232
|
-
if (!saved.ok) throw new Error(data.error || "could not save the pasted image");
|
|
2233
|
-
toFrame({ type: "eh:assetSaved", id: msg.id, src: data.src, stagedId: data.stagedId });
|
|
2234
|
-
} catch (err) {
|
|
2235
|
-
if (!sameRender(identity, currentRender())) break;
|
|
2236
|
-
toast(err.message);
|
|
2237
|
-
toFrame({ type: "eh:assetFailed", id: msg.id });
|
|
2238
|
-
}
|
|
2239
|
-
break;
|
|
2240
|
-
}
|
|
2241
|
-
case "eh:saving":
|
|
2242
|
-
state.domDirty = true;
|
|
2243
|
-
state.save = "saving";
|
|
2244
|
-
renderSave();
|
|
2245
|
-
break;
|
|
2246
|
-
case "eh:html":
|
|
2247
|
-
await saveNow(msg.html);
|
|
2248
|
-
break;
|
|
2249
|
-
case "eh:clean":
|
|
2250
|
-
// SDK equality is not a server acknowledgement: prior saves may still be failing.
|
|
2251
|
-
state.cleanObservation = {
|
|
2252
|
-
key: state.key, generation: state.renderGeneration,
|
|
2253
|
-
saveSequence, editSequence: editMutationSequence,
|
|
2254
|
-
};
|
|
2255
|
-
applyCleanObservation();
|
|
2256
|
-
break;
|
|
2257
|
-
case "eh:snapshot":
|
|
2258
|
-
captures.receive(msg);
|
|
2259
|
-
break;
|
|
2260
|
-
case "eh:viewChanged": {
|
|
2261
|
-
// Only a deduplicated identity-change signal can reopen this render's
|
|
2262
|
-
// automatic attempt. Snapshot and ordinary mutation messages cannot.
|
|
2263
|
-
captureCoordinator.reset();
|
|
2264
|
-
manualCaptureController?.abort();
|
|
2265
|
-
scheduleResultCapture();
|
|
2266
|
-
break;
|
|
2267
|
-
}
|
|
2268
|
-
case "eh:dynamic":
|
|
2269
|
-
state.dynamic = true;
|
|
2270
|
-
renderSave();
|
|
2271
|
-
break;
|
|
2272
|
-
case "eh:flushed":
|
|
2273
|
-
if (typeof msg.requestId === "string") flushRequests.get(msg.requestId)?.settle(true);
|
|
2274
|
-
else for (const request of flushRequests.values()) {
|
|
2275
|
-
if (!request.strict) request.settle(true);
|
|
2276
|
-
}
|
|
2277
|
-
break;
|
|
2278
|
-
case "eh:scroll":
|
|
2279
|
-
state.scroll = { x: msg.x, y: msg.y };
|
|
2280
|
-
break;
|
|
2281
|
-
case "eh:external": {
|
|
2282
|
-
// This side is what actually calls window.open, so it re-checks the
|
|
2283
|
-
// scheme rather than trusting the frame: a javascript: or data: URL
|
|
2284
|
-
// arriving here would run on this origin, next to the token.
|
|
2285
|
-
const external = externalHref(msg.href, location.href);
|
|
2286
|
-
if (external) window.open(external, "_blank", "noopener");
|
|
2287
|
-
break;
|
|
2288
|
-
}
|
|
2289
|
-
case "eh:navigate":
|
|
2290
|
-
try {
|
|
2291
|
-
if (draftCount(state)) throw new Error("Save or cancel open comment drafts before changing pages");
|
|
2292
|
-
await fullSaveBarrier();
|
|
2293
|
-
const result = await api(`/api/session/${state.sessionId}/navigate`, {
|
|
2294
|
-
method: "POST",
|
|
2295
|
-
body: JSON.stringify({ href: msg.href }),
|
|
2296
|
-
});
|
|
2297
|
-
state.scroll = { x: 0, y: 0 };
|
|
2298
|
-
await loadPage(result.key);
|
|
2299
|
-
} catch (err) {
|
|
2300
|
-
toast(err.message);
|
|
2301
|
-
}
|
|
2302
|
-
break;
|
|
2303
|
-
default:
|
|
2304
|
-
break;
|
|
2305
|
-
}
|
|
2306
|
-
});
|
|
2307
|
-
|
|
2308
|
-
// --------------------------------------------------------------- UI wiring
|
|
2309
|
-
|
|
2310
|
-
let composeComposing = false;
|
|
2311
|
-
|
|
2312
|
-
$("composeAdd").addEventListener("click", commitCompose);
|
|
2313
|
-
$("composeCancel").addEventListener("click", cancelCompose);
|
|
2314
|
-
$("composeClose").addEventListener("click", cancelCompose);
|
|
2315
|
-
$("composeReveal").addEventListener("click", () => {
|
|
2316
|
-
if (!state.compose) return;
|
|
2317
|
-
diagnostic("reveal-target-requested");
|
|
2318
|
-
toFrame({ type: "eh:revealTarget", targetGeneration: state.compose.generation });
|
|
2319
|
-
});
|
|
2320
|
-
|
|
2321
|
-
$("compose").addEventListener("mousedown", (event) => {
|
|
2322
|
-
if (event.target.closest("button")) return;
|
|
2323
|
-
if (event.target !== $("composeText")) $("composeText").focus();
|
|
2324
|
-
});
|
|
2325
|
-
|
|
2326
|
-
$("composeText").addEventListener("compositionstart", () => {
|
|
2327
|
-
composeComposing = true;
|
|
2328
|
-
});
|
|
2329
|
-
$("composeText").addEventListener("compositionend", () => {
|
|
2330
|
-
composeComposing = false;
|
|
2331
|
-
});
|
|
2332
|
-
$("composeText").addEventListener("keydown", (event) => {
|
|
2333
|
-
if (event.key === "Enter") {
|
|
2334
|
-
event.stopPropagation();
|
|
2335
|
-
if (composeComposing || event.isComposing || event.shiftKey) return;
|
|
2336
|
-
event.preventDefault();
|
|
2337
|
-
void commitCompose();
|
|
2338
|
-
}
|
|
2339
|
-
if (event.key === "Escape") {
|
|
2340
|
-
event.stopPropagation();
|
|
2341
|
-
event.preventDefault();
|
|
2342
|
-
cancelCompose();
|
|
2343
|
-
}
|
|
2344
|
-
});
|
|
2345
|
-
|
|
2346
|
-
$("modeButton").addEventListener("click", () => {
|
|
2347
|
-
if ($("modeMenu").hidden) openModeMenu();
|
|
2348
|
-
else closeModeMenu("trigger", { restoreFocus: true });
|
|
2349
|
-
});
|
|
2350
|
-
$("modeMenu").addEventListener("click", (event) => {
|
|
2351
|
-
const item = event.target.closest("[data-mode]");
|
|
2352
|
-
if (item) void setReviewMode(item.dataset.mode);
|
|
2353
|
-
});
|
|
2354
|
-
$("modeMenu").addEventListener("keydown", (event) => {
|
|
2355
|
-
const items = [...$("modeMenu").querySelectorAll("[data-mode]")];
|
|
2356
|
-
const current = items.indexOf(document.activeElement);
|
|
2357
|
-
if (event.key === "ArrowDown" || event.key === "ArrowUp") {
|
|
2358
|
-
event.preventDefault();
|
|
2359
|
-
const delta = event.key === "ArrowDown" ? 1 : -1;
|
|
2360
|
-
items[(current + delta + items.length) % items.length].focus();
|
|
2361
|
-
} else if (event.key === "Escape") {
|
|
2362
|
-
event.preventDefault();
|
|
2363
|
-
event.stopPropagation();
|
|
2364
|
-
closeModeMenu("escape", { restoreFocus: true });
|
|
2365
|
-
}
|
|
2366
|
-
});
|
|
2367
|
-
$("commentsButton").addEventListener("click", openDrawer);
|
|
2368
|
-
$("drawerClose").addEventListener("click", closeDrawer);
|
|
2369
|
-
$("drawerBackdrop").addEventListener("click", closeDrawer);
|
|
2370
|
-
|
|
2371
|
-
const dismissModeMenuOutside = (event) => {
|
|
2372
|
-
if (!state.modeMenuOpen || event.target.closest(".mode-control")) return;
|
|
2373
|
-
closeModeMenu(event.type === "focusin" ? "parent-focus" : "parent-pointer");
|
|
2374
|
-
};
|
|
2375
|
-
document.addEventListener("pointerdown", dismissModeMenuOutside, true);
|
|
2376
|
-
document.addEventListener("focusin", dismissModeMenuOutside, true);
|
|
2377
|
-
|
|
2378
|
-
function closeCommentMenu({ restoreFocus = false } = {}) {
|
|
2379
|
-
const menu = state.commentUi.menu;
|
|
2380
|
-
if (!menu) return false;
|
|
2381
|
-
clearOwned(state.commentUi, "menu");
|
|
2382
|
-
toFrame({ type: "eh:modeMenuState", open: false });
|
|
2383
|
-
if (restoreFocus) requestControlFocus(menu.triggerId);
|
|
2384
|
-
render();
|
|
2385
|
-
return true;
|
|
2386
|
-
}
|
|
2387
|
-
|
|
2388
|
-
const dismissCommentMenuOutside = (event) => {
|
|
2389
|
-
const menu = state.commentUi.menu;
|
|
2390
|
-
if (!menu) return;
|
|
2391
|
-
const menuElement = document.getElementById(controlId(menu.commentId, menu.surface, "menu"));
|
|
2392
|
-
const trigger = document.getElementById(menu.triggerId);
|
|
2393
|
-
if (menuElement?.contains(event.target) || trigger?.contains(event.target)) return;
|
|
2394
|
-
closeCommentMenu();
|
|
2395
|
-
};
|
|
2396
|
-
document.addEventListener("pointerdown", dismissCommentMenuOutside, true);
|
|
2397
|
-
document.addEventListener("focusin", dismissCommentMenuOutside, true);
|
|
2398
|
-
|
|
2399
|
-
async function sendFeedback() {
|
|
2400
|
-
if (state.sending || state.ended) return;
|
|
2401
|
-
const identity = currentRender();
|
|
2402
|
-
const sentNote = $("note").value;
|
|
2403
|
-
state.sending = true;
|
|
2404
|
-
if ($("captureWarning")) $("captureWarning").hidden = true;
|
|
2405
|
-
captureCoordinator.reset();
|
|
2406
|
-
manualCaptureController?.abort();
|
|
2407
|
-
render();
|
|
2408
|
-
let phase = "saving";
|
|
2409
|
-
let delivered = false;
|
|
2410
|
-
try {
|
|
2411
|
-
const outcome = await deliverFeedback({
|
|
2412
|
-
save: fullSaveBarrier,
|
|
2413
|
-
capture: () => captureStablePage({ timeout: 500 }),
|
|
2414
|
-
deliver: async (snapshot) => {
|
|
2415
|
-
// A snapshot flush can reveal more queued writes. Those are still required.
|
|
2416
|
-
await activeSavePromise;
|
|
2417
|
-
await settleEditPersistence(state.key);
|
|
2418
|
-
if (state.ended || !sameRender(identity, currentRender())) throw new Error("The page changed before sending. Retry on the current page.");
|
|
2419
|
-
if (state.saveConflict || state.save === "failed" ||
|
|
2420
|
-
(state.savePolicy === "writable" && !state.dynamic && state.domDirty)) {
|
|
2421
|
-
throw new Error("Your page edits have not finished saving.");
|
|
2422
|
-
}
|
|
2423
|
-
phase = "delivery";
|
|
2424
|
-
const result = await api(`/api/page/${state.key}/send`, {
|
|
2425
|
-
method: "POST",
|
|
2426
|
-
body: JSON.stringify({
|
|
2427
|
-
sessionId: state.sessionId, note: sentNote.trim(),
|
|
2428
|
-
history: {
|
|
2429
|
-
renderId: state.renderId, generation: state.renderGeneration,
|
|
2430
|
-
semantic: snapshot?.semantic || null,
|
|
2431
|
-
semanticCapturedAt: snapshot?.semanticCapturedAt, view: snapshot?.view,
|
|
2432
|
-
expectedSourceHash: snapshot?.sourceHash || state.baseHash || state.renderSourceHash,
|
|
2433
|
-
allowUnavailable: true,
|
|
2434
|
-
},
|
|
2435
|
-
}),
|
|
2436
|
-
});
|
|
2437
|
-
if (result.ok === false) throw new Error(result.error || "Feedback delivery was not confirmed");
|
|
2438
|
-
},
|
|
2439
|
-
committed() {
|
|
2440
|
-
delivered = true;
|
|
2441
|
-
if ($("note").value === sentNote) $("note").value = "";
|
|
2442
|
-
state.sent = true;
|
|
2443
|
-
},
|
|
2444
|
-
refresh: refreshHistory,
|
|
2445
|
-
});
|
|
2446
|
-
if (outcome.refreshFailure) toast("Feedback sent. History could not be refreshed; your feedback does not need to be sent again.");
|
|
2447
|
-
else if (outcome.captureFailure) announce("Feedback sent. Content comparison may be incomplete.");
|
|
2448
|
-
} catch (err) {
|
|
2449
|
-
const message = delivered ? "Feedback sent. History is temporarily unavailable; do not send it again."
|
|
2450
|
-
: phase === "saving" ? `Feedback not sent: ${err.message} Your feedback and drafts are still here.`
|
|
2451
|
-
: `Feedback delivery was not confirmed: ${err.message} No automatic retry was made. Check the agent before sending again.`;
|
|
2452
|
-
toast(message);
|
|
2453
|
-
announce(message);
|
|
2454
|
-
} finally {
|
|
2455
|
-
state.sending = false;
|
|
2456
|
-
render();
|
|
2457
|
-
scheduleResultCapture();
|
|
2458
|
-
}
|
|
2459
|
-
}
|
|
2460
|
-
$("send").addEventListener("click", () => void sendFeedback());
|
|
2461
|
-
|
|
2462
|
-
$("revert").addEventListener("click", async () => {
|
|
2463
|
-
const count = state.page.edits.length;
|
|
2464
|
-
if (!window.confirm(`Discard all ${count} of your edits?`)) return;
|
|
2465
|
-
const identity = currentRender();
|
|
2466
|
-
// Stop the SDK's debounced save and our own retries first, so a queued save
|
|
2467
|
-
// can't land after the revert and write the edits straight back.
|
|
2468
|
-
toFrame({ type: "eh:abortSave" });
|
|
2469
|
-
clearTimeout(retryTimer);
|
|
2470
|
-
try {
|
|
2471
|
-
await activeSavePromise;
|
|
2472
|
-
await settleEditPersistence(identity.key).catch(() => {});
|
|
2473
|
-
if (!sameRender(identity, currentRender())) throw new Error("The page changed before reverting. Retry on the current page.");
|
|
2474
|
-
const baseHash = state.baseHash;
|
|
2475
|
-
editBacklogs.delete(identity.key);
|
|
2476
|
-
editErrors.delete(identity.key);
|
|
2477
|
-
const result = await api(`/api/page/${identity.key}/revert`, {
|
|
2478
|
-
method: "POST",
|
|
2479
|
-
body: JSON.stringify({
|
|
2480
|
-
sessionId: state.sessionId, renderId: identity.renderId, generation: identity.generation, baseHash,
|
|
2481
|
-
}),
|
|
2482
|
-
});
|
|
2483
|
-
if (state.key !== identity.key) return;
|
|
2484
|
-
state.page = result.page;
|
|
2485
|
-
state.save = "idle";
|
|
2486
|
-
state.savedAt = "";
|
|
2487
|
-
render();
|
|
2488
|
-
} catch (err) {
|
|
2489
|
-
toast(err.message);
|
|
2490
|
-
}
|
|
2491
|
-
});
|
|
2492
|
-
|
|
2493
|
-
/** The session is over: freeze the page and say so. Feedback is already safe. */
|
|
2494
|
-
function showEnded() {
|
|
2495
|
-
if (document.querySelector(".ended")) return;
|
|
2496
|
-
state.ended = true;
|
|
2497
|
-
captureCoordinator.stop();
|
|
2498
|
-
manualCaptureController?.abort();
|
|
2499
|
-
captures.cancel("Review ended");
|
|
2500
|
-
historyController.cancel();
|
|
2501
|
-
if (events) events.close();
|
|
2502
|
-
clearTimeout(retryTimer);
|
|
2503
|
-
const overlay = document.createElement("div");
|
|
2504
|
-
overlay.className = "ended";
|
|
2505
|
-
const title = document.createElement("h2");
|
|
2506
|
-
title.textContent = "Review ended";
|
|
2507
|
-
const line = document.createElement("p");
|
|
2508
|
-
line.textContent = "Unsent feedback is saved and ships next time you review this page. You can close this tab.";
|
|
2509
|
-
overlay.append(title, line);
|
|
2510
|
-
document.body.append(overlay);
|
|
2511
|
-
}
|
|
2512
|
-
|
|
2513
|
-
$("endReview").addEventListener("click", async () => {
|
|
2514
|
-
const page = state.page;
|
|
2515
|
-
const otherTotal = (state.others || []).reduce((sum, o) => sum + o.count, 0);
|
|
2516
|
-
const unsent = page ? (page.comments || []).length + (page.edits || []).length + otherTotal : 0;
|
|
2517
|
-
const message = unsent
|
|
2518
|
-
? `End this review? ${unsent} unsent ${unsent === 1 ? "item" : "items"} will be kept for next time.`
|
|
2519
|
-
: "End this review? The waiting agent will be told to stop polling.";
|
|
2520
|
-
if (!window.confirm(message)) return;
|
|
2521
|
-
try {
|
|
2522
|
-
// Ship anything still sitting in the SDK's debounce windows first.
|
|
2523
|
-
await flushFrame();
|
|
2524
|
-
await api(`/api/session/${state.sessionId}/end`, { method: "POST" });
|
|
2525
|
-
showEnded();
|
|
2526
|
-
} catch (err) {
|
|
2527
|
-
toast(err.message);
|
|
2528
|
-
}
|
|
2529
|
-
});
|
|
2530
|
-
|
|
2531
|
-
$("handoffCopy").addEventListener("click", async (event) => {
|
|
2532
|
-
const button = event.currentTarget;
|
|
2533
|
-
try {
|
|
2534
|
-
await navigator.clipboard.writeText($("handoffCmd").textContent);
|
|
2535
|
-
button.textContent = "Copied";
|
|
2536
|
-
setTimeout(() => {
|
|
2537
|
-
button.textContent = "Copy prompt";
|
|
2538
|
-
}, 1600);
|
|
2539
|
-
} catch {
|
|
2540
|
-
toast("Couldn't copy — select the prompt and copy it manually");
|
|
2541
|
-
}
|
|
2542
|
-
});
|
|
2543
|
-
|
|
2544
|
-
$("note").addEventListener("input", (event) => {
|
|
2545
|
-
const el = event.target;
|
|
2546
|
-
el.style.height = "auto";
|
|
2547
|
-
el.style.height = `${Math.min(el.scrollHeight + 2, window.innerHeight * 0.4)}px`;
|
|
2548
|
-
render(); // keep the send button in step with note-only feedback
|
|
2549
|
-
});
|
|
2550
|
-
|
|
2551
|
-
$("theme").addEventListener("click", () => {
|
|
2552
|
-
const dark = document.documentElement.dataset.theme !== "dark";
|
|
2553
|
-
applyTheme(dark);
|
|
2554
|
-
try {
|
|
2555
|
-
localStorage.setItem("doc-review:theme", dark ? "dark" : "light");
|
|
2556
|
-
} catch {}
|
|
2557
|
-
});
|
|
2558
|
-
|
|
2559
|
-
function applyTheme(dark) {
|
|
2560
|
-
document.documentElement.dataset.theme = dark ? "dark" : "light";
|
|
2561
|
-
const button = $("theme");
|
|
2562
|
-
button.title = dark ? "Switch chrome to light" : "Switch chrome to dark";
|
|
2563
|
-
button.setAttribute("aria-label", button.title);
|
|
2564
|
-
replaceIcon(button, dark ? "sun" : "moon");
|
|
2565
|
-
}
|
|
2566
|
-
|
|
2567
|
-
document.addEventListener("keydown", (event) => {
|
|
2568
|
-
const meta = event.metaKey || event.ctrlKey;
|
|
2569
|
-
// ⌘S is reassurance only: flush pending keystrokes, never a state change.
|
|
2570
|
-
if (meta && event.key.toLowerCase() === "s") {
|
|
2571
|
-
event.preventDefault();
|
|
2572
|
-
toFrame({ type: "eh:flush" });
|
|
2573
|
-
renderSave();
|
|
2574
|
-
return;
|
|
2575
|
-
}
|
|
2576
|
-
if (event.key === "Tab" && state.commentUi.menu) {
|
|
2577
|
-
closeCommentMenu();
|
|
2578
|
-
return;
|
|
2579
|
-
}
|
|
2580
|
-
if (event.key !== "Escape") return;
|
|
2581
|
-
const recoveryMenu = event.target.closest?.("#reviewDetails");
|
|
2582
|
-
if (recoveryMenu) {
|
|
2583
|
-
recoveryMenu.open = false;
|
|
2584
|
-
recoveryMenu.querySelector("summary")?.focus({ preventScroll: true });
|
|
2585
|
-
event.preventDefault();
|
|
2586
|
-
event.stopPropagation();
|
|
2587
|
-
return;
|
|
2588
|
-
}
|
|
2589
|
-
let handled = false;
|
|
2590
|
-
if (state.commentUi.confirmation) handled = cancelDeleteConfirmation();
|
|
2591
|
-
else if (state.commentUi.menu) handled = closeCommentMenu({ restoreFocus: true });
|
|
2592
|
-
else if (state.commentUi.edit) handled = cancelCommentEdit();
|
|
2593
|
-
else if (state.compose) handled = cancelCompose();
|
|
2594
|
-
else if (state.modeMenuOpen) handled = closeModeMenu("escape", { restoreFocus: true });
|
|
2595
|
-
else if (state.drawerOpen) {
|
|
2596
|
-
closeDrawer();
|
|
2597
|
-
handled = true;
|
|
2598
|
-
} else if (state.activeSavedCommentId) handled = dismissActiveComment("escape", { restoreFocus: true });
|
|
2599
|
-
if (handled) {
|
|
2600
|
-
event.preventDefault();
|
|
2601
|
-
event.stopPropagation();
|
|
2602
|
-
}
|
|
2603
|
-
});
|
|
2604
|
-
|
|
2605
|
-
// ------------------------------------------------------------------ events
|
|
2606
|
-
|
|
2607
|
-
let events = null;
|
|
2608
|
-
|
|
2609
|
-
function holdReload() {
|
|
2610
|
-
state.pendingReload = true;
|
|
2611
|
-
state.baseHash = null;
|
|
2612
|
-
clearTimeout(retryTimer);
|
|
2613
|
-
toFrame({ type: "eh:abortSave" });
|
|
2614
|
-
if (state.domDirty) state.saveConflict = true;
|
|
2615
|
-
$("reloadNotice").hidden = false;
|
|
2616
|
-
$("reloadMessage").textContent = state.domDirty || state.saveConflict
|
|
2617
|
-
? "The source changed. This page has unsaved edits; reload discards those page edits, but keeps open comment drafts. Stale edits will not overwrite the new source."
|
|
2618
|
-
: "The source changed. Reload keeps your comment drafts as unresolved excerpts so they cannot attach to the wrong content.";
|
|
2619
|
-
captureCoordinator.reset();
|
|
2620
|
-
manualCaptureController?.abort();
|
|
2621
|
-
captures.cancel();
|
|
2622
|
-
renderExecution();
|
|
2623
|
-
if (state.comparing) renderHistory();
|
|
2624
|
-
}
|
|
2625
|
-
|
|
2626
|
-
async function reloadLatest({ explicit = false } = {}) {
|
|
2627
|
-
if (!explicit && (draftCount(state) || state.domDirty || state.saveConflict || state.sending)) {
|
|
2628
|
-
holdReload();
|
|
2629
|
-
return;
|
|
2630
|
-
}
|
|
2631
|
-
const key = state.key;
|
|
2632
|
-
advancePageEpoch("reload");
|
|
2633
|
-
captureEditState();
|
|
2634
|
-
if (state.compose) {
|
|
2635
|
-
state.compose.unresolved = true;
|
|
2636
|
-
state.compose.relation = "unavailable";
|
|
2637
|
-
state.compose.rects = [];
|
|
2638
|
-
state.compose.generation = -1;
|
|
2639
|
-
}
|
|
2640
|
-
const generation = beginFrameTransition();
|
|
2641
|
-
state.frameReadyRetries = 0;
|
|
2642
|
-
state.reloading = true;
|
|
2643
|
-
state.dynamic = false;
|
|
2644
|
-
state.baseHash = null;
|
|
2645
|
-
clearTimeout(retryTimer);
|
|
2646
|
-
state.pendingReload = false;
|
|
2647
|
-
state.saveConflict = false;
|
|
2648
|
-
state.domDirty = false;
|
|
2649
|
-
$("reloadNotice").hidden = true;
|
|
2650
|
-
try {
|
|
2651
|
-
const page = await api(pageUrl(key, state.sessionId));
|
|
2652
|
-
if (state.key !== key || state.renderGeneration !== generation) return;
|
|
2653
|
-
state.executionPreference = page.executionPreference || null;
|
|
2654
|
-
// Keep parent-owned drafts rather than replacing their text on a source update.
|
|
2655
|
-
replacePage(state, page);
|
|
2656
|
-
state.save = "idle";
|
|
2657
|
-
state.savedAt = "";
|
|
2658
|
-
render();
|
|
2659
|
-
if (state.compose) {
|
|
2660
|
-
$("compose").hidden = false;
|
|
2661
|
-
$("compose").classList.add("sheet");
|
|
2662
|
-
$("composeError").hidden = false;
|
|
2663
|
-
$("composeError").textContent = "The source changed. This draft keeps its original excerpt; cancel it to select a new target.";
|
|
2664
|
-
}
|
|
2665
|
-
await registerFrame(key, generation);
|
|
2666
|
-
} catch (err) {
|
|
2667
|
-
if (state.key === key && state.renderGeneration === generation) failFrame(err.message);
|
|
2668
|
-
}
|
|
2669
|
-
}
|
|
2670
|
-
$("safeReload").addEventListener("click", () => void reloadLatest({ explicit: true }));
|
|
2671
|
-
$("keepCurrent").addEventListener("click", () => { $("reloadNotice").hidden = true; announce("Keeping the current page. Return to Latest version to reload when ready."); });
|
|
2672
|
-
window.addEventListener("beforeunload", (event) => {
|
|
2673
|
-
if (!state.domDirty && !draftCount(state, $("note").value) && !editBacklogs.get(state.key)?.size) return;
|
|
2674
|
-
event.preventDefault();
|
|
2675
|
-
event.returnValue = "";
|
|
2676
|
-
});
|
|
2677
|
-
|
|
2678
|
-
function connect() {
|
|
2679
|
-
const source = new EventSource(`/events/${state.sessionId}`);
|
|
2680
|
-
events = source;
|
|
2681
|
-
// Another window on this session hit End review.
|
|
2682
|
-
source.addEventListener("ended", () => showEnded());
|
|
2683
|
-
source.addEventListener("reload", () => void reloadLatest());
|
|
2684
|
-
source.addEventListener("history", () => void refreshHistory());
|
|
2685
|
-
source.addEventListener("agent", (event) => {
|
|
2686
|
-
state.agent = JSON.parse(event.data).state;
|
|
2687
|
-
render();
|
|
2688
|
-
});
|
|
2689
|
-
source.addEventListener("refresh", async () => {
|
|
2690
|
-
const key = state.key;
|
|
2691
|
-
advancePageEpoch("acknowledgement");
|
|
2692
|
-
const page = await api(pageUrl(key, state.sessionId));
|
|
2693
|
-
if (state.key !== key) return;
|
|
2694
|
-
reconcilePage(page, { syncAnchors: true, reason: "acknowledgement", advance: false });
|
|
2695
|
-
state.sent = false;
|
|
2696
|
-
render();
|
|
2697
|
-
});
|
|
2698
|
-
source.onerror = () => {
|
|
2699
|
-
/* EventSource reconnects on its own. */
|
|
2700
|
-
};
|
|
2701
|
-
}
|
|
2702
|
-
|
|
2703
|
-
// -------------------------------------------------------------------- start
|
|
2704
|
-
|
|
2705
|
-
(async function start() {
|
|
2706
|
-
installStaticIcons();
|
|
2707
|
-
try {
|
|
2708
|
-
applyTheme(localStorage.getItem("doc-review:theme") === "dark");
|
|
2709
|
-
} catch {}
|
|
2710
|
-
|
|
2711
|
-
const bootstrap = await api(`/api/session/${state.sessionId}/page`).catch(() => null);
|
|
2712
|
-
if (bootstrap && bootstrap.page) state.pollCommand = bootstrap.page.pollCommand;
|
|
2713
|
-
if (bootstrap && Number.isSafeInteger(bootstrap.generation)) state.renderGeneration = bootstrap.generation;
|
|
2714
|
-
const key = bootstrap ? bootstrap.key : new URLSearchParams(location.search).get("key");
|
|
2715
|
-
await loadPage(key);
|
|
2716
|
-
connect();
|
|
2717
|
-
void refreshHistory();
|
|
2718
|
-
})();
|
|
2719
|
-
|
|
2720
|
-
window.addEventListener("resize", () => {
|
|
2721
|
-
positionCompose();
|
|
2722
|
-
if (state.activeSavedCommentId) positionAlignedCard();
|
|
2723
|
-
});
|
|
2724
|
-
if (window.visualViewport) {
|
|
2725
|
-
window.visualViewport.addEventListener("resize", positionCompose);
|
|
2726
|
-
window.visualViewport.addEventListener("scroll", positionCompose);
|
|
2727
|
-
}
|
|
2728
|
-
|
|
2729
|
-
function attachFrameEvents(target) {
|
|
2730
|
-
target.addEventListener("load", () => {
|
|
2731
|
-
if (target !== frame) return;
|
|
2732
|
-
if (!state.renderId || !state.frameCapability) return;
|
|
2733
|
-
if (state.pendingInitialLoad) {
|
|
2734
|
-
state.pendingInitialLoad = false;
|
|
2735
|
-
return;
|
|
2736
|
-
}
|
|
2737
|
-
rotateCurrentFrame();
|
|
2738
|
-
});
|
|
2739
|
-
|
|
2740
|
-
target.addEventListener("focus", () => {
|
|
2741
|
-
if (target !== frame) return;
|
|
2742
|
-
closeCommentMenu();
|
|
2743
|
-
if (state.compose) $("compose").classList.add("pass-through");
|
|
2744
|
-
});
|
|
2745
|
-
}
|
|
2746
|
-
attachFrameEvents(frame);
|
|
2747
|
-
$("compose").addEventListener("focusin", () => $("compose").classList.remove("pass-through"));
|