@erdemtuna/doc-review 0.10.0 → 0.11.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +13 -2
- package/lib/SKILL.md +5 -2
- package/lib/changes-controller.js +184 -0
- package/lib/chrome-client.js +361 -916
- package/lib/chrome-session.js +11 -14
- package/lib/chrome.html +19 -162
- package/lib/comments-controller.js +109 -0
- package/lib/contextual-controller.js +33 -0
- package/lib/controller-store.js +96 -0
- package/lib/execution-client.js +1 -51
- package/lib/feedback-controller.js +21 -14
- package/lib/feedback-panel-controller.js +161 -0
- package/lib/history-coordinator.js +7 -0
- package/lib/icons.js +76 -0
- package/lib/recovery-controller.js +123 -0
- package/lib/save-controller.js +13 -10
- package/lib/server.js +4 -4
- package/lib/toolbar-controller.js +32 -0
- package/lib/ui/THIRD_PARTY_NOTICES.md +1254 -0
- package/lib/ui/chrome.css +4881 -0
- package/lib/ui/chrome.js +52 -0
- package/package.json +31 -5
- package/lib/chrome.css +0 -652
- package/lib/comparison-view.js +0 -380
package/lib/chrome-client.js
CHANGED
|
@@ -5,23 +5,27 @@
|
|
|
5
5
|
* postMessage, because the artifact iframe lives on the other loopback
|
|
6
6
|
* hostname: a separate origin that can never reach this page or its token.
|
|
7
7
|
*/
|
|
8
|
-
import {
|
|
9
|
-
import { clearOwned, createCommentUi, migrateCommentUi, mutationIsCurrent, ownConfirmation, ownEdit, ownMenu, newestComments, pageUrl, reconcileCommentUi, replacePage, } from "./chrome-session.js";
|
|
8
|
+
import { clearOwned, commentControlId as controlId, createCommentUi, migrateCommentUi, mutationIsCurrent, ownConfirmation, ownEdit, newestComments, pageUrl, reconcileCommentUi, replacePage, } from "./chrome-session.js";
|
|
10
9
|
import { sanitizeClientRects, sanitizeClipRect, sanitizeRelation } from "./comment-target.js";
|
|
11
10
|
import { externalHref } from "./editing.js";
|
|
12
11
|
import { framePolicy } from "./frame-policy.js";
|
|
13
12
|
import { createIcon } from "./icons.js";
|
|
14
|
-
import {
|
|
15
|
-
import {
|
|
13
|
+
import { executionPresentation } from "./execution-client.js";
|
|
14
|
+
import { createToolbarController } from "./toolbar-controller.js";
|
|
15
|
+
import { createRecoveryController } from "./recovery-controller.js";
|
|
16
|
+
import { createCommentsController } from "./comments-controller.js";
|
|
17
|
+
import { createComposerDraft, createContextualController } from "./contextual-controller.js";
|
|
16
18
|
import { alignedCardPosition, placeContextualSurface, visibleViewport } from "./positioning.js";
|
|
17
19
|
import { normalizeReviewMode, reviewConfiguration } from "./review-mode.js";
|
|
18
|
-
import { createCaptureRequests, captureError, sameRender, draftCount,
|
|
19
|
-
import { createCaptureCoordinator, createHistoryController,
|
|
20
|
+
import { createCaptureRequests, captureError, sameRender, draftCount, pendingCaptureTarget } from "./history-client.js";
|
|
21
|
+
import { createCaptureCoordinator, createHistoryController, requireCaptureSuccess } from "./history-coordinator.js";
|
|
20
22
|
import { createReviewApi, decodePage } from "./chrome-api.js";
|
|
21
23
|
import { createFrameHost } from "./frame-host.js";
|
|
22
24
|
import { createFrameController } from "./frame-controller.js";
|
|
23
25
|
import { createSaveController } from "./save-controller.js";
|
|
24
26
|
import { createFeedbackController } from "./feedback-controller.js";
|
|
27
|
+
import { createFeedbackPanelController, createNoteDraft } from "./feedback-panel-controller.js";
|
|
28
|
+
import { createChangesController } from "./changes-controller.js";
|
|
25
29
|
import { createReviewController } from "./review-controller.js";
|
|
26
30
|
const $ = (id) => document.getElementById(id);
|
|
27
31
|
const ARTIFACT_HOST = location.hostname === "127.0.0.1" ? "localhost" : "127.0.0.1";
|
|
@@ -57,6 +61,7 @@ const state = {
|
|
|
57
61
|
token: document.body.dataset.token,
|
|
58
62
|
page: null,
|
|
59
63
|
compose: null,
|
|
64
|
+
composerDraft: createComposerDraft(),
|
|
60
65
|
composeLifecycle: "closed",
|
|
61
66
|
composePlacement: "hidden",
|
|
62
67
|
target: null,
|
|
@@ -68,16 +73,21 @@ const state = {
|
|
|
68
73
|
savePolicy: "writable",
|
|
69
74
|
modeApplying: false,
|
|
70
75
|
modeMenuOpen: false,
|
|
76
|
+
restoreModeFocus: false,
|
|
77
|
+
theme: "light",
|
|
71
78
|
drawerOpen: false,
|
|
72
79
|
agent: "idle",
|
|
73
80
|
orphans: new Set(),
|
|
74
81
|
pollCommand: "",
|
|
75
|
-
|
|
82
|
+
noteDraft: createNoteDraft(),
|
|
76
83
|
others: [],
|
|
77
84
|
scroll: { x: 0, y: 0 },
|
|
78
85
|
comparing: false,
|
|
79
86
|
executionPreference: null,
|
|
80
87
|
ended: false,
|
|
88
|
+
reloadVisible: false,
|
|
89
|
+
reloadMessage: "",
|
|
90
|
+
reloadFailed: false,
|
|
81
91
|
};
|
|
82
92
|
const diagnostic = (event, detail = {}) => {
|
|
83
93
|
console.info("[doc-review]", { event, ...detail });
|
|
@@ -95,13 +105,10 @@ function advancePageEpoch(reason) {
|
|
|
95
105
|
function commentById(id) {
|
|
96
106
|
return state.page?.comments?.find((comment) => comment.id === id) || null;
|
|
97
107
|
}
|
|
98
|
-
function domId(value) {
|
|
99
|
-
return String(value).replace(/[^a-zA-Z0-9_-]/g, (char) => `-${char.codePointAt(0).toString(16)}-`);
|
|
100
|
-
}
|
|
101
|
-
function controlId(commentId, surface, action) {
|
|
102
|
-
return `comment-${surface}-${domId(commentId)}-${action}`;
|
|
103
|
-
}
|
|
104
108
|
function requestControlFocus(id) {
|
|
109
|
+
if (state.drawerOpen && document.getElementById(id)?.closest("#cards")) {
|
|
110
|
+
commentsRuntime.commands.setSectionOpen(true);
|
|
111
|
+
}
|
|
105
112
|
requestedFocus = id;
|
|
106
113
|
}
|
|
107
114
|
function editTextarea(commentId) {
|
|
@@ -126,7 +133,12 @@ function restoreTransientFocus(editWasFocused = false) {
|
|
|
126
133
|
const shouldFocusEdit = editWasFocused || editFocusRequested;
|
|
127
134
|
afterPaint(() => {
|
|
128
135
|
if (focusId) {
|
|
129
|
-
document.getElementById(focusId)
|
|
136
|
+
const target = document.getElementById(focusId);
|
|
137
|
+
if (target?.closest("[hidden], [inert]")) {
|
|
138
|
+
$(state.comparing ? "latestVersion" : state.drawerOpen ? "commentsSection" : "commentsButton").focus();
|
|
139
|
+
}
|
|
140
|
+
else
|
|
141
|
+
target?.focus();
|
|
130
142
|
return;
|
|
131
143
|
}
|
|
132
144
|
const edit = state.commentUi.edit;
|
|
@@ -138,16 +150,14 @@ function restoreTransientFocus(editWasFocused = false) {
|
|
|
138
150
|
return;
|
|
139
151
|
}
|
|
140
152
|
editFocusRequested = false;
|
|
153
|
+
// A mounted React editor already owns its live selection; delayed focus must not reset it.
|
|
154
|
+
if (document.activeElement === input)
|
|
155
|
+
return;
|
|
141
156
|
input.focus();
|
|
142
157
|
input.setSelectionRange(edit.selectionStart, edit.selectionEnd);
|
|
143
158
|
});
|
|
144
159
|
}
|
|
145
160
|
function moveTransientSurface(surface) {
|
|
146
|
-
const menu = state.commentUi.menu;
|
|
147
|
-
if (menu) {
|
|
148
|
-
menu.surface = surface;
|
|
149
|
-
menu.triggerId = controlId(menu.commentId, surface, "more");
|
|
150
|
-
}
|
|
151
161
|
if (state.commentUi.confirmation)
|
|
152
162
|
state.commentUi.confirmation.surface = surface;
|
|
153
163
|
}
|
|
@@ -166,29 +176,24 @@ function installStaticIcons() {
|
|
|
166
176
|
document.querySelectorAll("[data-icon]").forEach((container) => {
|
|
167
177
|
replaceIcon(container, container.dataset.icon);
|
|
168
178
|
});
|
|
169
|
-
replaceIcon($("modeChevron"), "chevronDown");
|
|
170
|
-
replaceIcon($("composeClose"), "x");
|
|
171
|
-
replaceIcon($("drawerClose"), "x");
|
|
172
179
|
}
|
|
173
180
|
function openModeMenu() {
|
|
174
181
|
if (state.modeMenuOpen)
|
|
175
182
|
return;
|
|
183
|
+
recoveryRuntime.commands.setMenuOpen(false, { restoreFocus: false });
|
|
176
184
|
state.modeMenuOpen = true;
|
|
177
|
-
|
|
178
|
-
|
|
185
|
+
state.restoreModeFocus = true;
|
|
186
|
+
toolbarRuntime.publish();
|
|
179
187
|
toFrame({ type: "eh:modeMenuState", open: true });
|
|
180
|
-
$("modeMenu").querySelector(`[data-mode="${state.reviewMode}"]`)?.focus();
|
|
181
188
|
}
|
|
182
189
|
function closeModeMenu(reason = "dismissed", { restoreFocus = false } = {}) {
|
|
183
190
|
if (!state.modeMenuOpen)
|
|
184
191
|
return false;
|
|
185
192
|
state.modeMenuOpen = false;
|
|
186
|
-
|
|
187
|
-
|
|
193
|
+
state.restoreModeFocus = restoreFocus;
|
|
194
|
+
toolbarRuntime.publish();
|
|
188
195
|
toFrame({ type: "eh:modeMenuState", open: false });
|
|
189
196
|
diagnostic("mode-menu-close", { reason });
|
|
190
|
-
if (restoreFocus)
|
|
191
|
-
$("modeButton").focus();
|
|
192
197
|
return true;
|
|
193
198
|
}
|
|
194
199
|
function openDrawer() {
|
|
@@ -209,7 +214,6 @@ function closeDrawer() {
|
|
|
209
214
|
state.drawerOpen = false;
|
|
210
215
|
const transientOwner = state.commentUi.edit?.commentId ||
|
|
211
216
|
state.commentUi.confirmation?.commentId ||
|
|
212
|
-
state.commentUi.menu?.commentId ||
|
|
213
217
|
null;
|
|
214
218
|
if (transientOwner && commentById(transientOwner)) {
|
|
215
219
|
state.activeSavedCommentId = transientOwner;
|
|
@@ -229,9 +233,7 @@ function setComposeLifecycle(next, reason) {
|
|
|
229
233
|
return;
|
|
230
234
|
const from = state.composeLifecycle;
|
|
231
235
|
state.composeLifecycle = next;
|
|
232
|
-
|
|
233
|
-
$("composeCancel").disabled = submitting;
|
|
234
|
-
$("composeClose").disabled = submitting;
|
|
236
|
+
contextualRuntime.publish();
|
|
235
237
|
diagnostic("composer-lifecycle-transition", { from, to: next, reason });
|
|
236
238
|
}
|
|
237
239
|
function setComposePlacement(next) {
|
|
@@ -239,6 +241,7 @@ function setComposePlacement(next) {
|
|
|
239
241
|
return;
|
|
240
242
|
const from = state.composePlacement;
|
|
241
243
|
state.composePlacement = next;
|
|
244
|
+
contextualRuntime.publish();
|
|
242
245
|
diagnostic("placement-transition", { from, to: next });
|
|
243
246
|
}
|
|
244
247
|
/**
|
|
@@ -275,8 +278,10 @@ const frameController = createFrameController({
|
|
|
275
278
|
saveController.baseline(null);
|
|
276
279
|
},
|
|
277
280
|
failed(message) {
|
|
278
|
-
|
|
279
|
-
|
|
281
|
+
state.reloadVisible = true;
|
|
282
|
+
state.reloadFailed = true;
|
|
283
|
+
state.reloadMessage = `${message} Reload keeps your comment drafts.`;
|
|
284
|
+
recoveryRuntime.publish();
|
|
280
285
|
toast(message);
|
|
281
286
|
},
|
|
282
287
|
});
|
|
@@ -295,12 +300,12 @@ const feedbackController = createFeedbackController({
|
|
|
295
300
|
sourceHash: () => frameController.state.sourceHash, save: saveController,
|
|
296
301
|
policy: () => state.savePolicy, request: api,
|
|
297
302
|
capture: () => captureStablePage({ timeout: 500 }), refresh: () => refreshHistory(),
|
|
298
|
-
note: () =>
|
|
299
|
-
clearNote(sent) {
|
|
300
|
-
|
|
303
|
+
note: () => state.noteDraft.text,
|
|
304
|
+
clearNote(sent) {
|
|
305
|
+
if (state.noteDraft.text === sent && !state.noteDraft.composing)
|
|
306
|
+
Object.assign(state.noteDraft, createNoteDraft());
|
|
307
|
+
},
|
|
301
308
|
pauseCapture() {
|
|
302
|
-
if ($("captureWarning"))
|
|
303
|
-
$("captureWarning").hidden = true;
|
|
304
309
|
captureCoordinator.reset();
|
|
305
310
|
manualCaptureController?.abort();
|
|
306
311
|
},
|
|
@@ -340,14 +345,19 @@ reviewController.own(() => frameController.dispose());
|
|
|
340
345
|
reviewController.own(() => saveController.dispose());
|
|
341
346
|
reviewController.own(() => feedbackController.dispose());
|
|
342
347
|
reviewController.own(() => transport.dispose());
|
|
343
|
-
const
|
|
344
|
-
api,
|
|
348
|
+
export const recoveryRuntime = createRecoveryController({
|
|
349
|
+
request: api,
|
|
345
350
|
sessionId: state.sessionId,
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
+
read: () => ({
|
|
352
|
+
page: state.page, rendered: frameHost.visibleExecution, identity: frameController.identity(),
|
|
353
|
+
comparing: state.comparing, ended: state.ended, loading: frameController.loading,
|
|
354
|
+
pendingReload: frameController.state.pendingReload || !!frameHost.previous,
|
|
355
|
+
frameError: frameController.state.phase.kind === "failed" ? frameController.state.phase.message : null,
|
|
356
|
+
reload: {
|
|
357
|
+
visible: state.reloadVisible, message: state.reloadMessage,
|
|
358
|
+
error: state.reloadFailed || saveController.state.conflict,
|
|
359
|
+
},
|
|
360
|
+
}),
|
|
351
361
|
changed(page) {
|
|
352
362
|
if (!page || page.key !== frameController.state.key)
|
|
353
363
|
return;
|
|
@@ -360,7 +370,148 @@ const executionControls = createExecutionControls({
|
|
|
360
370
|
state.savePolicy = reviewConfiguration(state.page, state.reviewMode).savePolicy;
|
|
361
371
|
render();
|
|
362
372
|
},
|
|
363
|
-
failed
|
|
373
|
+
failed: toast,
|
|
374
|
+
menuChanged(open) {
|
|
375
|
+
if (open) {
|
|
376
|
+
closeModeMenu("recovery-menu");
|
|
377
|
+
}
|
|
378
|
+
toFrame({ type: "eh:modeMenuState", open });
|
|
379
|
+
},
|
|
380
|
+
reload: () => reloadLatest({ explicit: true }),
|
|
381
|
+
keepCurrent() {
|
|
382
|
+
state.reloadVisible = false;
|
|
383
|
+
announce("Keeping the current page. Return to Review to reload when ready.");
|
|
384
|
+
},
|
|
385
|
+
});
|
|
386
|
+
reviewController.own(() => recoveryRuntime.dispose());
|
|
387
|
+
function pendingFeedbackCount() {
|
|
388
|
+
return (state.page?.comments?.length || 0) + (state.page?.edits?.length || 0) +
|
|
389
|
+
(state.others || []).reduce((sum, other) => sum + other.count, 0);
|
|
390
|
+
}
|
|
391
|
+
export const toolbarRuntime = createToolbarController(() => ({
|
|
392
|
+
comparing: state.comparing,
|
|
393
|
+
mode: normalizeReviewMode(state.reviewMode),
|
|
394
|
+
modeDisabled: state.modeApplying || state.comparing || feedbackController.sending || !frameController.state.execution,
|
|
395
|
+
modeMenuOpen: state.modeMenuOpen,
|
|
396
|
+
restoreModeFocus: state.restoreModeFocus,
|
|
397
|
+
editDescription: executionPresentation(state.page, frameHost.visibleExecution).editDescription,
|
|
398
|
+
drawerOpen: state.drawerOpen,
|
|
399
|
+
feedbackCount: pendingFeedbackCount(),
|
|
400
|
+
theme: state.theme === "dark" ? "dark" : "light",
|
|
401
|
+
ended: state.ended,
|
|
402
|
+
}), {
|
|
403
|
+
setComparing: setHistoryView,
|
|
404
|
+
setMode: (mode) => { void setReviewMode(mode); },
|
|
405
|
+
setModeMenu: (open) => {
|
|
406
|
+
if (open)
|
|
407
|
+
openModeMenu();
|
|
408
|
+
else
|
|
409
|
+
closeModeMenu("dismissed", { restoreFocus: true });
|
|
410
|
+
},
|
|
411
|
+
openComments: openDrawer,
|
|
412
|
+
toggleTheme,
|
|
413
|
+
});
|
|
414
|
+
reviewController.own(() => toolbarRuntime.dispose());
|
|
415
|
+
export const commentsRuntime = createCommentsController({
|
|
416
|
+
read: () => ({
|
|
417
|
+
open: state.drawerOpen, ended: state.ended, comparing: state.comparing,
|
|
418
|
+
hasPage: !!state.page, composeOpen: !!state.compose,
|
|
419
|
+
error: frameController.state.phase.kind === "failed" ? frameController.state.phase.message : null,
|
|
420
|
+
comments: state.page?.comments || [], others: state.others || [],
|
|
421
|
+
activeId: state.activeSavedCommentId, orphans: state.orphans, ui: state.commentUi,
|
|
422
|
+
}),
|
|
423
|
+
close: closeDrawer,
|
|
424
|
+
activate: setActive,
|
|
425
|
+
edit: (id, surface) => startCommentEdit(commentById(id), surface),
|
|
426
|
+
save: saveCommentEdit,
|
|
427
|
+
cancelEdit: cancelCommentEdit,
|
|
428
|
+
confirm: confirmCommentDelete,
|
|
429
|
+
dismiss: () => dismissActiveComment("close", { restoreFocus: true }),
|
|
430
|
+
cancelDelete: cancelDeleteConfirmation,
|
|
431
|
+
remove: deleteComment,
|
|
432
|
+
async navigate(key) {
|
|
433
|
+
try {
|
|
434
|
+
await reviewController.navigate({ key });
|
|
435
|
+
}
|
|
436
|
+
catch (err) {
|
|
437
|
+
toast(`${err.message}. Stay on this page and retry.`);
|
|
438
|
+
}
|
|
439
|
+
},
|
|
440
|
+
});
|
|
441
|
+
reviewController.own(() => commentsRuntime.dispose());
|
|
442
|
+
export const contextualRuntime = createContextualController({
|
|
443
|
+
read: () => ({
|
|
444
|
+
open: !!state.compose, disabled: state.ended || state.comparing,
|
|
445
|
+
submitting: state.composeLifecycle === "submitting",
|
|
446
|
+
kind: state.compose?.kind || "selection", quote: state.compose?.quote || "",
|
|
447
|
+
placement: state.composePlacement, draft: state.composerDraft,
|
|
448
|
+
}),
|
|
449
|
+
submit: commitCompose, cancel: cancelCompose,
|
|
450
|
+
reveal() {
|
|
451
|
+
if (!state.compose || state.compose.unresolved)
|
|
452
|
+
return;
|
|
453
|
+
diagnostic("reveal-target-requested");
|
|
454
|
+
toFrame({ type: "eh:revealTarget", targetGeneration: state.compose.generation });
|
|
455
|
+
},
|
|
456
|
+
focus() { $("compose").classList.remove("pass-through"); },
|
|
457
|
+
measure: scheduleContextualPosition,
|
|
458
|
+
});
|
|
459
|
+
reviewController.own(() => contextualRuntime.dispose());
|
|
460
|
+
export const feedbackRuntime = createFeedbackPanelController({
|
|
461
|
+
read: () => ({
|
|
462
|
+
note: state.noteDraft, ended: state.ended,
|
|
463
|
+
available: !!state.page && !frameController.loading && !state.comparing,
|
|
464
|
+
identity: JSON.stringify([frameController.state.key, frameController.state.renderId, frameController.state.generation, state.pageEpoch, frameController.state.pendingReload]),
|
|
465
|
+
source: frameController.state.sourceHash,
|
|
466
|
+
edits: state.page?.edits || [],
|
|
467
|
+
total: pendingFeedbackCount(),
|
|
468
|
+
drafts: draftCount(state), agent: state.agent,
|
|
469
|
+
prompt: handoffPrompt(state.pollCommand || state.page?.pollCommand),
|
|
470
|
+
filename: state.page?.filename || "", kind: state.page?.kind || "", markdown: !!state.page?.markdown,
|
|
471
|
+
save: saveController.getSnapshot(), delivery: feedbackController.getSnapshot(),
|
|
472
|
+
}),
|
|
473
|
+
send: () => feedbackController.send(),
|
|
474
|
+
async flush(action) {
|
|
475
|
+
if (action === "revert") {
|
|
476
|
+
// Revert owns save ordering and can discard edits that failed to persist.
|
|
477
|
+
await frameController.flush(true);
|
|
478
|
+
}
|
|
479
|
+
else {
|
|
480
|
+
await flushFrame({ strict: true });
|
|
481
|
+
await saveController.settled();
|
|
482
|
+
}
|
|
483
|
+
},
|
|
484
|
+
revert: () => saveController.revert(),
|
|
485
|
+
async end() {
|
|
486
|
+
await api(`/api/session/${state.sessionId}/end`, { method: "POST" });
|
|
487
|
+
showEnded();
|
|
488
|
+
},
|
|
489
|
+
copy: (text) => navigator.clipboard.writeText(text),
|
|
490
|
+
failed: toast,
|
|
491
|
+
});
|
|
492
|
+
reviewController.own(() => feedbackRuntime.dispose());
|
|
493
|
+
reviewController.own(frameController.subscribe(() => feedbackRuntime.publish()));
|
|
494
|
+
let contextualFrame = null;
|
|
495
|
+
function scheduleContextualPosition() {
|
|
496
|
+
if (contextualFrame !== null || uiLifetime.signal.aborted)
|
|
497
|
+
return;
|
|
498
|
+
contextualFrame = requestAnimationFrame(() => {
|
|
499
|
+
contextualFrame = null;
|
|
500
|
+
if (uiLifetime.signal.aborted)
|
|
501
|
+
return;
|
|
502
|
+
positionCompose();
|
|
503
|
+
positionAlignedCard();
|
|
504
|
+
});
|
|
505
|
+
}
|
|
506
|
+
const contextualObserver = new ResizeObserver(scheduleContextualPosition);
|
|
507
|
+
for (const host of [$("compose"), $("alignedCard"), $("noticesRoot"), document.querySelector(".document-host")]) {
|
|
508
|
+
contextualObserver.observe(host);
|
|
509
|
+
}
|
|
510
|
+
reviewController.own(() => {
|
|
511
|
+
contextualObserver.disconnect();
|
|
512
|
+
if (contextualFrame !== null)
|
|
513
|
+
cancelAnimationFrame(contextualFrame);
|
|
514
|
+
contextualFrame = null;
|
|
364
515
|
});
|
|
365
516
|
const toolbar = document.querySelector(".toolbar");
|
|
366
517
|
const toolbarObserver = new ResizeObserver(() => {
|
|
@@ -369,11 +520,9 @@ const toolbarObserver = new ResizeObserver(() => {
|
|
|
369
520
|
toolbarObserver.observe(toolbar);
|
|
370
521
|
reviewController.own(() => toolbarObserver.disconnect());
|
|
371
522
|
function renderExecution() {
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
error: frameController.state.phase.kind === "failed" ? frameController.state.phase.message : null,
|
|
376
|
-
});
|
|
523
|
+
recoveryRuntime.publish();
|
|
524
|
+
toolbarRuntime.publish();
|
|
525
|
+
commentsRuntime.publish();
|
|
377
526
|
}
|
|
378
527
|
const persistEdit = (key, payload) => saveController.persistEdit(key, payload);
|
|
379
528
|
const settleEditPersistence = (key) => saveController.settleEdits(key);
|
|
@@ -422,7 +571,7 @@ function configureFrame() {
|
|
|
422
571
|
}
|
|
423
572
|
async function setReviewMode(nextMode) {
|
|
424
573
|
const next = normalizeReviewMode(nextMode);
|
|
425
|
-
closeModeMenu("selection");
|
|
574
|
+
closeModeMenu("selection", { restoreFocus: true });
|
|
426
575
|
if (next === state.reviewMode || state.modeApplying)
|
|
427
576
|
return;
|
|
428
577
|
state.modeApplying = true;
|
|
@@ -454,6 +603,8 @@ async function setReviewMode(nextMode) {
|
|
|
454
603
|
}
|
|
455
604
|
async function loadPage(key, { reload = true } = {}) {
|
|
456
605
|
const returning = state.page;
|
|
606
|
+
state.reloadVisible = false;
|
|
607
|
+
state.reloadFailed = false;
|
|
457
608
|
advancePageEpoch(returning ? "navigation" : "reload");
|
|
458
609
|
const generation = beginFrameTransition(key);
|
|
459
610
|
frameController.resetRetries();
|
|
@@ -466,6 +617,7 @@ async function loadPage(key, { reload = true } = {}) {
|
|
|
466
617
|
frameController.setPolicy(framePolicy(state.page, ARTIFACT_ORIGIN));
|
|
467
618
|
state.orphans = new Set();
|
|
468
619
|
state.compose = null;
|
|
620
|
+
state.composerDraft = createComposerDraft();
|
|
469
621
|
setComposeLifecycle("closed", "page-change");
|
|
470
622
|
setComposePlacement("hidden");
|
|
471
623
|
state.target = null;
|
|
@@ -486,26 +638,17 @@ async function loadPage(key, { reload = true } = {}) {
|
|
|
486
638
|
toast(`${edits} ${edits === 1 ? "edit is" : "edits are"} queued for the agent to apply to the source`);
|
|
487
639
|
}
|
|
488
640
|
}
|
|
489
|
-
// -------------------------------------------------------------------- clock
|
|
490
|
-
function ago(ts) {
|
|
491
|
-
const secs = Math.max(0, Math.round((Date.now() - ts) / 1000));
|
|
492
|
-
if (secs < 45)
|
|
493
|
-
return "just now";
|
|
494
|
-
const mins = Math.round(secs / 60);
|
|
495
|
-
if (mins < 60)
|
|
496
|
-
return `${mins}m`;
|
|
497
|
-
const hours = Math.round(mins / 60);
|
|
498
|
-
if (hours < 24)
|
|
499
|
-
return `${hours}h`;
|
|
500
|
-
return `${Math.round(hours / 24)}d`;
|
|
501
|
-
}
|
|
502
641
|
// -------------------------------------------------------------------- render
|
|
503
642
|
function render() {
|
|
504
643
|
if (state.ended)
|
|
505
644
|
return;
|
|
645
|
+
toolbarRuntime.publish();
|
|
506
646
|
const page = state.page;
|
|
507
|
-
if (!page)
|
|
647
|
+
if (!page) {
|
|
648
|
+
commentsRuntime.publish();
|
|
649
|
+
contextualRuntime.publish();
|
|
508
650
|
return;
|
|
651
|
+
}
|
|
509
652
|
// Source updates cannot change the policy of a frame awaiting draft-safe reload.
|
|
510
653
|
if (frameController.state.execution)
|
|
511
654
|
Object.assign(page, frameController.state.execution);
|
|
@@ -515,150 +658,18 @@ function render() {
|
|
|
515
658
|
syncFrameInteraction();
|
|
516
659
|
const editWasFocused = skipEditCaptureOnce ? false : captureEditState();
|
|
517
660
|
skipEditCaptureOnce = false;
|
|
661
|
+
commentsRuntime.publish();
|
|
518
662
|
document.title = page.filename || 'doc-review';
|
|
519
|
-
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
$("modeLabel").textContent = state.reviewMode === "edit" ? "Edit" : "View";
|
|
525
|
-
replaceIcon($("modeIcon"), state.reviewMode === "edit" ? "pencil" : "eye");
|
|
526
|
-
$("modeButton").disabled = state.modeApplying || state.comparing || feedbackController.sending || !frameController.state.execution;
|
|
527
|
-
for (const item of $("modeMenu").querySelectorAll("[data-mode]")) {
|
|
528
|
-
const checked = item.dataset.mode === state.reviewMode;
|
|
529
|
-
item.setAttribute("aria-checked", String(checked));
|
|
530
|
-
const check = item.querySelector(".menu-check");
|
|
531
|
-
check.textContent = "";
|
|
532
|
-
if (checked)
|
|
533
|
-
check.append(createIcon("check"));
|
|
534
|
-
}
|
|
535
|
-
$("drawer").classList.toggle("open", state.drawerOpen);
|
|
536
|
-
$("drawer").setAttribute("aria-hidden", String(!state.drawerOpen));
|
|
537
|
-
$("commentsButton").setAttribute("aria-expanded", String(state.drawerOpen));
|
|
538
|
-
$("drawerBackdrop").hidden = !state.drawerOpen;
|
|
539
|
-
// --- compose
|
|
540
|
-
const composeWrap = $("compose");
|
|
541
|
-
if (state.compose) {
|
|
542
|
-
composeWrap.hidden = false;
|
|
543
|
-
$("composeKind").textContent = state.compose.kind === "element" ? "Element" : "Selection";
|
|
544
|
-
$("composeQuote").textContent = tidyMiddle(state.compose.quote, 260);
|
|
545
|
-
afterPaint(positionCompose);
|
|
546
|
-
}
|
|
547
|
-
else {
|
|
548
|
-
composeWrap.hidden = true;
|
|
549
|
-
$("composeText").value = "";
|
|
550
|
-
$("composeError").hidden = true;
|
|
551
|
-
}
|
|
552
|
-
// --- comment cards
|
|
553
|
-
const list = $("cards");
|
|
554
|
-
list.textContent = "";
|
|
555
|
-
for (const comment of comments) {
|
|
556
|
-
list.append(renderCommentCard(comment, "drawer"));
|
|
557
|
-
}
|
|
558
|
-
renderAlignedCard(comments);
|
|
559
|
-
// --- your edits
|
|
560
|
-
const box = $("editsBox");
|
|
561
|
-
box.hidden = edits.length === 0;
|
|
562
|
-
if (edits.length) {
|
|
563
|
-
$("editCount").textContent = String(edits.length);
|
|
564
|
-
const rows = $("editList");
|
|
565
|
-
rows.textContent = "";
|
|
566
|
-
const LIMIT = 5;
|
|
567
|
-
const shown = state.editsExpanded ? edits : edits.slice(0, LIMIT);
|
|
568
|
-
for (const edit of shown) {
|
|
569
|
-
const row = document.createElement("div");
|
|
570
|
-
row.className = `edit-row${edit.kind === "deleted" ? " deleted" : ""}`;
|
|
571
|
-
const pip = document.createElement("span");
|
|
572
|
-
pip.className = "pip";
|
|
573
|
-
const label = document.createElement("span");
|
|
574
|
-
label.className = "label";
|
|
575
|
-
label.textContent = edit.label;
|
|
576
|
-
const kind = document.createElement("span");
|
|
577
|
-
kind.className = "kind";
|
|
578
|
-
kind.textContent = edit.kind;
|
|
579
|
-
row.append(pip, label, kind);
|
|
580
|
-
rows.append(row);
|
|
581
|
-
}
|
|
582
|
-
if (edits.length > LIMIT) {
|
|
583
|
-
const more = document.createElement("button");
|
|
584
|
-
more.type = "button";
|
|
585
|
-
more.className = "edit-more";
|
|
586
|
-
more.textContent = state.editsExpanded ? "Show fewer" : `${edits.length - LIMIT} more…`;
|
|
587
|
-
more.addEventListener("click", () => {
|
|
588
|
-
state.editsExpanded = !state.editsExpanded;
|
|
589
|
-
render();
|
|
590
|
-
});
|
|
591
|
-
rows.append(more);
|
|
592
|
-
}
|
|
593
|
-
renderSave();
|
|
594
|
-
}
|
|
595
|
-
// --- pages you left feedback on but are not looking at
|
|
596
|
-
const others = state.others || [];
|
|
597
|
-
const othersBox = $("othersBox");
|
|
598
|
-
othersBox.hidden = others.length === 0;
|
|
599
|
-
if (others.length) {
|
|
600
|
-
$("othersCount").textContent = String(others.length);
|
|
601
|
-
const list = $("othersList");
|
|
602
|
-
list.textContent = "";
|
|
603
|
-
for (const other of others) {
|
|
604
|
-
const row = document.createElement("button");
|
|
605
|
-
row.type = "button";
|
|
606
|
-
row.className = "edit-row other-row";
|
|
607
|
-
const label = document.createElement("span");
|
|
608
|
-
label.className = "label";
|
|
609
|
-
label.textContent = other.filename;
|
|
610
|
-
const count = document.createElement("span");
|
|
611
|
-
count.className = "kind";
|
|
612
|
-
count.textContent = String(other.count);
|
|
613
|
-
row.append(label, count);
|
|
614
|
-
row.addEventListener("click", async () => {
|
|
615
|
-
try {
|
|
616
|
-
await reviewController.navigate({ key: other.key });
|
|
617
|
-
}
|
|
618
|
-
catch (err) {
|
|
619
|
-
toast(`${err.message}. Stay on this page and retry.`);
|
|
620
|
-
}
|
|
621
|
-
});
|
|
622
|
-
list.append(row);
|
|
623
|
-
}
|
|
624
|
-
}
|
|
625
|
-
// --- send
|
|
626
|
-
const otherTotal = others.reduce((sum, o) => sum + o.count, 0);
|
|
627
|
-
const total = comments.length + edits.length + otherTotal;
|
|
628
|
-
// An overall note is sendable on its own — the server already accepts
|
|
629
|
-
// note-only batches; the button must not stay dead while one is typed.
|
|
630
|
-
const hasNote = $("note").value.trim().length > 0;
|
|
631
|
-
const send = $("send");
|
|
632
|
-
const delivered = state.agent === "working";
|
|
633
|
-
const stranded = state.agent === "stranded";
|
|
634
|
-
const busy = delivered || stranded || feedbackController.sent;
|
|
635
|
-
send.disabled = (total === 0 && !hasNote) || busy || feedbackController.sending;
|
|
636
|
-
send.textContent = feedbackController.sending ? "Sending feedback…" : delivered
|
|
637
|
-
? "Feedback delivered"
|
|
638
|
-
: stranded
|
|
639
|
-
? "Sent — agent is not listening"
|
|
640
|
-
: feedbackController.sent
|
|
641
|
-
? "Sent — waiting for agent"
|
|
642
|
-
: total
|
|
643
|
-
? `Send ${total} to agent`
|
|
644
|
-
: hasNote
|
|
645
|
-
? "Send note to agent"
|
|
646
|
-
: "Nothing to send yet";
|
|
647
|
-
// After sending, say what happens next. If nothing is polling, the loop would
|
|
648
|
-
// otherwise dead-end silently, so hand over the exact command to run.
|
|
649
|
-
$("agentLine").hidden = !delivered;
|
|
650
|
-
$("agentText").textContent = "Feedback delivered — page reloads when fixes land";
|
|
651
|
-
// Server-authoritative, so it survives a browser refresh.
|
|
652
|
-
$("handoff").hidden = !stranded;
|
|
653
|
-
if (stranded)
|
|
654
|
-
$("handoffCmd").textContent = handoffPrompt(state.pollCommand || page.pollCommand);
|
|
655
|
-
const excluded = draftCount(state);
|
|
656
|
-
$("draftWarning").hidden = excluded === 0;
|
|
657
|
-
$("draftWarning").textContent = `${excluded} open ${excluded === 1 ? "draft is" : "drafts are"} not included. Save comments explicitly before sending.`;
|
|
663
|
+
// Stable outer hosts are the bounded geometry adapter; React owns their controls.
|
|
664
|
+
$("compose").hidden = !state.compose;
|
|
665
|
+
contextualRuntime.publish();
|
|
666
|
+
scheduleContextualPosition();
|
|
667
|
+
feedbackRuntime.publish();
|
|
658
668
|
restoreTransientFocus(editWasFocused);
|
|
659
669
|
}
|
|
660
670
|
function positionCompose() {
|
|
661
671
|
if (!state.compose || state.composeLifecycle === "closed") {
|
|
672
|
+
$("compose").hidden = true;
|
|
662
673
|
setComposePlacement("hidden");
|
|
663
674
|
return;
|
|
664
675
|
}
|
|
@@ -667,7 +678,9 @@ function positionCompose() {
|
|
|
667
678
|
surface.hidden = false;
|
|
668
679
|
surface.classList.add("sheet");
|
|
669
680
|
surface.classList.remove("edge-top", "edge-bottom");
|
|
670
|
-
|
|
681
|
+
surface.style.left = "";
|
|
682
|
+
surface.style.top = "";
|
|
683
|
+
surface.style.width = "";
|
|
671
684
|
setComposePlacement("sheet");
|
|
672
685
|
return;
|
|
673
686
|
}
|
|
@@ -688,13 +701,6 @@ function positionCompose() {
|
|
|
688
701
|
surface.classList.toggle("edge-top", placement.kind === "edge-top");
|
|
689
702
|
surface.classList.toggle("edge-bottom", placement.kind === "edge-bottom");
|
|
690
703
|
setComposePlacement(placement.kind);
|
|
691
|
-
const edge = placement.kind === "edge-top" || placement.kind === "edge-bottom";
|
|
692
|
-
$("composeDirection").hidden = !edge;
|
|
693
|
-
$("composeDirectionText").textContent = placement.kind === "edge-top"
|
|
694
|
-
? "Selection is above"
|
|
695
|
-
: placement.kind === "edge-bottom"
|
|
696
|
-
? "Selection is below"
|
|
697
|
-
: "";
|
|
698
704
|
surface.hidden = placement.kind === "hidden";
|
|
699
705
|
if (placement.kind !== "sheet" && placement.kind !== "hidden") {
|
|
700
706
|
surface.style.left = `${placement.left}px`;
|
|
@@ -707,273 +713,15 @@ function positionCompose() {
|
|
|
707
713
|
surface.style.width = "";
|
|
708
714
|
}
|
|
709
715
|
}
|
|
710
|
-
function
|
|
711
|
-
|
|
712
|
-
|
|
713
|
-
|
|
714
|
-
|
|
715
|
-
|
|
716
|
-
}
|
|
717
|
-
function makeWho(comment, surface) {
|
|
718
|
-
const who = document.createElement("span");
|
|
719
|
-
who.className = "who";
|
|
720
|
-
who.append("You");
|
|
721
|
-
if (surface === "drawer") {
|
|
722
|
-
const sep = document.createElement("span");
|
|
723
|
-
sep.className = "sep";
|
|
724
|
-
sep.textContent = "·";
|
|
725
|
-
const when = document.createElement("span");
|
|
726
|
-
when.className = "when";
|
|
727
|
-
when.textContent = ago(comment.updatedAt || comment.createdAt);
|
|
728
|
-
who.append(sep, when);
|
|
729
|
-
}
|
|
730
|
-
if (comment.correction) {
|
|
731
|
-
const badge = document.createElement("span");
|
|
732
|
-
badge.className = "badge correction";
|
|
733
|
-
badge.textContent = "correction";
|
|
734
|
-
who.append(badge);
|
|
735
|
-
}
|
|
736
|
-
else if (comment.updatedAt) {
|
|
737
|
-
const badge = document.createElement("span");
|
|
738
|
-
badge.className = "badge";
|
|
739
|
-
badge.textContent = "edited";
|
|
740
|
-
who.append(badge);
|
|
741
|
-
}
|
|
742
|
-
if (state.orphans.has(comment.id)) {
|
|
743
|
-
const badge = document.createElement("span");
|
|
744
|
-
badge.className = "badge";
|
|
745
|
-
badge.textContent = "orphaned";
|
|
746
|
-
who.append(badge);
|
|
747
|
-
}
|
|
748
|
-
return who;
|
|
749
|
-
}
|
|
750
|
-
function commentUiKind(commentId) {
|
|
751
|
-
if (state.commentUi.edit?.commentId === commentId)
|
|
752
|
-
return "edit";
|
|
753
|
-
if (state.commentUi.confirmation?.commentId === commentId)
|
|
754
|
-
return "confirmation";
|
|
755
|
-
if (state.commentUi.menu?.commentId === commentId)
|
|
756
|
-
return "menu";
|
|
757
|
-
return "normal";
|
|
758
|
-
}
|
|
759
|
-
function renderMore(comment, surface, open, disabled = false) {
|
|
760
|
-
const triggerId = controlId(comment.id, surface, "more");
|
|
761
|
-
const menuId = controlId(comment.id, surface, "menu");
|
|
762
|
-
const trigger = makeAction("More", "card-action more-trigger");
|
|
763
|
-
trigger.id = triggerId;
|
|
764
|
-
trigger.setAttribute("aria-haspopup", "menu");
|
|
765
|
-
trigger.setAttribute("aria-expanded", String(open));
|
|
766
|
-
trigger.setAttribute("aria-controls", menuId);
|
|
767
|
-
trigger.disabled = disabled;
|
|
768
|
-
trigger.prepend(createIcon("moreHorizontal", { size: 14 }));
|
|
769
|
-
trigger.addEventListener("click", (event) => {
|
|
770
|
-
event.stopPropagation();
|
|
771
|
-
if (open) {
|
|
772
|
-
closeCommentMenu({ restoreFocus: true });
|
|
773
|
-
}
|
|
774
|
-
else {
|
|
775
|
-
if (!ownMenu(state.commentUi, comment.id, surface, triggerId)) {
|
|
776
|
-
focusCurrentCommentEdit();
|
|
777
|
-
return;
|
|
778
|
-
}
|
|
779
|
-
toFrame({ type: "eh:modeMenuState", open: true });
|
|
780
|
-
requestControlFocus(controlId(comment.id, surface, "delete-item"));
|
|
781
|
-
render();
|
|
782
|
-
}
|
|
783
|
-
});
|
|
784
|
-
return trigger;
|
|
785
|
-
}
|
|
786
|
-
function renderCommentCard(comment, surface) {
|
|
787
|
-
const card = document.createElement(surface === "aligned" ? "article" : "div");
|
|
788
|
-
if (surface === "drawer") {
|
|
789
|
-
card.className = `comment${state.activeSavedCommentId === comment.id ? " active" : ""}`;
|
|
790
|
-
}
|
|
791
|
-
card.dataset.id = comment.id;
|
|
792
|
-
card.dataset.surface = surface;
|
|
793
|
-
const kind = commentUiKind(comment.id);
|
|
794
|
-
const editOwnedElsewhere = !!(state.commentUi.edit &&
|
|
795
|
-
state.commentUi.edit.commentId !== comment.id);
|
|
796
|
-
const head = document.createElement("div");
|
|
797
|
-
head.className = "comment-head";
|
|
798
|
-
head.append(makeWho(comment, surface));
|
|
799
|
-
if (kind === "normal") {
|
|
800
|
-
if (surface === "drawer") {
|
|
801
|
-
const jump = makeAction("Jump to");
|
|
802
|
-
jump.disabled = editOwnedElsewhere;
|
|
803
|
-
jump.addEventListener("click", (event) => {
|
|
804
|
-
event.stopPropagation();
|
|
805
|
-
setActive(comment.id, true);
|
|
806
|
-
});
|
|
807
|
-
head.append(jump);
|
|
808
|
-
}
|
|
809
|
-
const edit = makeAction("Edit");
|
|
810
|
-
edit.id = controlId(comment.id, surface, "edit");
|
|
811
|
-
edit.setAttribute("aria-label", "Edit comment");
|
|
812
|
-
edit.disabled = editOwnedElsewhere;
|
|
813
|
-
edit.addEventListener("click", (event) => {
|
|
814
|
-
event.stopPropagation();
|
|
815
|
-
startCommentEdit(comment, surface);
|
|
816
|
-
});
|
|
817
|
-
head.append(edit);
|
|
818
|
-
if (surface === "aligned") {
|
|
819
|
-
const close = makeAction("Close");
|
|
820
|
-
close.setAttribute("aria-label", "Close comment card");
|
|
821
|
-
close.addEventListener("click", (event) => {
|
|
822
|
-
event.stopPropagation();
|
|
823
|
-
dismissActiveComment("close", { restoreFocus: true });
|
|
824
|
-
});
|
|
825
|
-
head.append(close);
|
|
826
|
-
}
|
|
827
|
-
head.append(renderMore(comment, surface, false, editOwnedElsewhere));
|
|
828
|
-
}
|
|
829
|
-
else if (kind === "menu") {
|
|
830
|
-
head.append(renderMore(comment, surface, true));
|
|
831
|
-
}
|
|
832
|
-
const quote = document.createElement("p");
|
|
833
|
-
quote.className = "quote";
|
|
834
|
-
quote.textContent = tidyMiddle(comment.quote, 140);
|
|
835
|
-
card.append(head, quote);
|
|
836
|
-
if (kind === "edit") {
|
|
837
|
-
const edit = state.commentUi.edit;
|
|
838
|
-
const input = document.createElement("textarea");
|
|
839
|
-
input.className = "body-edit";
|
|
840
|
-
input.rows = 3;
|
|
841
|
-
input.value = edit.draft;
|
|
842
|
-
input.readOnly = edit.status === "saving";
|
|
843
|
-
input.dataset.commentEdit = comment.id;
|
|
844
|
-
input.setAttribute("aria-label", "Edit comment text");
|
|
845
|
-
input.addEventListener("input", () => {
|
|
846
|
-
edit.draft = input.value;
|
|
847
|
-
edit.selectionStart = input.selectionStart;
|
|
848
|
-
edit.selectionEnd = input.selectionEnd;
|
|
849
|
-
edit.validation = "";
|
|
850
|
-
});
|
|
851
|
-
const rememberSelection = () => {
|
|
852
|
-
edit.selectionStart = input.selectionStart;
|
|
853
|
-
edit.selectionEnd = input.selectionEnd;
|
|
854
|
-
};
|
|
855
|
-
input.addEventListener("select", rememberSelection);
|
|
856
|
-
input.addEventListener("keyup", rememberSelection);
|
|
857
|
-
input.addEventListener("click", (event) => {
|
|
858
|
-
event.stopPropagation();
|
|
859
|
-
rememberSelection();
|
|
860
|
-
});
|
|
861
|
-
input.addEventListener("compositionstart", () => {
|
|
862
|
-
edit.composing = true;
|
|
863
|
-
});
|
|
864
|
-
input.addEventListener("compositionend", () => {
|
|
865
|
-
edit.composing = false;
|
|
866
|
-
rememberSelection();
|
|
867
|
-
});
|
|
868
|
-
input.addEventListener("keydown", (event) => {
|
|
869
|
-
event.stopPropagation();
|
|
870
|
-
rememberSelection();
|
|
871
|
-
if (event.key === "Enter" && !event.shiftKey) {
|
|
872
|
-
if (edit.composing || event.isComposing)
|
|
873
|
-
return;
|
|
874
|
-
event.preventDefault();
|
|
875
|
-
void saveCommentEdit();
|
|
876
|
-
}
|
|
877
|
-
else if (event.key === "Escape") {
|
|
878
|
-
event.preventDefault();
|
|
879
|
-
cancelCommentEdit();
|
|
880
|
-
}
|
|
881
|
-
});
|
|
882
|
-
card.append(input);
|
|
883
|
-
const helper = document.createElement("p");
|
|
884
|
-
helper.className = "edit-help";
|
|
885
|
-
helper.textContent = "Enter to save · Shift+Enter for new line";
|
|
886
|
-
card.append(helper);
|
|
887
|
-
if (edit.validation) {
|
|
888
|
-
const validation = document.createElement("p");
|
|
889
|
-
validation.className = "comment-validation";
|
|
890
|
-
validation.setAttribute("role", "alert");
|
|
891
|
-
validation.textContent = edit.validation;
|
|
892
|
-
card.append(validation);
|
|
893
|
-
}
|
|
894
|
-
const actions = document.createElement("div");
|
|
895
|
-
actions.className = "comment-edit-actions";
|
|
896
|
-
const save = makeAction(edit.status === "saving" ? "Saving…" : "Save", "btn-primary");
|
|
897
|
-
save.disabled = edit.status === "saving";
|
|
898
|
-
save.addEventListener("click", (event) => {
|
|
899
|
-
event.stopPropagation();
|
|
900
|
-
void saveCommentEdit();
|
|
901
|
-
});
|
|
902
|
-
const cancel = makeAction("Cancel", "btn-ghost");
|
|
903
|
-
cancel.disabled = edit.status === "saving";
|
|
904
|
-
cancel.addEventListener("click", (event) => {
|
|
905
|
-
event.stopPropagation();
|
|
906
|
-
cancelCommentEdit();
|
|
907
|
-
});
|
|
908
|
-
actions.append(save, cancel);
|
|
909
|
-
card.append(actions);
|
|
910
|
-
}
|
|
911
|
-
else if (kind === "confirmation") {
|
|
912
|
-
const confirmation = state.commentUi.confirmation;
|
|
913
|
-
const prompt = document.createElement("p");
|
|
914
|
-
prompt.className = "delete-prompt";
|
|
915
|
-
prompt.textContent = "Delete this comment?";
|
|
916
|
-
const actions = document.createElement("div");
|
|
917
|
-
actions.className = "delete-actions";
|
|
918
|
-
const cancel = makeAction("Cancel", "btn-ghost");
|
|
919
|
-
cancel.disabled = confirmation.status === "deleting";
|
|
920
|
-
cancel.addEventListener("click", (event) => {
|
|
921
|
-
event.stopPropagation();
|
|
922
|
-
cancelDeleteConfirmation();
|
|
923
|
-
});
|
|
924
|
-
const remove = makeAction(confirmation.status === "deleting" ? "Deleting…" : "Delete", "btn-danger");
|
|
925
|
-
remove.id = controlId(comment.id, surface, "confirm-delete");
|
|
926
|
-
remove.disabled = confirmation.status === "deleting";
|
|
927
|
-
remove.addEventListener("click", (event) => {
|
|
928
|
-
event.stopPropagation();
|
|
929
|
-
void deleteComment(comment.id);
|
|
930
|
-
});
|
|
931
|
-
actions.append(cancel, remove);
|
|
932
|
-
card.append(prompt, actions);
|
|
933
|
-
}
|
|
934
|
-
else {
|
|
935
|
-
const body = document.createElement("p");
|
|
936
|
-
body.className = "body";
|
|
937
|
-
body.textContent = comment.feedback;
|
|
938
|
-
body.title = "Click to edit";
|
|
939
|
-
if (!editOwnedElsewhere) {
|
|
940
|
-
body.addEventListener("click", (event) => {
|
|
941
|
-
event.stopPropagation();
|
|
942
|
-
startCommentEdit(comment, surface);
|
|
943
|
-
});
|
|
944
|
-
}
|
|
945
|
-
card.append(body);
|
|
946
|
-
}
|
|
947
|
-
if (kind === "menu") {
|
|
948
|
-
const menu = document.createElement("div");
|
|
949
|
-
menu.id = controlId(comment.id, surface, "menu");
|
|
950
|
-
menu.className = "comment-menu";
|
|
951
|
-
menu.setAttribute("role", "menu");
|
|
952
|
-
menu.setAttribute("aria-labelledby", controlId(comment.id, surface, "more"));
|
|
953
|
-
const remove = makeAction("Delete", "comment-menu-item");
|
|
954
|
-
remove.id = controlId(comment.id, surface, "delete-item");
|
|
955
|
-
remove.setAttribute("role", "menuitem");
|
|
956
|
-
remove.prepend(createIcon("trash", { size: 14 }));
|
|
957
|
-
remove.addEventListener("click", (event) => {
|
|
958
|
-
event.stopPropagation();
|
|
959
|
-
toFrame({ type: "eh:modeMenuState", open: false });
|
|
960
|
-
if (!ownConfirmation(state.commentUi, comment.id, surface)) {
|
|
961
|
-
focusCurrentCommentEdit();
|
|
962
|
-
return;
|
|
963
|
-
}
|
|
964
|
-
requestControlFocus(controlId(comment.id, surface, "confirm-delete"));
|
|
965
|
-
render();
|
|
966
|
-
});
|
|
967
|
-
menu.append(remove);
|
|
968
|
-
card.append(menu);
|
|
969
|
-
}
|
|
970
|
-
if (surface === "drawer") {
|
|
971
|
-
card.addEventListener("click", () => {
|
|
972
|
-
if (kind === "normal" && !editOwnedElsewhere)
|
|
973
|
-
setActive(comment.id, false);
|
|
974
|
-
});
|
|
716
|
+
function confirmCommentDelete(id, surface) {
|
|
717
|
+
if (!commentById(id))
|
|
718
|
+
return;
|
|
719
|
+
if (!ownConfirmation(state.commentUi, id, surface)) {
|
|
720
|
+
focusCurrentCommentEdit();
|
|
721
|
+
return;
|
|
975
722
|
}
|
|
976
|
-
|
|
723
|
+
requestControlFocus(controlId(id, surface, "confirm-delete"));
|
|
724
|
+
render();
|
|
977
725
|
}
|
|
978
726
|
function positionAlignedCard() {
|
|
979
727
|
const host = $("alignedCard");
|
|
@@ -987,6 +735,8 @@ function positionAlignedCard() {
|
|
|
987
735
|
const position = alignedCardPosition(geometry.rects, {
|
|
988
736
|
frameRect: frameHost.current.getBoundingClientRect(),
|
|
989
737
|
viewport,
|
|
738
|
+
width: host.offsetWidth || 300,
|
|
739
|
+
height: host.offsetHeight || 180,
|
|
990
740
|
});
|
|
991
741
|
if (!position) {
|
|
992
742
|
host.hidden = true;
|
|
@@ -997,19 +747,6 @@ function positionAlignedCard() {
|
|
|
997
747
|
host.hidden = false;
|
|
998
748
|
return true;
|
|
999
749
|
}
|
|
1000
|
-
function renderAlignedCard(comments) {
|
|
1001
|
-
const host = $("alignedCard");
|
|
1002
|
-
host.textContent = "";
|
|
1003
|
-
const comment = comments.find((item) => item.id === state.activeSavedCommentId);
|
|
1004
|
-
if (!comment) {
|
|
1005
|
-
host.hidden = true;
|
|
1006
|
-
return;
|
|
1007
|
-
}
|
|
1008
|
-
const card = renderCommentCard(comment, "aligned");
|
|
1009
|
-
while (card.firstChild)
|
|
1010
|
-
host.append(card.firstChild);
|
|
1011
|
-
positionAlignedCard();
|
|
1012
|
-
}
|
|
1013
750
|
async function deleteComment(id) {
|
|
1014
751
|
if (deleteFlights.has(id))
|
|
1015
752
|
return deleteFlights.get(id);
|
|
@@ -1019,6 +756,7 @@ async function deleteComment(id) {
|
|
|
1019
756
|
confirmation.status = "deleting";
|
|
1020
757
|
render();
|
|
1021
758
|
const startEpoch = state.pageEpoch;
|
|
759
|
+
const index = newestComments(state.page.comments).findIndex((comment) => comment.id === id);
|
|
1022
760
|
const flight = (async () => {
|
|
1023
761
|
try {
|
|
1024
762
|
const result = await api(`/api/page/${frameController.state.key}/comment/${id}`, { method: "DELETE" });
|
|
@@ -1029,6 +767,11 @@ async function deleteComment(id) {
|
|
|
1029
767
|
state.activeGeometry.delete(id);
|
|
1030
768
|
if (state.activeSavedCommentId === id)
|
|
1031
769
|
state.activeSavedCommentId = null;
|
|
770
|
+
if (state.drawerOpen) {
|
|
771
|
+
const remaining = newestComments(state.page.comments);
|
|
772
|
+
const next = remaining[Math.min(index, remaining.length - 1)];
|
|
773
|
+
requestControlFocus(next ? controlId(next.id, "drawer", "delete") : "commentsSection");
|
|
774
|
+
}
|
|
1032
775
|
render();
|
|
1033
776
|
return true;
|
|
1034
777
|
}
|
|
@@ -1052,7 +795,7 @@ function cancelDeleteConfirmation() {
|
|
|
1052
795
|
const confirmation = state.commentUi.confirmation;
|
|
1053
796
|
if (!confirmation || confirmation.status === "deleting")
|
|
1054
797
|
return false;
|
|
1055
|
-
const triggerId = controlId(confirmation.commentId, confirmation.surface, "
|
|
798
|
+
const triggerId = controlId(confirmation.commentId, confirmation.surface, "delete");
|
|
1056
799
|
clearOwned(state.commentUi, "confirmation");
|
|
1057
800
|
requestControlFocus(triggerId);
|
|
1058
801
|
render();
|
|
@@ -1144,6 +887,8 @@ async function executeCommentPatch(edit, startEpoch) {
|
|
|
1144
887
|
}
|
|
1145
888
|
reconcilePage(result.page, { reason: "mutation" });
|
|
1146
889
|
clearOwned(state.commentUi, "edit");
|
|
890
|
+
if (state.drawerOpen)
|
|
891
|
+
requestControlFocus(controlId(replacement?.id || id, "drawer", "edit"));
|
|
1147
892
|
if (result.delivery === "updated-pending") {
|
|
1148
893
|
toast("Updated the feedback waiting for your agent");
|
|
1149
894
|
}
|
|
@@ -1176,37 +921,7 @@ async function executeCommentPatch(edit, startEpoch) {
|
|
|
1176
921
|
}
|
|
1177
922
|
}
|
|
1178
923
|
function renderSave() {
|
|
1179
|
-
|
|
1180
|
-
if (saveController.state.conflict) {
|
|
1181
|
-
line.className = "save-line failed";
|
|
1182
|
-
$("saveText").textContent = "Source changed — reload latest before saving. Your page edits remain in this tab.";
|
|
1183
|
-
return;
|
|
1184
|
-
}
|
|
1185
|
-
if (state.page && state.page.kind === "url") {
|
|
1186
|
-
line.className = "save-line dynamic";
|
|
1187
|
-
$("saveText").textContent = "Localhost page — your direct edits go to the agent for source updates";
|
|
1188
|
-
return;
|
|
1189
|
-
}
|
|
1190
|
-
if (state.page && state.page.markdown) {
|
|
1191
|
-
line.className = "save-line dynamic";
|
|
1192
|
-
$("saveText").textContent = "Markdown source — edits go to the agent as feedback";
|
|
1193
|
-
return;
|
|
1194
|
-
}
|
|
1195
|
-
if (saveController.state.dynamic) {
|
|
1196
|
-
// The page's own scripts render it, so writing the live DOM back would
|
|
1197
|
-
// corrupt the file. Edits still reach the agent as feedback.
|
|
1198
|
-
line.className = "save-line dynamic";
|
|
1199
|
-
$("saveText").textContent = "Live page — edits go to the agent, the file is left alone";
|
|
1200
|
-
return;
|
|
1201
|
-
}
|
|
1202
|
-
line.className = `save-line ${saveController.state.status === "saving" ? "saving" : saveController.state.status === "failed" ? "failed" : ""}`;
|
|
1203
|
-
const name = state.page ? state.page.filename : "";
|
|
1204
|
-
if (saveController.state.status === "saving")
|
|
1205
|
-
$("saveText").textContent = `Saving to ${name}…`;
|
|
1206
|
-
else if (saveController.state.status === "failed")
|
|
1207
|
-
$("saveText").textContent = "Couldn't save — retry before sending";
|
|
1208
|
-
else
|
|
1209
|
-
$("saveText").textContent = saveController.state.savedAt ? `Saved to ${name} · ${saveController.state.savedAt}` : `Saved to ${name}`;
|
|
924
|
+
feedbackRuntime.publish();
|
|
1210
925
|
}
|
|
1211
926
|
function toast(message) {
|
|
1212
927
|
const el = document.createElement("div");
|
|
@@ -1216,6 +931,8 @@ function toast(message) {
|
|
|
1216
931
|
afterDelay(() => el.remove(), 3200);
|
|
1217
932
|
}
|
|
1218
933
|
function setActive(id, scroll) {
|
|
934
|
+
if (id && state.drawerOpen)
|
|
935
|
+
commentsRuntime.commands.setSectionOpen(true);
|
|
1219
936
|
state.activeSavedCommentId = id;
|
|
1220
937
|
toFrame({ type: "eh:activate", id, scroll: !!scroll });
|
|
1221
938
|
render();
|
|
@@ -1236,13 +953,15 @@ function dismissActiveComment(reason, { restoreFocus = false } = {}) {
|
|
|
1236
953
|
}
|
|
1237
954
|
// ------------------------------------------------------------------ compose
|
|
1238
955
|
async function openCompose(detail) {
|
|
956
|
+
const identity = currentRender();
|
|
957
|
+
const pageKey = frameController.state.key;
|
|
1239
958
|
if (state.compose && state.compose.generation === detail.generation) {
|
|
1240
959
|
if (state.composeLifecycle === "closed")
|
|
1241
960
|
setComposeLifecycle("open", "accepted");
|
|
1242
961
|
$("composeText").focus();
|
|
1243
962
|
return true;
|
|
1244
963
|
}
|
|
1245
|
-
if (state.compose &&
|
|
964
|
+
if (state.compose && state.composerDraft.text.trim()) {
|
|
1246
965
|
const submitted = await commitCompose();
|
|
1247
966
|
if (!submitted) {
|
|
1248
967
|
diagnostic("comment-retarget-blocked", { reason: "submit-failed" });
|
|
@@ -1252,12 +971,12 @@ async function openCompose(detail) {
|
|
|
1252
971
|
else if (state.compose) {
|
|
1253
972
|
cancelCompose({ restoreFocus: false, preserveRetarget: true });
|
|
1254
973
|
}
|
|
974
|
+
if (state.ended || pageKey !== frameController.state.key || !sameRender(identity, currentRender()))
|
|
975
|
+
return false;
|
|
1255
976
|
dismissActiveComment("composer-open");
|
|
1256
977
|
state.compose = detail;
|
|
978
|
+
state.composerDraft = createComposerDraft();
|
|
1257
979
|
setComposeLifecycle("open", "accepted");
|
|
1258
|
-
$("composeText").value = "";
|
|
1259
|
-
$("composeError").hidden = true;
|
|
1260
|
-
$("composeAddLabel").textContent = "Comment";
|
|
1261
980
|
render();
|
|
1262
981
|
afterPaint(() => {
|
|
1263
982
|
positionCompose();
|
|
@@ -1272,6 +991,7 @@ function cancelCompose({ restoreFocus = true, preserveRetarget = false } = {}) {
|
|
|
1272
991
|
const generation = state.compose.generation;
|
|
1273
992
|
const discardThroughGeneration = Math.max(generation, Number(state.compose.rejectedTargetGeneration) || generation);
|
|
1274
993
|
state.compose = null;
|
|
994
|
+
state.composerDraft = createComposerDraft();
|
|
1275
995
|
setComposeLifecycle("closed", "cancel");
|
|
1276
996
|
setComposePlacement("hidden");
|
|
1277
997
|
toFrame({
|
|
@@ -1297,26 +1017,40 @@ function commitCompose() {
|
|
|
1297
1017
|
}
|
|
1298
1018
|
async function executeComposeSubmit() {
|
|
1299
1019
|
const compose = state.compose;
|
|
1300
|
-
const
|
|
1301
|
-
|
|
1020
|
+
const draft = state.composerDraft;
|
|
1021
|
+
const feedback = draft.text.trim();
|
|
1022
|
+
if (!compose || !feedback || draft.composing || state.ended)
|
|
1302
1023
|
return false;
|
|
1303
|
-
const
|
|
1304
|
-
|
|
1024
|
+
const startEpoch = state.pageEpoch;
|
|
1025
|
+
const identity = currentRender();
|
|
1026
|
+
const targetGeneration = compose.generation;
|
|
1027
|
+
const current = () => !state.ended && state.compose === compose && state.composerDraft === draft;
|
|
1028
|
+
draft.error = "";
|
|
1305
1029
|
setComposeLifecycle("submitting", "submit");
|
|
1306
1030
|
diagnostic("comment-submit-executed");
|
|
1307
|
-
$("composeError").hidden = true;
|
|
1308
1031
|
try {
|
|
1309
|
-
const result = await api(`/api/page/${
|
|
1032
|
+
const result = await api(`/api/page/${identity.key}/comment`, {
|
|
1310
1033
|
method: "POST",
|
|
1311
1034
|
body: JSON.stringify({ kind: compose.kind, quote: compose.quote, anchor: compose.unresolved ? null : compose.anchor, feedback }),
|
|
1312
1035
|
});
|
|
1313
|
-
|
|
1314
|
-
|
|
1315
|
-
|
|
1316
|
-
|
|
1317
|
-
|
|
1318
|
-
|
|
1036
|
+
if (!current())
|
|
1037
|
+
return false;
|
|
1038
|
+
if (startEpoch !== state.pageEpoch || !sameRender(identity, currentRender())) {
|
|
1039
|
+
draft.error = "The page changed while this comment was saving. Your draft is preserved; check the comments before retrying.";
|
|
1040
|
+
draft.retry = true;
|
|
1041
|
+
setComposeLifecycle("open", "stale-submit");
|
|
1042
|
+
render();
|
|
1043
|
+
return false;
|
|
1044
|
+
}
|
|
1045
|
+
if (!compose.unresolved)
|
|
1046
|
+
toFrame({
|
|
1047
|
+
type: "eh:commit",
|
|
1048
|
+
id: result.comment.id,
|
|
1049
|
+
targetGeneration,
|
|
1050
|
+
restoreFocus: true,
|
|
1051
|
+
});
|
|
1319
1052
|
state.compose = null;
|
|
1053
|
+
state.composerDraft = createComposerDraft();
|
|
1320
1054
|
setComposeLifecycle("closed", "submitted");
|
|
1321
1055
|
setComposePlacement("hidden");
|
|
1322
1056
|
state.page = result.page;
|
|
@@ -1326,24 +1060,22 @@ async function executeComposeSubmit() {
|
|
|
1326
1060
|
return true;
|
|
1327
1061
|
}
|
|
1328
1062
|
catch (err) {
|
|
1329
|
-
|
|
1330
|
-
|
|
1331
|
-
|
|
1063
|
+
if (!current())
|
|
1064
|
+
return false;
|
|
1065
|
+
draft.error = `${err.message}. Retry or cancel.`;
|
|
1066
|
+
draft.retry = true;
|
|
1332
1067
|
diagnostic("comment-request-failure", { status: err.status || 0 });
|
|
1333
1068
|
announce("Comment could not be saved. Your draft is still here.");
|
|
1334
1069
|
setComposeLifecycle("open", "submit-failed");
|
|
1070
|
+
render();
|
|
1335
1071
|
$("compose").classList.remove("pass-through");
|
|
1336
1072
|
afterPaint(() => $("composeText").focus());
|
|
1337
1073
|
return false;
|
|
1338
1074
|
}
|
|
1339
|
-
finally {
|
|
1340
|
-
button.disabled = false;
|
|
1341
|
-
}
|
|
1342
1075
|
}
|
|
1343
1076
|
// ------------------------------------------------------------ review history
|
|
1344
1077
|
const currentRender = () => frameController.identity();
|
|
1345
1078
|
const captures = createCaptureRequests({ send: toFrame, current: currentRender });
|
|
1346
|
-
const comparisonView = createComparisonView($("changeDetail"));
|
|
1347
1079
|
const historyUrl = (suffix = "") => `/api/session/${state.sessionId}/history${suffix}`;
|
|
1348
1080
|
const historyController = createHistoryController({
|
|
1349
1081
|
request: (suffix) => api(historyUrl(suffix)), changed: renderHistory, refreshed: scheduleResultCapture,
|
|
@@ -1371,164 +1103,42 @@ function selectedTarget() {
|
|
|
1371
1103
|
const targets = roundTargets(history.round);
|
|
1372
1104
|
return targets.find((target) => target.key === history.targetKey) || targets[0] || null;
|
|
1373
1105
|
}
|
|
1374
|
-
function targetComparison(target) { return target?.comparison || {}; }
|
|
1375
|
-
function comparisonItems() {
|
|
1376
|
-
const comparison = targetComparison(selectedTarget());
|
|
1377
|
-
const value = history.mode === "source" ? comparison.source : comparison.content;
|
|
1378
|
-
return value?.changes || value?.items || (history.mode === "content" ? comparison.changes || comparison.items : []) || [];
|
|
1379
|
-
}
|
|
1380
1106
|
const refreshHistory = () => state.ended ? Promise.resolve(false) : historyController.refresh();
|
|
1107
|
+
export const changesRuntime = createChangesController({
|
|
1108
|
+
read: () => ({
|
|
1109
|
+
history, ended: state.ended, comparing: state.comparing, sending: feedbackController.sending,
|
|
1110
|
+
current: {
|
|
1111
|
+
key: frameController.state.key, kind: state.page?.kind,
|
|
1112
|
+
ready: !frameController.loading && !!frameController.state.readyAt,
|
|
1113
|
+
pendingReload: frameController.state.pendingReload,
|
|
1114
|
+
dirty: saveController.state.dirty || saveController.state.conflict,
|
|
1115
|
+
sourceHash: frameController.state.sourceHash || saveController.state.baseHash,
|
|
1116
|
+
sessionId: state.sessionId, generation: frameController.state.generation,
|
|
1117
|
+
},
|
|
1118
|
+
}),
|
|
1119
|
+
selectRound: (id) => historyController.selectRound(id),
|
|
1120
|
+
selectTarget: (key) => historyController.selectTarget(key),
|
|
1121
|
+
selectMode(mode) {
|
|
1122
|
+
history.preferredMode = mode;
|
|
1123
|
+
history.mode = mode;
|
|
1124
|
+
history.index = 0;
|
|
1125
|
+
renderHistory();
|
|
1126
|
+
},
|
|
1127
|
+
selectIndex(index) { history.index = index; renderHistory(); },
|
|
1128
|
+
capture: () => captureResult(history.round, selectedTarget()),
|
|
1129
|
+
finish: () => finishCapture(),
|
|
1130
|
+
refresh: refreshHistory,
|
|
1131
|
+
failed: toast,
|
|
1132
|
+
});
|
|
1133
|
+
reviewController.own(() => changesRuntime.dispose());
|
|
1134
|
+
reviewController.own(frameController.subscribe(() => changesRuntime.publish()));
|
|
1135
|
+
reviewController.own(saveController.subscribe(() => changesRuntime.publish()));
|
|
1136
|
+
reviewController.own(feedbackController.subscribe(() => changesRuntime.publish()));
|
|
1381
1137
|
function renderHistory() {
|
|
1382
1138
|
if (state.ended)
|
|
1383
1139
|
return;
|
|
1384
1140
|
syncFrameInteraction();
|
|
1385
|
-
|
|
1386
|
-
const picker = $("roundPicker");
|
|
1387
|
-
picker.replaceChildren();
|
|
1388
|
-
for (const [index, round] of history.rounds.entries()) {
|
|
1389
|
-
const option = document.createElement("option");
|
|
1390
|
-
option.value = roundId(round);
|
|
1391
|
-
option.textContent = `Round ${round.ordinal || round.number || history.rounds.length - index} · ${round.captureStatus === "ready" ? "completed" : round.captureStatus || round.status || round.feedbackStatus || "pending"}`;
|
|
1392
|
-
picker.append(option);
|
|
1393
|
-
}
|
|
1394
|
-
picker.value = history.selectedId || "";
|
|
1395
|
-
picker.disabled = !history.rounds.length;
|
|
1396
|
-
const targets = roundTargets(history.round);
|
|
1397
|
-
const target = selectedTarget();
|
|
1398
|
-
if (history.round)
|
|
1399
|
-
history.targetKey = target?.key || null;
|
|
1400
|
-
$("historyTarget").replaceChildren();
|
|
1401
|
-
for (const item of targets) {
|
|
1402
|
-
const option = document.createElement("option");
|
|
1403
|
-
option.value = item.key;
|
|
1404
|
-
option.textContent = item.filename || item.label || item.key;
|
|
1405
|
-
$("historyTarget").append(option);
|
|
1406
|
-
}
|
|
1407
|
-
$("historyTarget").value = history.targetKey || "";
|
|
1408
|
-
$("historyTargetLabel").hidden = targets.length < 2;
|
|
1409
|
-
const comparison = targetComparison(target);
|
|
1410
|
-
const failedCapture = history.failures.get(`${roundId(history.round)}:${target?.key}`);
|
|
1411
|
-
const presentation = historyPresentation({ ...history, target, comparison, failure: failedCapture });
|
|
1412
|
-
const modes = presentation.modes;
|
|
1413
|
-
history.mode = modes.includes(history.preferredMode) ? history.preferredMode : modes[0] || "content";
|
|
1414
|
-
const displayedComparison = comparison[history.mode] || {};
|
|
1415
|
-
$("historyUnavailable").replaceChildren();
|
|
1416
|
-
for (const mode of ["content", "source"]) {
|
|
1417
|
-
const representation = comparison[mode];
|
|
1418
|
-
if (representation && representation.available === false) {
|
|
1419
|
-
const reason = document.createElement("p");
|
|
1420
|
-
reason.textContent = `${mode === "content" ? "Content" : "Source"} comparison unavailable: ${representation.reason || "This representation could not be compared."}`;
|
|
1421
|
-
$("historyUnavailable").append(reason);
|
|
1422
|
-
}
|
|
1423
|
-
}
|
|
1424
|
-
$("historyUnavailable").hidden = !$("historyUnavailable").childElementCount;
|
|
1425
|
-
$("historyTiming").replaceChildren();
|
|
1426
|
-
for (const [label, timestamp] of [
|
|
1427
|
-
["Before captured", displayedComparison.beforeCapturedAt],
|
|
1428
|
-
["Agent acknowledged", history.round?.acknowledgedAt],
|
|
1429
|
-
["After captured", displayedComparison.afterCapturedAt],
|
|
1430
|
-
]) {
|
|
1431
|
-
const row = document.createElement("div");
|
|
1432
|
-
const term = document.createElement("dt");
|
|
1433
|
-
term.textContent = label;
|
|
1434
|
-
const value = document.createElement("dd");
|
|
1435
|
-
const date = timestamp == null ? null : new Date(timestamp);
|
|
1436
|
-
value.textContent = date && Number.isFinite(date.getTime()) ? date.toLocaleString() : "Not available";
|
|
1437
|
-
row.append(term, value);
|
|
1438
|
-
$("historyTiming").append(row);
|
|
1439
|
-
}
|
|
1440
|
-
$("historyTiming").hidden = !history.round;
|
|
1441
|
-
const afterAt = displayedComparison.afterCapturedAt == null ? NaN : new Date(displayedComparison.afterCapturedAt).getTime();
|
|
1442
|
-
const acknowledgedAt = history.round?.acknowledgedAt == null ? NaN : new Date(history.round.acknowledgedAt).getTime();
|
|
1443
|
-
const delay = afterAt - acknowledgedAt;
|
|
1444
|
-
$("historyCaptureDelay").hidden = !Number.isFinite(delay);
|
|
1445
|
-
$("historyCaptureDelay").textContent = delay >= 0
|
|
1446
|
-
? `Result captured ${Math.round(delay / 1000)} seconds after acknowledgment—not at the acknowledgment instant. A delayed capture can include later changes.`
|
|
1447
|
-
: "This result snapshot predates acknowledgment. It is not proof of the page state at acknowledgment.";
|
|
1448
|
-
$("historyCurrentStatus").hidden = !target?.resultRevisionId && !displayedComparison.afterCapturedAt;
|
|
1449
|
-
$("historyCurrentStatus").textContent = comparisonFreshness(displayedComparison, target?.key, {
|
|
1450
|
-
key: frameController.state.key, kind: state.page?.kind, ready: !frameController.loading && !!frameController.state.readyAt,
|
|
1451
|
-
pendingReload: frameController.state.pendingReload, dirty: saveController.state.dirty || saveController.state.conflict,
|
|
1452
|
-
sourceHash: frameController.state.sourceHash || saveController.state.baseHash,
|
|
1453
|
-
sessionId: state.sessionId, generation: frameController.state.generation,
|
|
1454
|
-
});
|
|
1455
|
-
const viewComparison = history.mode === "content" ? displayedComparison.viewComparison : null;
|
|
1456
|
-
$("historyViewCoverage").hidden = !viewComparison?.message;
|
|
1457
|
-
$("historyViewCoverage").textContent = viewComparison?.message || "";
|
|
1458
|
-
$("historyViewCoverage").dataset.status = viewComparison?.status || "";
|
|
1459
|
-
$("comparisonModes").replaceChildren();
|
|
1460
|
-
for (const mode of modes) {
|
|
1461
|
-
const button = makeAction(mode === "source" ? "Source" : "Content", "btn-ghost");
|
|
1462
|
-
button.dataset.comparisonMode = mode;
|
|
1463
|
-
button.setAttribute("aria-pressed", String(history.mode === mode));
|
|
1464
|
-
button.addEventListener("click", () => {
|
|
1465
|
-
history.preferredMode = mode;
|
|
1466
|
-
history.mode = mode;
|
|
1467
|
-
history.index = 0;
|
|
1468
|
-
renderHistory();
|
|
1469
|
-
$("comparisonModes").querySelector(`[data-comparison-mode="${mode}"]`)?.focus({ preventScroll: true });
|
|
1470
|
-
});
|
|
1471
|
-
$("comparisonModes").append(button);
|
|
1472
|
-
}
|
|
1473
|
-
if (focusedMode)
|
|
1474
|
-
$("comparisonModes").querySelector(`[data-comparison-mode="${focusedMode}"]`)?.focus({ preventScroll: true });
|
|
1475
|
-
if ($("historyStatus").textContent !== presentation.message)
|
|
1476
|
-
$("historyStatus").textContent = presentation.message;
|
|
1477
|
-
$("historyStatus").dataset.state = presentation.state;
|
|
1478
|
-
const detail = history.error?.message || failedCapture || target?.capture?.error;
|
|
1479
|
-
$("historyError").hidden = !detail;
|
|
1480
|
-
$("historyError").textContent = detail || "";
|
|
1481
|
-
const captureAllowed = target && !target.resultRevisionId && target.capture?.status !== "unavailable" && history.round?.feedbackStatus === "acknowledged";
|
|
1482
|
-
$("captureResult").hidden = !captureAllowed;
|
|
1483
|
-
$("captureResult").disabled = history.captureBusy || history.finalizing || target?.key !== frameController.state.key || frameController.loading;
|
|
1484
|
-
$("captureResult").textContent = history.captureBusy ? "Capturing result…" : "Capture result";
|
|
1485
|
-
const canFinalize = target && !target.resultRevisionId && history.round?.feedbackStatus === "acknowledged" &&
|
|
1486
|
-
["pending", "failed"].includes(target.capture?.status || target.captureStatus || "pending");
|
|
1487
|
-
$("finishCapture").hidden = !canFinalize;
|
|
1488
|
-
$("finishCaptureHelp").hidden = !canFinalize;
|
|
1489
|
-
$("finishCapture").disabled = history.finalizing || history.captureBusy;
|
|
1490
|
-
$("finishCapture").setAttribute("aria-describedby", "finishCaptureHelp");
|
|
1491
|
-
$("finishCapture").textContent = history.finalizing ? "Finishing…" : "Finish with available snapshots";
|
|
1492
|
-
const items = comparisonItems();
|
|
1493
|
-
const limitations = [...new Set([
|
|
1494
|
-
...(comparison.content?.limitations || []),
|
|
1495
|
-
...(comparison.source?.limitations || []),
|
|
1496
|
-
])];
|
|
1497
|
-
$("historyLimitations").hidden = limitations.length === 0;
|
|
1498
|
-
$("historyLimitationsList").replaceChildren();
|
|
1499
|
-
for (const limitation of limitations) {
|
|
1500
|
-
const line = document.createElement("li");
|
|
1501
|
-
line.textContent = String(limitation).replaceAll("-", " ").replaceAll("_", " ");
|
|
1502
|
-
$("historyLimitationsList").append(line);
|
|
1503
|
-
}
|
|
1504
|
-
const counts = displayedComparison.counts;
|
|
1505
|
-
$("historyCounts").textContent = modes.length && counts
|
|
1506
|
-
? `${counts.added} Added · ${counts.modified} Modified · ${counts.removed} Removed`
|
|
1507
|
-
: "";
|
|
1508
|
-
$("historyCounts").hidden = !counts;
|
|
1509
|
-
history.index = Math.max(0, Math.min(history.index, items.length - 1));
|
|
1510
|
-
$("changeDetail").hidden = !modes.length;
|
|
1511
|
-
const comparisonHeader = $("historyPanel").querySelector(".comparison-header");
|
|
1512
|
-
if (comparisonHeader)
|
|
1513
|
-
comparisonHeader.hidden = !modes.length;
|
|
1514
|
-
if (modes.length) {
|
|
1515
|
-
comparisonView.render({ ...displayedComparison, changes: items }, {
|
|
1516
|
-
mode: history.mode, key: `${history.selectedId}:${history.targetKey}`,
|
|
1517
|
-
});
|
|
1518
|
-
comparisonView.select(history.index);
|
|
1519
|
-
}
|
|
1520
|
-
$("changeJump").replaceChildren();
|
|
1521
|
-
for (const [index, item] of items.entries()) {
|
|
1522
|
-
const option = document.createElement("option");
|
|
1523
|
-
option.value = index;
|
|
1524
|
-
option.textContent = `${index + 1}. ${changeKind(item)} · ${tidyMiddle(item.label || excerpt(item.after) || excerpt(item.before) || "Structure", 65)}`;
|
|
1525
|
-
$("changeJump").append(option);
|
|
1526
|
-
}
|
|
1527
|
-
$("changeJump").value = String(history.index);
|
|
1528
|
-
$("changeNavigation").hidden = items.length < 2;
|
|
1529
|
-
$("previousChange").disabled = history.index === 0;
|
|
1530
|
-
$("nextChange").disabled = history.index >= items.length - 1;
|
|
1531
|
-
$("changePosition").textContent = `${items.length ? history.index + 1 : 0} of ${items.length}`;
|
|
1141
|
+
changesRuntime.publish();
|
|
1532
1142
|
}
|
|
1533
1143
|
async function captureResult(round, target, automatic = false, signal) {
|
|
1534
1144
|
if (history.captureBusy || !round || target?.key !== frameController.state.key)
|
|
@@ -1583,8 +1193,7 @@ async function captureResult(round, target, automatic = false, signal) {
|
|
|
1583
1193
|
diagnostic("capture-failure-report-failed", { message: reportError.message });
|
|
1584
1194
|
});
|
|
1585
1195
|
}
|
|
1586
|
-
|
|
1587
|
-
$("historyError").textContent = `${err.message} Use Capture result to retry; existing captures are unchanged.`;
|
|
1196
|
+
history.failures.set(`${roundId(round)}:${target.key}`, `${err.message} Use Capture result to retry; existing captures are unchanged.`);
|
|
1588
1197
|
if (automatic)
|
|
1589
1198
|
throw err;
|
|
1590
1199
|
}
|
|
@@ -1602,35 +1211,21 @@ function scheduleResultCapture() {
|
|
|
1602
1211
|
function setHistoryView(comparing) {
|
|
1603
1212
|
captureEditState();
|
|
1604
1213
|
state.comparing = comparing;
|
|
1214
|
+
changesRuntime.publish();
|
|
1605
1215
|
closeModeMenu("history-switch");
|
|
1606
1216
|
document.body.classList.toggle("comparing", comparing);
|
|
1607
1217
|
syncFrameInteraction();
|
|
1608
1218
|
frameHost.current.setAttribute("aria-hidden", String(comparing));
|
|
1609
1219
|
$("historyPanel").hidden = !comparing;
|
|
1610
|
-
$("latestVersion").setAttribute("aria-pressed", String(!comparing));
|
|
1611
|
-
$("seeChanges").setAttribute("aria-pressed", String(comparing));
|
|
1612
1220
|
if (!comparing && frameController.state.pendingReload)
|
|
1613
|
-
|
|
1221
|
+
state.reloadVisible = true;
|
|
1614
1222
|
if (comparing) {
|
|
1615
1223
|
closeDrawer();
|
|
1616
1224
|
void refreshHistory();
|
|
1617
1225
|
}
|
|
1618
1226
|
render();
|
|
1619
1227
|
}
|
|
1620
|
-
|
|
1621
|
-
listen($("seeChanges"), "click", () => setHistoryView(true));
|
|
1622
|
-
listen($("roundPicker"), "change", () => void historyController.selectRound($("roundPicker").value));
|
|
1623
|
-
listen($("historyTarget"), "change", () => void historyController.selectTarget($("historyTarget").value));
|
|
1624
|
-
function jumpToChange(index) {
|
|
1625
|
-
history.index = index;
|
|
1626
|
-
renderHistory();
|
|
1627
|
-
comparisonView.select(history.index, { scroll: true });
|
|
1628
|
-
}
|
|
1629
|
-
listen($("previousChange"), "click", () => jumpToChange(history.index - 1));
|
|
1630
|
-
listen($("nextChange"), "click", () => jumpToChange(history.index + 1));
|
|
1631
|
-
listen($("changeJump"), "change", (event) => jumpToChange(Number(event.target.value)));
|
|
1632
|
-
listen($("captureResult"), "click", () => void captureResult(history.round, selectedTarget()));
|
|
1633
|
-
listen($("finishCapture"), "click", async () => {
|
|
1228
|
+
async function finishCapture() {
|
|
1634
1229
|
const target = selectedTarget();
|
|
1635
1230
|
const round = history.round;
|
|
1636
1231
|
if (!target || !round || history.finalizing || history.captureBusy)
|
|
@@ -1638,23 +1233,23 @@ listen($("finishCapture"), "click", async () => {
|
|
|
1638
1233
|
history.finalizing = true;
|
|
1639
1234
|
renderHistory();
|
|
1640
1235
|
try {
|
|
1641
|
-
await api(historyUrl(`/${roundId(round)}/capture`), {
|
|
1236
|
+
const result = await api(historyUrl(`/${roundId(round)}/capture`), {
|
|
1642
1237
|
method: "POST",
|
|
1643
1238
|
body: JSON.stringify({ key: target.key, manual: true, finalUnavailable: true }),
|
|
1644
1239
|
});
|
|
1240
|
+
requireCaptureSuccess(result, target.key);
|
|
1645
1241
|
history.failures.delete(`${roundId(round)}:${target.key}`);
|
|
1646
1242
|
await refreshHistory();
|
|
1647
1243
|
announce("Finished with available snapshots. Missing Content remains unavailable.");
|
|
1648
1244
|
}
|
|
1649
1245
|
catch (err) {
|
|
1650
|
-
|
|
1651
|
-
$("historyError").textContent = `${err.message} The capture is still open; you can retry.`;
|
|
1246
|
+
history.failures.set(`${roundId(round)}:${target.key}`, `${err.message} The capture is still open; you can retry.`);
|
|
1652
1247
|
}
|
|
1653
1248
|
finally {
|
|
1654
1249
|
history.finalizing = false;
|
|
1655
1250
|
renderHistory();
|
|
1656
1251
|
}
|
|
1657
|
-
}
|
|
1252
|
+
}
|
|
1658
1253
|
// -------------------------------------------------------------------- saving
|
|
1659
1254
|
const saveNow = (html) => saveController.save(html);
|
|
1660
1255
|
// ------------------------------------------------------------ frame messages
|
|
@@ -1776,7 +1371,7 @@ listen(window, "message", async (event) => {
|
|
|
1776
1371
|
state.compose.relation = geometry.relation;
|
|
1777
1372
|
state.compose.clip = geometry.clip;
|
|
1778
1373
|
state.compose.horizontal = geometry.horizontal ?? state.compose.horizontal;
|
|
1779
|
-
|
|
1374
|
+
scheduleContextualPosition();
|
|
1780
1375
|
}
|
|
1781
1376
|
break;
|
|
1782
1377
|
case "eh:commentGeometry": {
|
|
@@ -1796,7 +1391,7 @@ listen(window, "message", async (event) => {
|
|
|
1796
1391
|
visible: msg.visible !== false && rects.length > 0,
|
|
1797
1392
|
});
|
|
1798
1393
|
if (state.activeSavedCommentId === id) {
|
|
1799
|
-
|
|
1394
|
+
scheduleContextualPosition();
|
|
1800
1395
|
restoreTransientFocus(false);
|
|
1801
1396
|
}
|
|
1802
1397
|
break;
|
|
@@ -1809,10 +1404,10 @@ listen(window, "message", async (event) => {
|
|
|
1809
1404
|
break;
|
|
1810
1405
|
case "eh:interaction":
|
|
1811
1406
|
closeModeMenu("frame-interaction");
|
|
1812
|
-
|
|
1407
|
+
recoveryRuntime.commands.setMenuOpen(false, { restoreFocus: false });
|
|
1813
1408
|
break;
|
|
1814
1409
|
case "eh:dismiss":
|
|
1815
|
-
if (
|
|
1410
|
+
if (!state.composerDraft.text.trim())
|
|
1816
1411
|
cancelCompose();
|
|
1817
1412
|
break;
|
|
1818
1413
|
case "eh:activate":
|
|
@@ -1920,118 +1515,18 @@ listen(window, "message", async (event) => {
|
|
|
1920
1515
|
}
|
|
1921
1516
|
});
|
|
1922
1517
|
// --------------------------------------------------------------- UI wiring
|
|
1923
|
-
let composeComposing = false;
|
|
1924
|
-
listen($("composeAdd"), "click", commitCompose);
|
|
1925
|
-
listen($("composeCancel"), "click", cancelCompose);
|
|
1926
|
-
listen($("composeClose"), "click", cancelCompose);
|
|
1927
|
-
listen($("composeReveal"), "click", () => {
|
|
1928
|
-
if (!state.compose)
|
|
1929
|
-
return;
|
|
1930
|
-
diagnostic("reveal-target-requested");
|
|
1931
|
-
toFrame({ type: "eh:revealTarget", targetGeneration: state.compose.generation });
|
|
1932
|
-
});
|
|
1933
|
-
listen($("compose"), "mousedown", (event) => {
|
|
1934
|
-
if (event.target.closest("button"))
|
|
1935
|
-
return;
|
|
1936
|
-
if (event.target !== $("composeText"))
|
|
1937
|
-
$("composeText").focus();
|
|
1938
|
-
});
|
|
1939
|
-
listen($("composeText"), "compositionstart", () => {
|
|
1940
|
-
composeComposing = true;
|
|
1941
|
-
});
|
|
1942
|
-
listen($("composeText"), "compositionend", () => {
|
|
1943
|
-
composeComposing = false;
|
|
1944
|
-
});
|
|
1945
|
-
listen($("composeText"), "keydown", (event) => {
|
|
1946
|
-
if (event.key === "Enter") {
|
|
1947
|
-
event.stopPropagation();
|
|
1948
|
-
if (composeComposing || event.isComposing || event.shiftKey)
|
|
1949
|
-
return;
|
|
1950
|
-
event.preventDefault();
|
|
1951
|
-
void commitCompose();
|
|
1952
|
-
}
|
|
1953
|
-
if (event.key === "Escape") {
|
|
1954
|
-
event.stopPropagation();
|
|
1955
|
-
event.preventDefault();
|
|
1956
|
-
cancelCompose();
|
|
1957
|
-
}
|
|
1958
|
-
});
|
|
1959
|
-
listen($("modeButton"), "click", () => {
|
|
1960
|
-
if ($("modeMenu").hidden)
|
|
1961
|
-
openModeMenu();
|
|
1962
|
-
else
|
|
1963
|
-
closeModeMenu("trigger", { restoreFocus: true });
|
|
1964
|
-
});
|
|
1965
|
-
listen($("modeMenu"), "click", (event) => {
|
|
1966
|
-
const item = event.target.closest("[data-mode]");
|
|
1967
|
-
if (item)
|
|
1968
|
-
void setReviewMode(item.dataset.mode);
|
|
1969
|
-
});
|
|
1970
|
-
listen($("modeMenu"), "keydown", (event) => {
|
|
1971
|
-
const items = [...$("modeMenu").querySelectorAll("[data-mode]")];
|
|
1972
|
-
const current = items.indexOf(document.activeElement);
|
|
1973
|
-
if (event.key === "ArrowDown" || event.key === "ArrowUp") {
|
|
1974
|
-
event.preventDefault();
|
|
1975
|
-
const delta = event.key === "ArrowDown" ? 1 : -1;
|
|
1976
|
-
items[(current + delta + items.length) % items.length].focus();
|
|
1977
|
-
}
|
|
1978
|
-
else if (event.key === "Escape") {
|
|
1979
|
-
event.preventDefault();
|
|
1980
|
-
event.stopPropagation();
|
|
1981
|
-
closeModeMenu("escape", { restoreFocus: true });
|
|
1982
|
-
}
|
|
1983
|
-
});
|
|
1984
|
-
listen($("commentsButton"), "click", openDrawer);
|
|
1985
|
-
listen($("drawerClose"), "click", closeDrawer);
|
|
1986
|
-
listen($("drawerBackdrop"), "click", closeDrawer);
|
|
1987
|
-
const dismissModeMenuOutside = (event) => {
|
|
1988
|
-
if (!state.modeMenuOpen || event.target.closest(".mode-control"))
|
|
1989
|
-
return;
|
|
1990
|
-
closeModeMenu(event.type === "focusin" ? "parent-focus" : "parent-pointer");
|
|
1991
|
-
};
|
|
1992
|
-
listen(document, "pointerdown", dismissModeMenuOutside, true);
|
|
1993
|
-
listen(document, "focusin", dismissModeMenuOutside, true);
|
|
1994
|
-
function closeCommentMenu({ restoreFocus = false } = {}) {
|
|
1995
|
-
const menu = state.commentUi.menu;
|
|
1996
|
-
if (!menu)
|
|
1997
|
-
return false;
|
|
1998
|
-
clearOwned(state.commentUi, "menu");
|
|
1999
|
-
toFrame({ type: "eh:modeMenuState", open: false });
|
|
2000
|
-
if (restoreFocus)
|
|
2001
|
-
requestControlFocus(menu.triggerId);
|
|
2002
|
-
render();
|
|
2003
|
-
return true;
|
|
2004
|
-
}
|
|
2005
|
-
const dismissCommentMenuOutside = (event) => {
|
|
2006
|
-
const menu = state.commentUi.menu;
|
|
2007
|
-
if (!menu)
|
|
2008
|
-
return;
|
|
2009
|
-
const menuElement = document.getElementById(controlId(menu.commentId, menu.surface, "menu"));
|
|
2010
|
-
const trigger = document.getElementById(menu.triggerId);
|
|
2011
|
-
if (menuElement?.contains(event.target) || trigger?.contains(event.target))
|
|
2012
|
-
return;
|
|
2013
|
-
closeCommentMenu();
|
|
2014
|
-
};
|
|
2015
|
-
listen(document, "pointerdown", dismissCommentMenuOutside, true);
|
|
2016
|
-
listen(document, "focusin", dismissCommentMenuOutside, true);
|
|
2017
|
-
const sendFeedback = () => feedbackController.send();
|
|
2018
|
-
listen($("send"), "click", () => void sendFeedback());
|
|
2019
|
-
listen($("revert"), "click", async () => {
|
|
2020
|
-
const count = state.page.edits.length;
|
|
2021
|
-
if (!window.confirm(`Discard all ${count} of your edits?`))
|
|
2022
|
-
return;
|
|
2023
|
-
try {
|
|
2024
|
-
await saveController.revert();
|
|
2025
|
-
}
|
|
2026
|
-
catch (err) {
|
|
2027
|
-
toast(err.message);
|
|
2028
|
-
}
|
|
2029
|
-
});
|
|
2030
1518
|
/** The session is over: freeze the page and say so. Feedback is already safe. */
|
|
2031
1519
|
function showEnded() {
|
|
2032
1520
|
if (document.querySelector(".ended"))
|
|
2033
1521
|
return;
|
|
2034
1522
|
state.ended = true;
|
|
1523
|
+
state.modeMenuOpen = false;
|
|
1524
|
+
toolbarRuntime.publish();
|
|
1525
|
+
recoveryRuntime.publish();
|
|
1526
|
+
commentsRuntime.publish();
|
|
1527
|
+
contextualRuntime.publish();
|
|
1528
|
+
feedbackRuntime.publish();
|
|
1529
|
+
changesRuntime.publish();
|
|
2035
1530
|
captureCoordinator.stop();
|
|
2036
1531
|
manualCaptureController?.abort();
|
|
2037
1532
|
captures.cancel("Review ended");
|
|
@@ -2046,60 +1541,25 @@ function showEnded() {
|
|
|
2046
1541
|
overlay.append(title, line);
|
|
2047
1542
|
document.body.append(overlay);
|
|
2048
1543
|
}
|
|
2049
|
-
|
|
2050
|
-
const page = state.page;
|
|
2051
|
-
const otherTotal = (state.others || []).reduce((sum, o) => sum + o.count, 0);
|
|
2052
|
-
const unsent = page ? (page.comments || []).length + (page.edits || []).length + otherTotal : 0;
|
|
2053
|
-
const message = unsent
|
|
2054
|
-
? `End this review? ${unsent} unsent ${unsent === 1 ? "item" : "items"} will be kept for next time.`
|
|
2055
|
-
: "End this review? The waiting agent will be told to stop polling.";
|
|
2056
|
-
if (!window.confirm(message))
|
|
2057
|
-
return;
|
|
2058
|
-
try {
|
|
2059
|
-
// Ship anything still sitting in the SDK's debounce windows first.
|
|
2060
|
-
await flushFrame();
|
|
2061
|
-
await api(`/api/session/${state.sessionId}/end`, { method: "POST" });
|
|
2062
|
-
showEnded();
|
|
2063
|
-
}
|
|
2064
|
-
catch (err) {
|
|
2065
|
-
toast(err.message);
|
|
2066
|
-
}
|
|
2067
|
-
});
|
|
2068
|
-
listen($("handoffCopy"), "click", async (event) => {
|
|
2069
|
-
const button = event.currentTarget;
|
|
2070
|
-
try {
|
|
2071
|
-
await navigator.clipboard.writeText($("handoffCmd").textContent);
|
|
2072
|
-
button.textContent = "Copied";
|
|
2073
|
-
afterDelay(() => {
|
|
2074
|
-
button.textContent = "Copy prompt";
|
|
2075
|
-
}, 1600);
|
|
2076
|
-
}
|
|
2077
|
-
catch {
|
|
2078
|
-
toast("Couldn't copy — select the prompt and copy it manually");
|
|
2079
|
-
}
|
|
2080
|
-
});
|
|
2081
|
-
listen($("note"), "input", (event) => {
|
|
2082
|
-
const el = event.target;
|
|
2083
|
-
el.style.height = "auto";
|
|
2084
|
-
el.style.height = `${Math.min(el.scrollHeight + 2, window.innerHeight * 0.4)}px`;
|
|
2085
|
-
render(); // keep the send button in step with note-only feedback
|
|
2086
|
-
});
|
|
2087
|
-
listen($("theme"), "click", () => {
|
|
1544
|
+
function toggleTheme() {
|
|
2088
1545
|
const dark = document.documentElement.dataset.theme !== "dark";
|
|
2089
1546
|
applyTheme(dark);
|
|
2090
1547
|
try {
|
|
2091
1548
|
localStorage.setItem("doc-review:theme", dark ? "dark" : "light");
|
|
2092
1549
|
}
|
|
2093
|
-
catch {
|
|
2094
|
-
});
|
|
1550
|
+
catch (error) {
|
|
1551
|
+
diagnostic("theme-preference-save-failed", { message: error.message });
|
|
1552
|
+
announce("Theme changed for this review, but the preference could not be saved.");
|
|
1553
|
+
}
|
|
1554
|
+
}
|
|
2095
1555
|
function applyTheme(dark) {
|
|
2096
1556
|
document.documentElement.dataset.theme = dark ? "dark" : "light";
|
|
2097
|
-
|
|
2098
|
-
|
|
2099
|
-
button.setAttribute("aria-label", button.title);
|
|
2100
|
-
replaceIcon(button, dark ? "sun" : "moon");
|
|
1557
|
+
state.theme = dark ? "dark" : "light";
|
|
1558
|
+
toolbarRuntime.publish();
|
|
2101
1559
|
}
|
|
2102
1560
|
listen(document, "keydown", (event) => {
|
|
1561
|
+
if (event.defaultPrevented || feedbackRuntime.getSnapshot().dialog)
|
|
1562
|
+
return;
|
|
2103
1563
|
const meta = event.metaKey || event.ctrlKey;
|
|
2104
1564
|
// ⌘S is reassurance only: flush pending keystrokes, never a state change.
|
|
2105
1565
|
if (meta && event.key.toLowerCase() === "s") {
|
|
@@ -2108,25 +1568,11 @@ listen(document, "keydown", (event) => {
|
|
|
2108
1568
|
renderSave();
|
|
2109
1569
|
return;
|
|
2110
1570
|
}
|
|
2111
|
-
if (event.key === "Tab" && state.commentUi.menu) {
|
|
2112
|
-
closeCommentMenu();
|
|
2113
|
-
return;
|
|
2114
|
-
}
|
|
2115
1571
|
if (event.key !== "Escape")
|
|
2116
1572
|
return;
|
|
2117
|
-
const recoveryMenu = event.target.closest?.("#reviewDetails");
|
|
2118
|
-
if (recoveryMenu) {
|
|
2119
|
-
recoveryMenu.open = false;
|
|
2120
|
-
recoveryMenu.querySelector("summary")?.focus({ preventScroll: true });
|
|
2121
|
-
event.preventDefault();
|
|
2122
|
-
event.stopPropagation();
|
|
2123
|
-
return;
|
|
2124
|
-
}
|
|
2125
1573
|
let handled = false;
|
|
2126
1574
|
if (state.commentUi.confirmation)
|
|
2127
1575
|
handled = cancelDeleteConfirmation();
|
|
2128
|
-
else if (state.commentUi.menu)
|
|
2129
|
-
handled = closeCommentMenu({ restoreFocus: true });
|
|
2130
1576
|
else if (state.commentUi.edit)
|
|
2131
1577
|
handled = cancelCommentEdit();
|
|
2132
1578
|
else if (state.compose)
|
|
@@ -2148,8 +1594,9 @@ listen(document, "keydown", (event) => {
|
|
|
2148
1594
|
function holdReload() {
|
|
2149
1595
|
frameController.holdReload();
|
|
2150
1596
|
saveController.hold();
|
|
2151
|
-
|
|
2152
|
-
|
|
1597
|
+
state.reloadVisible = true;
|
|
1598
|
+
state.reloadFailed = false;
|
|
1599
|
+
state.reloadMessage = saveController.state.dirty || saveController.state.conflict
|
|
2153
1600
|
? "The source changed. This page has unsaved edits; reload discards those page edits, but keeps open comment drafts. Stale edits will not overwrite the new source."
|
|
2154
1601
|
: "The source changed. Reload keeps your comment drafts as unresolved excerpts so they cannot attach to the wrong content.";
|
|
2155
1602
|
captureCoordinator.reset();
|
|
@@ -2175,9 +1622,9 @@ async function reloadLatest({ explicit = false } = {}) {
|
|
|
2175
1622
|
}
|
|
2176
1623
|
const generation = beginFrameTransition();
|
|
2177
1624
|
frameController.resetRetries();
|
|
1625
|
+
state.reloadVisible = false;
|
|
2178
1626
|
frameController.startReload();
|
|
2179
1627
|
saveController.reset();
|
|
2180
|
-
$("reloadNotice").hidden = true;
|
|
2181
1628
|
try {
|
|
2182
1629
|
const page = await api(pageUrl(key, state.sessionId), undefined, decodePage);
|
|
2183
1630
|
if (frameController.state.key !== key || frameController.state.generation !== generation)
|
|
@@ -2186,13 +1633,10 @@ async function reloadLatest({ explicit = false } = {}) {
|
|
|
2186
1633
|
// Keep parent-owned drafts rather than replacing their text on a source update.
|
|
2187
1634
|
replacePage(state, page);
|
|
2188
1635
|
saveController.reset();
|
|
2189
|
-
render();
|
|
2190
1636
|
if (state.compose) {
|
|
2191
|
-
|
|
2192
|
-
$("compose").classList.add("sheet");
|
|
2193
|
-
$("composeError").hidden = false;
|
|
2194
|
-
$("composeError").textContent = "The source changed. This draft keeps its original excerpt; cancel it to select a new target.";
|
|
1637
|
+
state.composerDraft.error = "The source changed. This draft keeps its original excerpt; cancel it to select a new target.";
|
|
2195
1638
|
}
|
|
1639
|
+
render();
|
|
2196
1640
|
await registerFrame(key, generation);
|
|
2197
1641
|
}
|
|
2198
1642
|
catch (err) {
|
|
@@ -2200,10 +1644,8 @@ async function reloadLatest({ explicit = false } = {}) {
|
|
|
2200
1644
|
failFrame(err.message);
|
|
2201
1645
|
}
|
|
2202
1646
|
}
|
|
2203
|
-
listen($("safeReload"), "click", () => void reloadLatest({ explicit: true }));
|
|
2204
|
-
listen($("keepCurrent"), "click", () => { $("reloadNotice").hidden = true; announce("Keeping the current page. Return to Latest version to reload when ready."); });
|
|
2205
1647
|
listen(window, "beforeunload", (event) => {
|
|
2206
|
-
if (!saveController.state.dirty && !draftCount(state,
|
|
1648
|
+
if (!saveController.state.dirty && !draftCount(state, state.noteDraft.text) && !saveController.queued(frameController.state.key))
|
|
2207
1649
|
return;
|
|
2208
1650
|
event.preventDefault();
|
|
2209
1651
|
event.returnValue = "";
|
|
@@ -2212,6 +1654,12 @@ listen(window, "pagehide", (event) => {
|
|
|
2212
1654
|
if (event.persisted)
|
|
2213
1655
|
return;
|
|
2214
1656
|
state.ended = true;
|
|
1657
|
+
state.modeMenuOpen = false;
|
|
1658
|
+
toolbarRuntime.publish();
|
|
1659
|
+
recoveryRuntime.publish();
|
|
1660
|
+
commentsRuntime.publish();
|
|
1661
|
+
contextualRuntime.publish();
|
|
1662
|
+
changesRuntime.publish();
|
|
2215
1663
|
captureCoordinator.stop();
|
|
2216
1664
|
manualCaptureController?.abort();
|
|
2217
1665
|
captures.cancel("Review closed");
|
|
@@ -2225,7 +1673,10 @@ const connect = () => reviewController.connect();
|
|
|
2225
1673
|
try {
|
|
2226
1674
|
applyTheme(localStorage.getItem("doc-review:theme") === "dark");
|
|
2227
1675
|
}
|
|
2228
|
-
catch {
|
|
1676
|
+
catch (error) {
|
|
1677
|
+
applyTheme(false);
|
|
1678
|
+
diagnostic("theme-preference-read-failed", { message: error.message });
|
|
1679
|
+
}
|
|
2229
1680
|
const bootstrap = await api(`/api/session/${state.sessionId}/page`);
|
|
2230
1681
|
if (bootstrap && bootstrap.page)
|
|
2231
1682
|
state.pollCommand = bootstrap.page.pollCommand;
|
|
@@ -2238,18 +1689,12 @@ const connect = () => reviewController.connect();
|
|
|
2238
1689
|
})().catch((error) => {
|
|
2239
1690
|
failFrame(`Review could not start: ${error.message}`);
|
|
2240
1691
|
});
|
|
2241
|
-
listen(window, "resize",
|
|
2242
|
-
positionCompose();
|
|
2243
|
-
if (state.activeSavedCommentId)
|
|
2244
|
-
positionAlignedCard();
|
|
2245
|
-
});
|
|
1692
|
+
listen(window, "resize", scheduleContextualPosition);
|
|
2246
1693
|
if (window.visualViewport) {
|
|
2247
|
-
listen(window.visualViewport, "resize",
|
|
2248
|
-
listen(window.visualViewport, "scroll",
|
|
1694
|
+
listen(window.visualViewport, "resize", scheduleContextualPosition);
|
|
1695
|
+
listen(window.visualViewport, "scroll", scheduleContextualPosition);
|
|
2249
1696
|
}
|
|
2250
1697
|
reviewController.own(frameHost.onFocus(() => {
|
|
2251
|
-
closeCommentMenu();
|
|
2252
1698
|
if (state.compose)
|
|
2253
1699
|
$("compose").classList.add("pass-through");
|
|
2254
1700
|
}));
|
|
2255
|
-
listen($("compose"), "focusin", () => $("compose").classList.remove("pass-through"));
|