codex-overleaf-link 1.8.5 → 1.9.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (74) hide show
  1. package/README.md +45 -21
  2. package/bin/codex-overleaf-link.mjs +58 -0
  3. package/extension/assets/icons/codex-overleaf-dialog-icon.png +0 -0
  4. package/extension/assets/icons/codex-overleaf-icon.png +0 -0
  5. package/extension/assets/icons/icon128.png +0 -0
  6. package/extension/assets/icons/icon16.png +0 -0
  7. package/extension/assets/icons/icon32.png +0 -0
  8. package/extension/assets/icons/icon48.png +0 -0
  9. package/extension/bootstrap/background.js +404 -0
  10. package/extension/bootstrap/manifest.template.json +51 -0
  11. package/extension/bootstrap/popup.css +26 -0
  12. package/extension/bootstrap/popup.html +38 -0
  13. package/extension/bootstrap/popup.js +44 -0
  14. package/extension/bootstrap/runtimeContext.js +2 -0
  15. package/extension/runtime-manifest.json +60 -0
  16. package/extension/src/background.js +532 -0
  17. package/extension/src/content/applyResultFormatters.js +387 -0
  18. package/extension/src/content/composerAttachments.js +522 -0
  19. package/extension/src/content/composerPanel.js +254 -0
  20. package/extension/src/content/contentRuntime.js +8895 -0
  21. package/extension/src/content/contextTray.js +686 -0
  22. package/extension/src/content/diagnosticsController.js +659 -0
  23. package/extension/src/content/diagnosticsPanel.js +342 -0
  24. package/extension/src/content/diffReviewPanel.js +797 -0
  25. package/extension/src/content/localSkillsPanel.js +280 -0
  26. package/extension/src/content/markdownText.js +606 -0
  27. package/extension/src/content/mirrorHealth.js +92 -0
  28. package/extension/src/content/modelPicker.js +489 -0
  29. package/extension/src/content/nativeChannel.js +62 -0
  30. package/extension/src/content/otWarmMirror.js +964 -0
  31. package/extension/src/content/otWarmMirrorController.js +174 -0
  32. package/extension/src/content/panelMaintenance.js +367 -0
  33. package/extension/src/content/panelRenderer.js +253 -0
  34. package/extension/src/content/recentProjects.js +1063 -0
  35. package/extension/src/content/reviewHunks.js +247 -0
  36. package/extension/src/content/runController.js +242 -0
  37. package/extension/src/content/runTimelineView.js +1126 -0
  38. package/extension/src/content/sessionManager.js +487 -0
  39. package/extension/src/content/sessionPanel.js +263 -0
  40. package/extension/src/content/settingsPanel.js +527 -0
  41. package/extension/src/content/themeController.js +91 -0
  42. package/extension/src/content/updateIdle.js +67 -0
  43. package/extension/src/content/writebackController.js +300 -0
  44. package/extension/src/content/writebackOrchestrator.js +850 -0
  45. package/extension/src/contentScript.js +8 -0
  46. package/extension/src/page/compileBridge.js +460 -0
  47. package/extension/src/page/overleafCapabilities.js +100 -0
  48. package/extension/src/page/overleafEditor.js +282 -0
  49. package/extension/src/page/overleafProjectSnapshot.js +234 -0
  50. package/extension/src/page/overleafRealtimeObserver.js +491 -0
  51. package/extension/src/page/pageBridgeCapability.js +71 -0
  52. package/extension/src/page/snapshotRouter.js +948 -0
  53. package/extension/src/page/trackedChangesLifecycle.js +1506 -0
  54. package/extension/src/page/treeOperations.js +1584 -0
  55. package/extension/src/page/writeGuard.js +142 -0
  56. package/extension/src/page/writebackRouter.js +1934 -0
  57. package/extension/src/pageBridge.js +2224 -0
  58. package/extension/src/popup.js +247 -0
  59. package/extension/src/shared/compatibility.js +1 -1
  60. package/extension/styles/panel.css +4048 -0
  61. package/native-host/src/index.js +9 -2
  62. package/native-host/src/launcher.js +2 -0
  63. package/native-host/src/managedInstall.js +393 -0
  64. package/native-host/src/managedLauncherRuntime.js +71 -0
  65. package/native-host/src/nativeEnvironment.js +4 -0
  66. package/native-host/src/nativeHostPlatform.js +16 -0
  67. package/native-host/src/runtimeInstaller.js +6 -1
  68. package/native-host/src/taskRunnerRuntime.js +8 -0
  69. package/native-host/src/updateArchive.js +253 -0
  70. package/native-host/src/updateManager.js +548 -0
  71. package/native-host/src/updateTrust.js +156 -0
  72. package/package.json +11 -2
  73. package/scripts/install-managed.mjs +64 -0
  74. package/scripts/uninstall-managed.mjs +48 -0
@@ -0,0 +1,1506 @@
1
+ (function initCodexOverleafTrackedChangesLifecycle(root, factory) {
2
+ if (typeof module === 'object' && module.exports) {
3
+ module.exports = factory();
4
+ } else {
5
+ root.CodexOverleafTrackedChangesLifecycle = factory();
6
+ }
7
+ })(typeof window !== 'undefined' ? window : globalThis, function trackedChangesLifecycleFactory() {
8
+ 'use strict';
9
+
10
+ // Tracked-changes lifecycle — carved out of writebackRouter.js in v1.8.0
11
+ // (structural-debt phase 7, #117): the accept/reject flows, the
12
+ // accept-replay + editor-undo recovery machinery, the reviewing/editing
13
+ // blocked-result builders, and the DOM collectors for Overleaf's
14
+ // tracked-change widgets. Code moved verbatim (original indentation kept);
15
+ // page-side collaborators are factory-injected by writebackRouter, which
16
+ // remains the only consumer and the owner of applyOperations itself.
17
+ function create(deps = {}) {
18
+ const {
19
+ window,
20
+ checkWritebackRunProjectId,
21
+ buildNoTraceUndoBlockedResult,
22
+ buildReviewingRequiredBlockedResult,
23
+ buildEditingRequiredBlockedResult,
24
+ compileBridge,
25
+ applyOperationsCore,
26
+ clickNode,
27
+ compact,
28
+ invalidProjectPathResult,
29
+ isEditingConfirmedForNoTraceUndo,
30
+ isReviewingConfirmedForWrite,
31
+ uniqueNodes,
32
+ normalizeTrackedChangeRefs,
33
+ applyOperationsWithNoTraceUndo,
34
+ applyTextPatches,
35
+ buildPageFailure,
36
+ delay,
37
+ getActiveFilePath,
38
+ getReviewingState,
39
+ isEditorUndoControl,
40
+ isInsideCodexPanel,
41
+ normalizeOperationPaths,
42
+ normalizeReviewingSignalText,
43
+ normalizeSafeProjectPath,
44
+ openFileByPath,
45
+ readActiveEditorText,
46
+ readNodeSignalText,
47
+ setReviewingEnabled,
48
+ summarizeReviewingToggleResult,
49
+ verifyActiveEditorText,
50
+ collectElements,
51
+ } = deps;
52
+
53
+ async function rejectTrackedChanges(params = {}) {
54
+ // Welcome-panel + write-guard: defense-in-depth.
55
+ // The pageBridge wrapper already runs the runProjectId guard, but a
56
+ // future caller could reach the router directly. A missing or empty
57
+ // `runProjectId` blocks the reject with the same shape the page-side
58
+ // guard uses. Existing tests that target individual reject behaviors set
59
+ // `runProjectId` on params; tests that intentionally omit it land here.
60
+ const writeGuardBlock = checkWritebackRunProjectId(params);
61
+ if (writeGuardBlock) return writeGuardBlock;
62
+ const trackedChanges = normalizeTrackedChangeRefs(params.trackedChanges || []);
63
+ const expectedFiles = Array.isArray(params.expectedFiles) ? params.expectedFiles : [];
64
+ const postFiles = Array.isArray(params.postFiles) ? params.postFiles : [];
65
+ const applied = [];
66
+ const skipped = [];
67
+ const appliedPaths = new Set();
68
+ const invalidTrackedChange = trackedChanges.find(trackedChange => trackedChange.invalidProjectPath);
69
+ if (invalidTrackedChange) {
70
+ return {
71
+ ok: false,
72
+ applied,
73
+ skipped: [{
74
+ trackedChange: invalidTrackedChange,
75
+ result: invalidProjectPathResult('tracked-change path')
76
+ }]
77
+ };
78
+ }
79
+
80
+ const editorUndo = await rejectTrackedChangesViaEditorUndo(expectedFiles, postFiles, applied);
81
+ if (editorUndo.ok) {
82
+ if (applied.length > 0) {
83
+ compileBridge.markSourceEdited();
84
+ }
85
+ return {
86
+ ok: true,
87
+ applied,
88
+ skipped
89
+ };
90
+ }
91
+ const snapshotUndo = await restoreExpectedFilesWithNoTraceUndo(expectedFiles, postFiles);
92
+ if (snapshotUndo.attempted) {
93
+ applied.push(...snapshotUndo.applied);
94
+ skipped.push(...snapshotUndo.skipped);
95
+ if (applied.length > 0) {
96
+ compileBridge.markSourceEdited();
97
+ }
98
+ return {
99
+ ok: skipped.length === 0,
100
+ applied,
101
+ skipped
102
+ };
103
+ }
104
+ if (editorUndo.attempted) {
105
+ skipped.push({
106
+ trackedChange: null,
107
+ result: editorUndo
108
+ });
109
+ if (applied.length > 0) {
110
+ compileBridge.markSourceEdited();
111
+ }
112
+ return {
113
+ ok: false,
114
+ applied,
115
+ skipped
116
+ };
117
+ }
118
+
119
+ if (!trackedChanges.length) {
120
+ return {
121
+ ok: false,
122
+ applied,
123
+ skipped: [{
124
+ trackedChange: null,
125
+ result: {
126
+ ok: false,
127
+ code: 'missing_tracked_changes',
128
+ reason: '这轮写入没有可识别的 Overleaf 留痕记录;为避免制造新的红线,Codex 没有执行文本补丁撤销。'
129
+ }
130
+ }]
131
+ };
132
+ }
133
+
134
+ for (const trackedChange of orderTrackedChangesForReviewAction(trackedChanges)) {
135
+ if (trackedChange.path && getActiveFilePath() !== trackedChange.path) {
136
+ const opened = await openFileByPath(trackedChange.path);
137
+ if (!opened.ok) {
138
+ if (expectedFiles.length || trackedChange.path) {
139
+ continue;
140
+ }
141
+ skipped.push({
142
+ trackedChange,
143
+ result: {
144
+ ok: false,
145
+ code: 'tracked_change_file_open_failed',
146
+ reason: `无法打开 ${trackedChange.path} 来查找这轮写入的留痕记录;Codex 没有用文本补丁伪撤销。`
147
+ }
148
+ });
149
+ continue;
150
+ }
151
+ }
152
+
153
+ let node = findTrackedChangeNode(trackedChange);
154
+ let actualTrackedChange = trackedChange;
155
+ if (!node && trackedChange.path && appliedPaths.has(trackedChange.path)) {
156
+ node = findNextTrackedChangeNodeForPath(trackedChange.path);
157
+ if (node) {
158
+ actualTrackedChange = trackedChangeRefFromNode(node, trackedChange.path);
159
+ }
160
+ }
161
+ if (!node) {
162
+ if (expectedFiles.length || trackedChange.path) {
163
+ continue;
164
+ }
165
+ skipped.push({
166
+ trackedChange,
167
+ result: {
168
+ ok: false,
169
+ code: 'tracked_change_not_found',
170
+ reason: '没有在 Overleaf 页面里找到这轮写入对应的留痕记录;Codex 没有用文本补丁伪撤销。'
171
+ }
172
+ });
173
+ continue;
174
+ }
175
+
176
+ const rejectControl = findRejectControlForTrackedChangeNode(node);
177
+ if (!rejectControl) {
178
+ if (expectedFiles.length || trackedChange.path) {
179
+ continue;
180
+ }
181
+ skipped.push({
182
+ trackedChange,
183
+ result: {
184
+ ok: false,
185
+ code: 'tracked_change_reject_control_not_found',
186
+ reason: '找到了这轮写入的留痕记录,但没有找到对应的 Reject/拒绝按钮;Codex 没有用文本补丁伪撤销。'
187
+ }
188
+ });
189
+ continue;
190
+ }
191
+
192
+ clickNode(rejectControl);
193
+ await delay(180);
194
+
195
+ if (findTrackedChangeNode(trackedChange)) {
196
+ if (expectedFiles.length || trackedChange.path) {
197
+ continue;
198
+ }
199
+ skipped.push({
200
+ trackedChange,
201
+ result: {
202
+ ok: false,
203
+ code: 'tracked_change_reject_not_confirmed',
204
+ reason: 'Codex 点击了 Reject/拒绝,但 Overleaf 页面仍显示这条留痕记录;请在 Overleaf 审阅面板手动拒绝。'
205
+ }
206
+ });
207
+ continue;
208
+ }
209
+
210
+ applied.push({
211
+ trackedChange: actualTrackedChange,
212
+ result: {
213
+ ok: true,
214
+ method: 'overleaf-review-reject'
215
+ }
216
+ });
217
+ if (actualTrackedChange.path) {
218
+ appliedPaths.add(actualTrackedChange.path);
219
+ }
220
+ }
221
+
222
+ if (expectedFiles.length) {
223
+ const completion = await rejectRemainingTrackedChangesForExpectedFiles(expectedFiles, applied);
224
+ if (!completion.ok) {
225
+ skipped.push({
226
+ trackedChange: null,
227
+ result: completion
228
+ });
229
+ }
230
+ } else {
231
+ const completion = await rejectRemainingTrackedChangesForTrackedPaths(trackedChanges, applied);
232
+ if (!completion.ok) {
233
+ skipped.push({
234
+ trackedChange: null,
235
+ result: completion
236
+ });
237
+ }
238
+ }
239
+
240
+ if (applied.length > 0) {
241
+ compileBridge.markSourceEdited();
242
+ }
243
+
244
+ return {
245
+ ok: skipped.length === 0,
246
+ applied,
247
+ skipped
248
+ };
249
+ }
250
+
251
+ // Accept All — instead of hunting Overleaf's per-change Accept controls (which
252
+ // is unreliable in real browser use), reuse two proven-reliable mechanisms:
253
+ //
254
+ // 1. Editor-undo the run's tracked writeback back to its pre-write content
255
+ // (the reject path's primary mechanism), which makes every one of the
256
+ // run's tracked changes vanish.
257
+ // 2. Switch Overleaf to Editing mode (Track Changes OFF) and re-apply the
258
+ // run's post-write content as a plain, untracked edit.
259
+ //
260
+ // Net result: the run's new content lands as permanent, untracked text — the
261
+ // run is decisively accepted, with no DOM-control hunting.
262
+ //
263
+ // If the editor-undo cannot reach the pre-write state (content drifted — e.g.
264
+ // the user edited after the run), this bails WITHOUT re-writing so it never
265
+ // makes the document worse, mirroring Undo's safety stance.
266
+ async function acceptTrackedChanges(params = {}) {
267
+ // Welcome-panel + write-guard: defense-in-depth.
268
+ // The pageBridge wrapper already runs the runProjectId guard, but a
269
+ // future caller could reach the router directly. A missing or empty
270
+ // `runProjectId` blocks the accept with the same shape the page-side
271
+ // guard uses.
272
+ const writeGuardBlock = checkWritebackRunProjectId(params);
273
+ if (writeGuardBlock) return writeGuardBlock;
274
+ const expectedFiles = Array.isArray(params.expectedFiles) ? params.expectedFiles : [];
275
+ const postFiles = Array.isArray(params.postFiles) ? params.postFiles : [];
276
+ const applied = [];
277
+ const skipped = [];
278
+ // diagnostics: a per-step trace returned to the caller so the run card can
279
+ // surface exactly what happened on each Accept All step. Each entry is
280
+ // `{ step, info }`; the content runtime translates `step` via i18n.
281
+ const diagnostics = [];
282
+ const pushDiagnostic = (step, info) => {
283
+ diagnostics.push({ step, info: info || {} });
284
+ };
285
+
286
+ const expectedByPath = new Map(expectedFiles
287
+ .filter(file => file?.path && typeof file.content === 'string')
288
+ .map(file => [normalizeSafeProjectPath(file.path), file.content])
289
+ .filter(([path]) => path));
290
+ const postByPath = new Map(postFiles
291
+ .filter(file => file?.path && typeof file.content === 'string')
292
+ .map(file => [normalizeSafeProjectPath(file.path), file.content])
293
+ .filter(([path]) => path));
294
+ const paths = Array.from(expectedByPath.keys()).filter(path => postByPath.has(path));
295
+ if (!paths.length) {
296
+ return {
297
+ ok: false,
298
+ applied,
299
+ skipped: [{
300
+ trackedChange: null,
301
+ result: {
302
+ ok: false,
303
+ code: 'accept_missing_run_content',
304
+ reason: '这轮写入没有可识别的写入前/写入后内容;Codex 没有把留痕改动接受为永久文本。'
305
+ }
306
+ }],
307
+ diagnostics
308
+ };
309
+ }
310
+
311
+ // Step 1: editor-undo the run's tracked writeback back to its pre-write
312
+ // content, removing every tracked change. This reuses the exact mechanism
313
+ // the reject path relies on (its primary path).
314
+ const appliedBeforeUndo = applied.length;
315
+ const editorUndo = await rejectTrackedChangesViaEditorUndo(expectedFiles, postFiles, applied);
316
+ pushDiagnostic('editorUndo', {
317
+ ok: editorUndo.ok === true,
318
+ attempted: editorUndo.attempted === true,
319
+ code: editorUndo.code || '',
320
+ reason: editorUndo.reason || '',
321
+ pathsProcessed: applied.length - appliedBeforeUndo
322
+ });
323
+ if (!editorUndo.ok) {
324
+ // Drift bail: the editor-undo could not reach the pre-write state. Do NOT
325
+ // re-write — that would only make the document worse. Return not-ok so the
326
+ // run stays pending and the user can retry, same as Undo.
327
+ return {
328
+ ok: false,
329
+ applied: [],
330
+ skipped: [{
331
+ trackedChange: null,
332
+ result: editorUndo.attempted || editorUndo.code
333
+ ? editorUndo
334
+ : {
335
+ ok: false,
336
+ code: 'accept_editor_undo_unavailable',
337
+ reason: '没有可执行的 Overleaf 原生撤销来清掉本轮留痕;Codex 没有接受这轮改动。'
338
+ }
339
+ }],
340
+ diagnostics
341
+ };
342
+ }
343
+
344
+ // Step 2: switch to Editing mode (Track Changes OFF) so the replay lands as
345
+ // plain, untracked text.
346
+ //
347
+ // ensureEditing() short-circuits on isEditingConfirmedForNoTraceUndo, whose
348
+ // "already Editing" detection is negation-based and false-positives while
349
+ // Track Changes is actually ON (e.g. the Reviewing mode shows only as a
350
+ // dropdown-trigger label with no active aria attribute, so reviewing is not
351
+ // positively confirmed and a stray "Editing" menu option is read as the
352
+ // current mode). Trusting that short-circuit replays the run while tracked.
353
+ //
354
+ // So do not trust the short-circuit here: read the reviewing state
355
+ // explicitly, and if Reviewing/Track Changes is on — or Editing is not
356
+ // positively confirmed OFF — force the toggle via setReviewingEnabled(false)
357
+ // rather than ensureEditing's lenient path.
358
+ const modeBefore = getReviewingState({});
359
+ pushDiagnostic('modeBefore', summarizeReviewingStateForDiagnostics(modeBefore));
360
+ const editingSwitch = await forceEditingForAcceptReplay();
361
+ pushDiagnostic('forceEditing', {
362
+ ok: editingSwitch.ok === true,
363
+ activated: editingSwitch.activated === true,
364
+ code: editingSwitch.code || '',
365
+ reason: editingSwitch.reason || ''
366
+ });
367
+ if (!editingSwitch.ok) {
368
+ // The undo already reverted the writeback; without a *confirmed* Editing
369
+ // mode the replay would itself be tracked. Bail rather than re-introduce
370
+ // tracked changes. The structured failure from forceEditingForAcceptReplay
371
+ // (editing_not_confirmed, changedDocument:true) is preserved verbatim.
372
+ return {
373
+ ok: false,
374
+ applied: [],
375
+ skipped: [{
376
+ trackedChange: null,
377
+ result: {
378
+ ok: false,
379
+ code: editingSwitch.code || 'accept_editing_not_confirmed',
380
+ reason: editingSwitch.reason || '无法确认 Overleaf 已切换到 Editing 模式;Codex 没有把本轮改动重写为永久文本。',
381
+ failure: editingSwitch.failure || buildPageFailure('editing_not_confirmed', {
382
+ changedDocument: true,
383
+ userMessage: 'Codex could not confirm Editing mode for the untracked Accept replay, so the run was not finalized.',
384
+ evidence: {
385
+ originalCode: editingSwitch.code || 'accept_editing_not_confirmed',
386
+ writeStarted: false
387
+ }
388
+ })
389
+ }
390
+ }],
391
+ diagnostics
392
+ };
393
+ }
394
+ // Tracks whether THIS flow toggled Reviewing off (either initially via
395
+ // forceEditingForAcceptReplay, or later inside the per-op re-confirm loop).
396
+ // The final restore only fires if this flow owned the toggle.
397
+ let weToggledOff = editingSwitch.activated === true;
398
+ const stableAfterSwitch = await waitForStableEditingForAcceptReplay({
399
+ waitMs: 2400,
400
+ intervalMs: 160
401
+ });
402
+ if (!stableAfterSwitch.ok) {
403
+ return {
404
+ ok: false,
405
+ applied: [],
406
+ skipped: [{
407
+ trackedChange: null,
408
+ result: stableAfterSwitch
409
+ }],
410
+ diagnostics
411
+ };
412
+ }
413
+
414
+ // Step 3: re-apply each file's changed fragments as a plain edit. With
415
+ // tracking off these land as permanent, untracked text. The replay must
416
+ // write only the minimal changed fragments via the `patches` path of
417
+ // applyOperationsCore — never a whole-file replaceAll, which would clobber
418
+ // any unrelated content and produce one giant tracked change if anything
419
+ // about the mode switch were imperfect.
420
+ const operationsResult = buildAcceptReplayOperations(paths, expectedByPath, postByPath, params.appliedOperations);
421
+ if (!operationsResult.ok) {
422
+ if (weToggledOff) {
423
+ await setReviewingEnabled(true, { waitMs: 1800 });
424
+ }
425
+ return {
426
+ ok: false,
427
+ applied: [],
428
+ skipped: [{
429
+ trackedChange: null,
430
+ result: operationsResult
431
+ }],
432
+ diagnostics
433
+ };
434
+ }
435
+ const operations = operationsResult.operations;
436
+ // Per-op replay loop with sticky-Editing re-confirm and a short stable
437
+ // window before every actual CodeMirror write.
438
+ //
439
+ // Bug C: even after forceEditingForAcceptReplay confirmed Editing once,
440
+ // Overleaf has been observed flipping back to Reviewing between the
441
+ // confirm and the next CodeMirror write (Overleaf-side override, per-user
442
+ // "Track Changes for me" setting, or a positive-confirm false-positive
443
+ // window). The fix: re-verify Editing immediately BEFORE every single
444
+ // operation, and if Reviewing has slipped back on, force the toggle off
445
+ // again and positively re-confirm before writing. If Editing still cannot
446
+ // be positively confirmed after the re-toggle, bail the rest of the loop
447
+ // so we never land a tracked write.
448
+ let bailedReason = null;
449
+ for (const operation of operations) {
450
+ const opPath = operation?.path || '';
451
+ const preState = getReviewingState({});
452
+ const reviewingOnBefore = isReviewingConfirmedForWrite(preState);
453
+ const editingConfirmedBefore = isEditingPositivelyConfirmed(preState);
454
+ let reToggled = false;
455
+ let reToggleResult = null;
456
+
457
+ if (reviewingOnBefore || !editingConfirmedBefore) {
458
+ reToggled = true;
459
+ reToggleResult = await setReviewingEnabled(false, { waitMs: 1800 });
460
+ if (reToggleResult.ok) {
461
+ weToggledOff = true;
462
+ }
463
+ }
464
+
465
+ const postToggleState = reToggled ? getReviewingState({}) : preState;
466
+ const editingConfirmedAfter = reToggled
467
+ ? isEditingPositivelyConfirmed(postToggleState)
468
+ : editingConfirmedBefore;
469
+
470
+ pushDiagnostic('replayStart', {
471
+ path: opPath,
472
+ isReviewingPositivelyOn: reviewingOnBefore,
473
+ isEditingPositivelyConfirmed: editingConfirmedBefore,
474
+ reToggled,
475
+ reToggleOk: reToggleResult ? reToggleResult.ok === true : null,
476
+ reToggleCode: reToggleResult?.code || '',
477
+ editingConfirmedAfterReToggle: editingConfirmedAfter
478
+ });
479
+
480
+ if (!editingConfirmedAfter) {
481
+ // Bail the rest of the loop with the existing bail semantics — never
482
+ // land a tracked write because Editing slipped.
483
+ const bailResult = {
484
+ ok: false,
485
+ code: reToggleResult?.code || 'accept_editing_not_confirmed',
486
+ reason: reToggleResult?.reason
487
+ || '本次操作前未能确认 Overleaf 处于 Editing 模式;为避免重写又留下留痕,Codex 没有继续重放本轮剩余改动。',
488
+ failure: buildPageFailure('editing_not_confirmed', {
489
+ file: opPath || '',
490
+ operationType: 'accept-replay',
491
+ changedDocument: true,
492
+ userMessage: `Codex could not re-confirm Editing mode before replaying ${opPath || 'the next file'}; Codex stopped to avoid landing a tracked write.`,
493
+ evidence: {
494
+ originalCode: reToggleResult?.code || 'accept_editing_not_confirmed',
495
+ reToggled,
496
+ writeStarted: false
497
+ }
498
+ })
499
+ };
500
+ skipped.push({
501
+ trackedChange: {
502
+ key: `accept-replay:${opPath}`,
503
+ id: '',
504
+ path: opPath,
505
+ label: 'Accept All (untracked replay)'
506
+ },
507
+ result: bailResult
508
+ });
509
+ pushDiagnostic('replayDone', {
510
+ path: opPath,
511
+ ok: false,
512
+ bailed: true,
513
+ code: bailResult.code,
514
+ reason: bailResult.reason
515
+ });
516
+ bailedReason = bailResult;
517
+ break;
518
+ }
519
+
520
+ const stableBeforeReplay = await waitForStableEditingForAcceptReplay({
521
+ waitMs: reToggled ? 2400 : 1400,
522
+ intervalMs: 160
523
+ });
524
+ if (!stableBeforeReplay.ok) {
525
+ skipped.push({
526
+ trackedChange: {
527
+ key: `accept-replay:${opPath}`,
528
+ id: '',
529
+ path: opPath,
530
+ label: 'Accept All (untracked replay)'
531
+ },
532
+ result: stableBeforeReplay
533
+ });
534
+ pushDiagnostic('replayDone', {
535
+ path: opPath,
536
+ ok: false,
537
+ bailed: true,
538
+ code: stableBeforeReplay.code,
539
+ reason: stableBeforeReplay.reason
540
+ });
541
+ bailedReason = stableBeforeReplay;
542
+ break;
543
+ }
544
+
545
+ const opReplay = await applyOperationsCore([operation], {
546
+ baseFiles: [{
547
+ path: opPath,
548
+ content: expectedByPath.get(normalizeSafeProjectPath(opPath))
549
+ }],
550
+ forbidTrackedChanges: true
551
+ });
552
+ for (const item of opReplay.applied || []) {
553
+ applied.push({
554
+ trackedChange: {
555
+ key: `accept-replay:${item.operation?.path || ''}`,
556
+ id: '',
557
+ path: item.operation?.path || '',
558
+ label: 'Accept All (untracked replay)'
559
+ },
560
+ result: {
561
+ ...item.result,
562
+ method: 'overleaf-accept-untracked-replay'
563
+ }
564
+ });
565
+ }
566
+ for (const item of opReplay.skipped || []) {
567
+ skipped.push({
568
+ trackedChange: {
569
+ key: `accept-replay:${item.operation?.path || ''}`,
570
+ id: '',
571
+ path: item.operation?.path || '',
572
+ label: 'Accept All (untracked replay)'
573
+ },
574
+ result: item.result
575
+ });
576
+ }
577
+ const opAppliedEntry = (opReplay.applied || [])[0];
578
+ const opSkippedEntry = (opReplay.skipped || [])[0];
579
+ const opResult = opAppliedEntry?.result || opSkippedEntry?.result || {};
580
+ if (opResult.code === 'accept_replay_created_tracked_changes') {
581
+ opResult.rollback = await rollbackAcceptReplayTrackedWrite(opPath, expectedByPath, postByPath);
582
+ }
583
+ pushDiagnostic('replayDone', {
584
+ path: opPath,
585
+ ok: opAppliedEntry ? true : false,
586
+ verified: opResult.verified === true,
587
+ verifiedContentLength: typeof opResult.verifiedContent === 'string'
588
+ ? opResult.verifiedContent.length
589
+ : null,
590
+ trackedChangesDetected: Array.isArray(opResult.trackedChanges) ? opResult.trackedChanges.length : 0,
591
+ rollbackOk: opResult.rollback ? opResult.rollback.ok === true : null,
592
+ code: opResult.code || '',
593
+ reason: opResult.reason || ''
594
+ });
595
+ if (!opReplay.ok) {
596
+ bailedReason = opResult;
597
+ break;
598
+ }
599
+ }
600
+
601
+ // Step 4 intentionally does NOT restore Reviewing. Accept replay is a
602
+ // no-trace write transaction: restoring Track Changes immediately after the
603
+ // CodeMirror dispatch can race with Overleaf's collaboration/reviewing
604
+ // settlement and cause the replay itself to land as fresh tracked changes.
605
+ // Leave the editor in Editing after a successful replay; the user can turn
606
+ // Reviewing back on manually after Overleaf has saved the accepted text.
607
+ if (weToggledOff) {
608
+ pushDiagnostic('restoreReviewing', {
609
+ ok: true,
610
+ skipped: true,
611
+ enabled: false,
612
+ reason: 'Accept All left Overleaf in Editing mode to avoid re-tracking the accepted replay.'
613
+ });
614
+ }
615
+
616
+ if (applied.length > 0) {
617
+ compileBridge.markSourceEdited();
618
+ }
619
+
620
+ return {
621
+ ok: skipped.length === 0 && !bailedReason,
622
+ applied,
623
+ skipped,
624
+ diagnostics
625
+ };
626
+ }
627
+
628
+ // Summarizes a reviewing state for the per-step diagnostics. Surfaces
629
+ // exactly the bits the user needs to diagnose a sticky-Editing slip: the
630
+ // reviewing detector's verdict (ok/status/source), a compact controls
631
+ // summary, and the two strict gates (isReviewingPositivelyOn /
632
+ // isEditingPositivelyConfirmed). Self-contained — the run card can render
633
+ // this verbatim without re-reading any page state.
634
+ function summarizeReviewingStateForDiagnostics(state = {}) {
635
+ const reviewing = state.reviewing || {};
636
+ const controls = (state.signals?.controls || []).slice(0, 6).map(control => {
637
+ const text = [control?.text, control?.innerText, control?.ariaLabel, control?.title]
638
+ .map(value => String(value || '').replace(/\s+/g, ' ').trim())
639
+ .filter(Boolean)
640
+ .join(' | ');
641
+ return {
642
+ text: text.length > 80 ? text.slice(0, 77) + '...' : text,
643
+ ariaPressed: control?.ariaPressed || '',
644
+ ariaSelected: control?.ariaSelected || '',
645
+ ariaCurrent: control?.ariaCurrent || ''
646
+ };
647
+ });
648
+ return {
649
+ reviewingOk: reviewing.ok === true,
650
+ reviewingStatus: reviewing.status || '',
651
+ reviewingSource: reviewing.source || '',
652
+ controlsCount: (state.signals?.controls || []).length,
653
+ controls,
654
+ isReviewingPositivelyOn: isReviewingConfirmedForWrite(state),
655
+ isEditingPositivelyConfirmed: isEditingPositivelyConfirmed(state)
656
+ };
657
+ }
658
+
659
+ // Track Changes is positively confirmed OFF only when Editing is positively
660
+ // detected AND Reviewing is NOT positively confirmed on. This is the strict
661
+ // gate the Accept replay needs: it must never replay while tracked, so
662
+ // anything short of a positive "Editing on / Reviewing off" is rejected.
663
+ function isEditingPositivelyConfirmed(state = {}) {
664
+ return isEditingConfirmedForNoTraceUndo(state) && !isReviewingConfirmedForWrite(state);
665
+ }
666
+
667
+ async function waitForStableEditingForAcceptReplay(options = {}) {
668
+ const waitMs = Number.isFinite(Number(options.waitMs)) ? Number(options.waitMs) : 2000;
669
+ const intervalMs = Number.isFinite(Number(options.intervalMs)) ? Number(options.intervalMs) : 160;
670
+ const deadline = Date.now() + Math.max(0, waitMs);
671
+ let samples = 0;
672
+ let lastState = getReviewingState({});
673
+ while (Date.now() <= deadline) {
674
+ lastState = getReviewingState({});
675
+ samples += 1;
676
+ if (!isEditingPositivelyConfirmed(lastState)) {
677
+ return {
678
+ ok: false,
679
+ code: 'accept_editing_not_stable',
680
+ reason: 'Overleaf Editing mode did not remain stable long enough for an untracked Accept All replay; Codex did not replay this write.',
681
+ samples,
682
+ waitMs,
683
+ reviewing: summarizeReviewingStateForDiagnostics(lastState)
684
+ };
685
+ }
686
+ await delay(intervalMs);
687
+ }
688
+ return {
689
+ ok: true,
690
+ samples,
691
+ waitMs
692
+ };
693
+ }
694
+
695
+ async function rollbackAcceptReplayTrackedWrite(path, expectedByPath, postByPath) {
696
+ const normalizedPath = normalizeSafeProjectPath(path);
697
+ const expectedContent = expectedByPath.get(normalizedPath);
698
+ const postContent = postByPath.get(normalizedPath);
699
+ if (!normalizedPath || typeof expectedContent !== 'string' || typeof postContent !== 'string') {
700
+ return {
701
+ ok: false,
702
+ code: 'accept_replay_rollback_missing_content',
703
+ reason: 'Accept All detected fresh tracked changes, but could not roll back because the pre/post content was unavailable.'
704
+ };
705
+ }
706
+ const rollbackApplied = [];
707
+ const rollback = await rejectTrackedChangesViaEditorUndo(
708
+ [{ path: normalizedPath, content: expectedContent }],
709
+ [{ path: normalizedPath, content: postContent }],
710
+ rollbackApplied
711
+ );
712
+ return {
713
+ ok: rollback.ok === true,
714
+ attempted: rollback.attempted === true,
715
+ code: rollback.code || '',
716
+ reason: rollback.reason || '',
717
+ applied: rollbackApplied.length
718
+ };
719
+ }
720
+
721
+ // Robustly switch Overleaf to Editing (Track Changes OFF) for the Accept
722
+ // replay.
723
+ //
724
+ // Bug B root cause: the accept flow used ensureEditing, which short-circuits
725
+ // on isEditingConfirmedForNoTraceUndo. That detector is negation-based — it
726
+ // returns true whenever Reviewing is not *positively* confirmed-for-write
727
+ // (which requires an active aria attribute the real Overleaf reviewing
728
+ // control does not always carry) AND a control loosely matches an Editing
729
+ // pattern. So while Track Changes was actually ON, it false-positived
730
+ // "already Editing", ensureEditing returned activated:false without toggling,
731
+ // and the replay landed as tracked changes.
732
+ //
733
+ // The fix here does NOT trust that lenient short-circuit: it reads the
734
+ // reviewing state explicitly and treats Editing as confirmed only when it is
735
+ // *positively* confirmed (Editing detected AND Reviewing not confirmed on).
736
+ // Whenever that strict gate is not met it forces setReviewingEnabled(false),
737
+ // then positively re-confirms Editing before returning ok; if it still cannot
738
+ // confirm Editing it returns not-ok so the caller bails instead of replaying
739
+ // while tracked.
740
+ async function forceEditingForAcceptReplay() {
741
+ const initial = getReviewingState({});
742
+ if (isEditingPositivelyConfirmed(initial)) {
743
+ // Positively confirmed already in Editing — no toggle needed.
744
+ return { ok: true, activated: false };
745
+ }
746
+
747
+ // Reviewing is on, or Editing is not positively confirmed off. Force the
748
+ // toggle rather than trusting ensureEditing's lenient detection.
749
+ const switched = await setReviewingEnabled(false, { waitMs: 1800 });
750
+ if (!switched.ok) {
751
+ // §9.4 editing_not_confirmed: the document is at pre-write state after
752
+ // the editor-undo succeeded; the mode toggle failed. The Accept replay
753
+ // was rolled back so the user-visible doc state has moved from the
754
+ // post-write tracked state back to pre-write — set changedDocument:true
755
+ // per the design spec's "is there text the user might want to inspect"
756
+ // contract for accept-side failures after a rollback.
757
+ return {
758
+ ok: false,
759
+ code: switched.code || 'accept_editing_not_confirmed',
760
+ reason: switched.reason || '无法确认 Overleaf 已切换到 Editing 模式;Codex 没有把本轮改动重写为永久文本。',
761
+ failure: buildPageFailure('editing_not_confirmed', {
762
+ changedDocument: true,
763
+ userMessage: 'Codex could not switch Overleaf to Editing mode for the untracked Accept replay, so the run was not finalized.',
764
+ technicalMessage: switched.reason || '',
765
+ evidence: {
766
+ originalCode: switched.code || 'accept_editing_not_confirmed',
767
+ toggleAttempted: true,
768
+ writeStarted: false
769
+ }
770
+ })
771
+ };
772
+ }
773
+
774
+ // Positively confirm Track Changes is now off before replaying. If the
775
+ // post-toggle state does not positively confirm Editing, bail — do not
776
+ // replay while potentially still tracked.
777
+ const after = getReviewingState({});
778
+ if (!isEditingPositivelyConfirmed(after)) {
779
+ return {
780
+ ok: false,
781
+ code: 'accept_editing_not_confirmed',
782
+ reason: '切换后仍未能确认 Overleaf 处于 Editing 模式;为避免重写又留下留痕,Codex 没有重放本轮改动。',
783
+ failure: buildPageFailure('editing_not_confirmed', {
784
+ changedDocument: true,
785
+ userMessage: 'Codex toggled Overleaf to Editing, but the post-toggle state did not positively confirm Editing — Codex did not replay the run.',
786
+ evidence: {
787
+ originalCode: 'accept_editing_not_confirmed',
788
+ toggleAttempted: true,
789
+ writeStarted: false
790
+ }
791
+ })
792
+ };
793
+ }
794
+
795
+ return { ok: true, activated: true };
796
+ }
797
+
798
+ // Builds the minimal replay operations for Accept All. The replay must write
799
+ // only the changed fragments, never a whole-file replaceAll.
800
+ //
801
+ // Preferred source: the run's own original forward writeback operations
802
+ // (carrying their `patches`). After the editor-undo the document is back at
803
+ // the pre-write content, so those patches' `expected` slices match and
804
+ // re-apply cleanly.
805
+ //
806
+ // Fallback: when a path has no usable original patch operation, compute a
807
+ // minimal pre->post diff (trim the common prefix/suffix to a single targeted
808
+ // {from,to,insert} patch).
809
+ function buildAcceptReplayOperations(paths, expectedByPath, postByPath, appliedOperations) {
810
+ const patchesByPath = collectAppliedEditPatchesByPath(appliedOperations);
811
+ const operations = [];
812
+ for (const path of paths) {
813
+ const preContent = expectedByPath.get(path);
814
+ const postContent = postByPath.get(path);
815
+ if (typeof preContent !== 'string' || typeof postContent !== 'string') {
816
+ return {
817
+ ok: false,
818
+ code: 'accept_missing_run_content',
819
+ reason: `${path} 缺少写入前/写入后内容;Codex 没有重放本轮改动。`
820
+ };
821
+ }
822
+
823
+ // Preferred: re-apply the run's original forward patches verbatim.
824
+ const originalPatches = patchesByPath.get(path);
825
+ if (originalPatches && originalPatches.length && patchesApplyCleanly(preContent, originalPatches)) {
826
+ operations.push({
827
+ type: 'edit',
828
+ path,
829
+ patches: originalPatches,
830
+ reason: 'Accept tracked edit (replay untracked)'
831
+ });
832
+ continue;
833
+ }
834
+
835
+ // Fallback: minimal pre->post diff trimmed to a single targeted patch.
836
+ const diffPatch = buildMinimalDiffPatch(preContent, postContent);
837
+ if (!diffPatch) {
838
+ // pre === post: nothing to replay for this file.
839
+ continue;
840
+ }
841
+ operations.push({
842
+ type: 'edit',
843
+ path,
844
+ patches: [diffPatch],
845
+ reason: 'Accept tracked edit (replay untracked)'
846
+ });
847
+ }
848
+ return { ok: true, operations };
849
+ }
850
+
851
+ // Collects the per-path `patches` arrays from the run's applied edit
852
+ // operations. Only edit operations that actually carried `patches` are
853
+ // usable; whole-file replaceAll / verifiedContent operations are skipped so
854
+ // the replay never falls back to a whole-file write.
855
+ function collectAppliedEditPatchesByPath(appliedOperations) {
856
+ const patchesByPath = new Map();
857
+ for (const rawOperation of Array.isArray(appliedOperations) ? appliedOperations : []) {
858
+ if (!rawOperation || rawOperation.type !== 'edit') {
859
+ continue;
860
+ }
861
+ const path = typeof rawOperation.path === 'string'
862
+ ? normalizeSafeProjectPath(rawOperation.path)
863
+ : '';
864
+ if (!path || !Array.isArray(rawOperation.patches) || !rawOperation.patches.length) {
865
+ continue;
866
+ }
867
+ const normalized = rawOperation.patches.map(patch => ({
868
+ from: Number(patch?.from),
869
+ to: Number(patch?.to),
870
+ expected: String(patch?.expected ?? ''),
871
+ insert: String(patch?.insert ?? '')
872
+ }));
873
+ const existing = patchesByPath.get(path) || [];
874
+ patchesByPath.set(path, existing.concat(normalized));
875
+ }
876
+ return patchesByPath;
877
+ }
878
+
879
+ // Confirms a patch set re-applies cleanly against the given (pre-write) text:
880
+ // every patch range is valid and its `expected` slice matches.
881
+ function patchesApplyCleanly(text, patches) {
882
+ const applied = applyTextPatches(text, patches);
883
+ return applied.ok === true;
884
+ }
885
+
886
+ // Computes a single targeted {from,to,insert} patch describing the pre->post
887
+ // change by trimming the common prefix and suffix. Returns null when the two
888
+ // strings are identical.
889
+ function buildMinimalDiffPatch(preContent, postContent) {
890
+ if (preContent === postContent) {
891
+ return null;
892
+ }
893
+ const preLen = preContent.length;
894
+ const postLen = postContent.length;
895
+ let prefix = 0;
896
+ const maxPrefix = Math.min(preLen, postLen);
897
+ while (prefix < maxPrefix && preContent[prefix] === postContent[prefix]) {
898
+ prefix += 1;
899
+ }
900
+ let suffix = 0;
901
+ const maxSuffix = Math.min(preLen, postLen) - prefix;
902
+ while (
903
+ suffix < maxSuffix
904
+ && preContent[preLen - 1 - suffix] === postContent[postLen - 1 - suffix]
905
+ ) {
906
+ suffix += 1;
907
+ }
908
+ const from = prefix;
909
+ const to = preLen - suffix;
910
+ return {
911
+ from,
912
+ to,
913
+ expected: preContent.slice(from, to),
914
+ insert: postContent.slice(from, postLen - suffix)
915
+ };
916
+ }
917
+
918
+ async function rejectTrackedChangesViaEditorUndo(expectedFiles, postFiles, applied) {
919
+ const expectedByPath = new Map((expectedFiles || [])
920
+ .filter(file => file?.path && typeof file.content === 'string')
921
+ .map(file => [normalizeSafeProjectPath(file.path), file.content])
922
+ .filter(([path]) => path));
923
+ const postByPath = new Map((postFiles || [])
924
+ .filter(file => file?.path && typeof file.content === 'string')
925
+ .map(file => [normalizeSafeProjectPath(file.path), file.content])
926
+ .filter(([path]) => path));
927
+ const paths = Array.from(expectedByPath.keys()).filter(path => postByPath.has(path));
928
+ if (!paths.length) {
929
+ return { ok: false, attempted: false };
930
+ }
931
+
932
+ for (const path of paths) {
933
+ if (path && getActiveFilePath() !== path) {
934
+ const opened = await openFileByPath(path);
935
+ if (!opened.ok) {
936
+ return {
937
+ ok: false,
938
+ attempted: false,
939
+ code: 'tracked_change_editor_undo_open_failed',
940
+ reason: `无法打开 ${path} 来执行 Overleaf 原生撤销。`
941
+ };
942
+ }
943
+ }
944
+
945
+ const postContent = postByPath.get(path);
946
+ const postReady = await waitForActiveEditorExpectedText(path, postContent, 2500);
947
+ if (!postReady.ok) {
948
+ return {
949
+ ok: false,
950
+ attempted: false,
951
+ code: 'tracked_change_editor_undo_current_mismatch',
952
+ reason: `${path} 当前内容已经不是本轮写入后的内容;为避免撤掉你的后续修改,Codex 不使用 Overleaf 原生撤销。`
953
+ };
954
+ }
955
+
956
+ const result = await undoEditorHistoryUntilContent(expectedByPath.get(path), path);
957
+ if (!result.ok) {
958
+ return result;
959
+ }
960
+ applied.push({
961
+ trackedChange: {
962
+ path,
963
+ key: `editor-undo:${path}`,
964
+ id: '',
965
+ label: `Overleaf editor undo (${result.clicks} step${result.clicks === 1 ? '' : 's'})`
966
+ },
967
+ result: {
968
+ ok: true,
969
+ method: 'overleaf-editor-undo',
970
+ undoClicks: result.clicks
971
+ }
972
+ });
973
+ }
974
+
975
+ return { ok: true, attempted: true };
976
+ }
977
+
978
+ async function restoreExpectedFilesWithNoTraceUndo(expectedFiles, postFiles) {
979
+ const expectedByPath = new Map((expectedFiles || [])
980
+ .filter(file => file?.path && typeof file.content === 'string')
981
+ .map(file => [normalizeSafeProjectPath(file.path), file.content])
982
+ .filter(([path]) => path));
983
+ const postByPath = new Map((postFiles || [])
984
+ .filter(file => file?.path && typeof file.content === 'string')
985
+ .map(file => [normalizeSafeProjectPath(file.path), file.content])
986
+ .filter(([path]) => path));
987
+ const paths = Array.from(expectedByPath.keys()).filter(path => postByPath.has(path));
988
+ if (!paths.length) {
989
+ return {
990
+ ok: false,
991
+ attempted: false,
992
+ applied: [],
993
+ skipped: []
994
+ };
995
+ }
996
+
997
+ for (const path of paths) {
998
+ if (path && getActiveFilePath() !== path) {
999
+ const opened = await openFileByPath(path);
1000
+ if (!opened.ok) {
1001
+ return {
1002
+ ok: false,
1003
+ attempted: false,
1004
+ applied: [],
1005
+ skipped: [],
1006
+ code: 'snapshot_undo_open_failed',
1007
+ reason: `无法打开 ${path} 来执行快照撤销。`
1008
+ };
1009
+ }
1010
+ }
1011
+ const ready = await waitForActiveEditorExpectedText(path, postByPath.get(path), 1500);
1012
+ if (!ready.ok) {
1013
+ return {
1014
+ ok: false,
1015
+ attempted: false,
1016
+ applied: [],
1017
+ skipped: [],
1018
+ code: 'snapshot_undo_current_mismatch',
1019
+ reason: `${path} 当前内容已经不是本轮写入后的内容;为避免覆盖你的后续修改,Codex 不执行快照撤销。`
1020
+ };
1021
+ }
1022
+ }
1023
+
1024
+ const operations = paths.map(path => ({
1025
+ type: 'edit',
1026
+ path,
1027
+ replaceAll: expectedByPath.get(path),
1028
+ reason: 'Undo tracked edit'
1029
+ }));
1030
+ const result = await applyOperationsWithNoTraceUndo(operations, {
1031
+ baseFiles: paths.map(path => ({
1032
+ path,
1033
+ content: postByPath.get(path)
1034
+ }))
1035
+ });
1036
+ const toTrackedResult = item => ({
1037
+ trackedChange: {
1038
+ key: `snapshot-undo:${item.operation?.path || ''}`,
1039
+ id: '',
1040
+ path: item.operation?.path || '',
1041
+ label: 'No-trace snapshot undo'
1042
+ },
1043
+ result: item.result
1044
+ });
1045
+ return {
1046
+ ok: !(result.skipped || []).length,
1047
+ attempted: true,
1048
+ applied: (result.applied || []).map(toTrackedResult),
1049
+ skipped: (result.skipped || []).map(toTrackedResult)
1050
+ };
1051
+ }
1052
+
1053
+ async function waitForActiveEditorExpectedText(filePath, expectedContent, timeoutMs) {
1054
+ const deadline = Date.now() + Math.max(0, Number(timeoutMs) || 0);
1055
+ let actual = readActiveEditorText();
1056
+ while (Date.now() < deadline) {
1057
+ const activeFileMatches = !filePath || getActiveFilePath() === filePath;
1058
+ actual = readActiveEditorText();
1059
+ if (activeFileMatches && actual === expectedContent) {
1060
+ return {
1061
+ ok: true,
1062
+ text: actual
1063
+ };
1064
+ }
1065
+ await delay(60);
1066
+ }
1067
+ actual = readActiveEditorText();
1068
+ return {
1069
+ ok: false,
1070
+ text: actual,
1071
+ activePath: getActiveFilePath()
1072
+ };
1073
+ }
1074
+
1075
+ async function undoEditorHistoryUntilContent(expectedContent, path) {
1076
+ const maxClicks = 200;
1077
+ for (let clicks = 0; clicks <= maxClicks; clicks += 1) {
1078
+ if (readActiveEditorText() === expectedContent) {
1079
+ return {
1080
+ ok: true,
1081
+ attempted: clicks > 0,
1082
+ clicks
1083
+ };
1084
+ }
1085
+ if (clicks === maxClicks) {
1086
+ break;
1087
+ }
1088
+
1089
+ const undoControl = findEditorUndoControl();
1090
+ if (!undoControl) {
1091
+ return {
1092
+ ok: false,
1093
+ attempted: clicks > 0,
1094
+ code: 'editor_undo_control_not_found',
1095
+ reason: `没有找到 Overleaf 编辑器自己的 Undo/撤销按钮,无法一次性撤销 ${path} 的本轮留痕。`
1096
+ };
1097
+ }
1098
+
1099
+ const beforeText = readActiveEditorText();
1100
+ clickNode(undoControl);
1101
+ await waitForEditorTextProgress(beforeText, expectedContent, 1000);
1102
+ if (readActiveEditorText() === beforeText) {
1103
+ return {
1104
+ ok: false,
1105
+ attempted: clicks > 0,
1106
+ code: 'editor_undo_no_progress',
1107
+ reason: `Codex 点击了 Overleaf Undo/撤销,但 ${path} 内容没有变化。`
1108
+ };
1109
+ }
1110
+ }
1111
+
1112
+ return {
1113
+ ok: false,
1114
+ attempted: true,
1115
+ code: 'editor_undo_max_iterations',
1116
+ reason: `${path} 经过多次 Overleaf 原生撤销后仍未回到本轮写入前内容,已停止以避免撤销其它修改。`
1117
+ };
1118
+ }
1119
+
1120
+ async function waitForEditorTextProgress(beforeText, expectedContent, timeoutMs) {
1121
+ const startedAt = Date.now();
1122
+ while (Date.now() - startedAt < timeoutMs) {
1123
+ const current = readActiveEditorText();
1124
+ if (current !== beforeText || current === expectedContent) {
1125
+ return true;
1126
+ }
1127
+ await delay(60);
1128
+ }
1129
+ return false;
1130
+ }
1131
+
1132
+ async function rejectRemainingTrackedChangesForExpectedFiles(expectedFiles, applied) {
1133
+ for (const file of expectedFiles || []) {
1134
+ if (!file?.path || typeof file.content !== 'string') {
1135
+ continue;
1136
+ }
1137
+ const result = await rejectRemainingTrackedChangesForExpectedFile(file, applied);
1138
+ if (!result.ok) {
1139
+ return result;
1140
+ }
1141
+ }
1142
+ return { ok: true };
1143
+ }
1144
+
1145
+ async function rejectRemainingTrackedChangesForTrackedPaths(trackedChanges, applied) {
1146
+ const paths = Array.from(new Set((trackedChanges || [])
1147
+ .map(change => normalizeSafeProjectPath(change?.path || ''))
1148
+ .filter(Boolean)));
1149
+ if (!paths.length) {
1150
+ const activePath = normalizeSafeProjectPath(getActiveFilePath());
1151
+ if (!activePath || !applied.length) {
1152
+ return { ok: true };
1153
+ }
1154
+ paths.push(activePath);
1155
+ }
1156
+
1157
+ for (const path of paths) {
1158
+ const result = await rejectRemainingTrackedChangesForPath(path, applied);
1159
+ if (!result.ok) {
1160
+ return result;
1161
+ }
1162
+ }
1163
+ return { ok: true };
1164
+ }
1165
+
1166
+ async function rejectRemainingTrackedChangesForPath(path, applied) {
1167
+ if (path && getActiveFilePath() !== path) {
1168
+ const opened = await openFileByPath(path);
1169
+ if (!opened.ok) {
1170
+ return {
1171
+ ok: false,
1172
+ code: 'tracked_change_file_open_failed',
1173
+ reason: `无法打开 ${path} 来继续拒绝这轮留痕记录;请在 Overleaf 审阅面板手动处理。`
1174
+ };
1175
+ }
1176
+ }
1177
+
1178
+ const appliedCountBefore = applied.length;
1179
+ const maxAttempts = 200;
1180
+ for (let attempt = 0; attempt < maxAttempts; attempt += 1) {
1181
+ const node = findLastTrackedChangeNodeForPath(path);
1182
+ if (!node) {
1183
+ if (applied.length > appliedCountBefore) {
1184
+ return { ok: true };
1185
+ }
1186
+ return {
1187
+ ok: false,
1188
+ code: 'tracked_change_not_found',
1189
+ reason: '没有在 Overleaf 页面里找到这轮写入对应的留痕记录;Codex 没有用文本补丁伪撤销。'
1190
+ };
1191
+ }
1192
+
1193
+ const rejectControl = findRejectControlForTrackedChangeNode(node);
1194
+ if (!rejectControl) {
1195
+ return {
1196
+ ok: false,
1197
+ code: 'tracked_change_reject_control_not_found',
1198
+ reason: `还有 ${path || '当前文件'} 的留痕记录未处理,但没有找到对应的 Reject/拒绝按钮;请在 Overleaf 审阅面板手动拒绝。`
1199
+ };
1200
+ }
1201
+
1202
+ const trackedChange = trackedChangeRefFromNode(node, path);
1203
+ const beforeText = readActiveEditorText();
1204
+ clickNode(rejectControl);
1205
+ await waitForTrackedChangeRejectProgress(trackedChange, path, beforeText, 1200);
1206
+ applied.push({
1207
+ trackedChange,
1208
+ result: {
1209
+ ok: true,
1210
+ method: 'overleaf-review-reject-sweep'
1211
+ }
1212
+ });
1213
+ }
1214
+
1215
+ return {
1216
+ ok: false,
1217
+ code: 'tracked_change_undo_max_iterations',
1218
+ reason: `${path || '当前文件'} 仍有未完成的留痕记录;Codex 已停止以避免误拒绝其它改动。`,
1219
+ failure: buildPageFailure('tracked_changes_remain', {
1220
+ file: path || '',
1221
+ operationType: 'reject',
1222
+ changedDocument: true,
1223
+ terminalState: 'needs_review',
1224
+ userMessage: `${path || 'The target file'} still has tracked changes after the reject sweep, so Codex stopped to avoid mis-rejecting other edits.`,
1225
+ evidence: {
1226
+ originalCode: 'tracked_change_undo_max_iterations',
1227
+ maxAttemptsReached: true,
1228
+ writeStarted: true
1229
+ }
1230
+ })
1231
+ };
1232
+ }
1233
+
1234
+ async function rejectRemainingTrackedChangesForExpectedFile(file, applied) {
1235
+ const opened = await openFileByPath(file.path);
1236
+ if (!opened.ok) {
1237
+ return {
1238
+ ok: false,
1239
+ code: 'tracked_change_undo_verify_open_failed',
1240
+ reason: `撤销后无法打开 ${file.path} 验证内容;请刷新 Overleaf 后检查。`
1241
+ };
1242
+ }
1243
+
1244
+ const appliedCountBefore = applied.length;
1245
+ const maxAttempts = 200;
1246
+ for (let attempt = 0; attempt < maxAttempts; attempt += 1) {
1247
+ const node = findLastTrackedChangeNodeForPath(file.path);
1248
+ if (!node) {
1249
+ const verified = await verifyActiveEditorText(file.content, file.path, 800);
1250
+ if (verified.ok) {
1251
+ return { ok: true };
1252
+ }
1253
+ const rejectedAnyForFile = applied.length > appliedCountBefore;
1254
+ return {
1255
+ ...verified,
1256
+ code: rejectedAnyForFile ? 'tracked_change_undo_verify_failed' : 'tracked_change_not_found',
1257
+ reason: rejectedAnyForFile
1258
+ ? `${file.path} 拒绝留痕后内容没有回到写入前状态;请在 Overleaf 审阅面板检查这轮修改。`
1259
+ : '没有在 Overleaf 页面里找到这轮写入对应的留痕记录;Codex 没有用文本补丁伪撤销。'
1260
+ };
1261
+ }
1262
+
1263
+ const rejectControl = findRejectControlForTrackedChangeNode(node);
1264
+ if (!rejectControl) {
1265
+ return {
1266
+ ok: false,
1267
+ code: 'tracked_change_reject_control_not_found',
1268
+ reason: `还有 ${file.path} 的留痕记录未处理,但没有找到对应的 Reject/拒绝按钮;请在 Overleaf 审阅面板手动拒绝。`
1269
+ };
1270
+ }
1271
+
1272
+ const trackedChange = trackedChangeRefFromNode(node, file.path);
1273
+ const beforeText = readActiveEditorText();
1274
+ clickNode(rejectControl);
1275
+ await waitForTrackedChangeRejectProgress(trackedChange, file.path, beforeText, 1200);
1276
+ applied.push({
1277
+ trackedChange,
1278
+ result: {
1279
+ ok: true,
1280
+ method: 'overleaf-review-reject-sweep'
1281
+ }
1282
+ });
1283
+ }
1284
+
1285
+ return {
1286
+ ok: false,
1287
+ code: 'tracked_change_undo_max_iterations',
1288
+ reason: `${file.path} 仍有未完成的留痕记录;Codex 已停止以避免误拒绝其它改动。`,
1289
+ failure: buildPageFailure('tracked_changes_remain', {
1290
+ file: file.path || '',
1291
+ operationType: 'reject',
1292
+ changedDocument: true,
1293
+ terminalState: 'needs_review',
1294
+ userMessage: `${file.path || 'The target file'} still has tracked changes after the per-expected-file reject sweep, so Codex stopped to avoid mis-rejecting other edits.`,
1295
+ evidence: {
1296
+ originalCode: 'tracked_change_undo_max_iterations',
1297
+ maxAttemptsReached: true,
1298
+ writeStarted: true
1299
+ }
1300
+ })
1301
+ };
1302
+ }
1303
+
1304
+ async function waitForTrackedChangeRejectProgress(trackedChange, path, beforeText, timeoutMs) {
1305
+ const startedAt = Date.now();
1306
+ while (Date.now() - startedAt < timeoutMs) {
1307
+ const currentText = readActiveEditorText();
1308
+ if (currentText !== beforeText) {
1309
+ return true;
1310
+ }
1311
+ if (trackedChange?.key && !findTrackedChangeNode({ ...trackedChange, path })) {
1312
+ return true;
1313
+ }
1314
+ await delay(80);
1315
+ }
1316
+ return false;
1317
+ }
1318
+
1319
+
1320
+
1321
+
1322
+ async function waitForTrackedChangeDiff(trackedBefore, paths, options = {}) {
1323
+ const waitMs = Number.isFinite(Number(options.waitMs)) ? Number(options.waitMs) : 3000;
1324
+ const intervalMs = Number.isFinite(Number(options.intervalMs)) ? Number(options.intervalMs) : 180;
1325
+ const deadline = Date.now() + Math.max(0, waitMs);
1326
+ let latest = [];
1327
+ while (Date.now() <= deadline) {
1328
+ const trackedAfter = collectTrackedChangeRefsForPaths(paths);
1329
+ latest = diffTrackedChangeRefs(trackedBefore, trackedAfter);
1330
+ if (latest.length > 0) {
1331
+ return {
1332
+ ok: true,
1333
+ trackedChanges: latest,
1334
+ waitMs
1335
+ };
1336
+ }
1337
+ await delay(intervalMs);
1338
+ }
1339
+ return {
1340
+ ok: true,
1341
+ trackedChanges: latest,
1342
+ waitMs
1343
+ };
1344
+ }
1345
+
1346
+ function collectTrackedChangeRefsForPaths(paths = []) {
1347
+ const pathSet = new Set((paths || []).filter(Boolean));
1348
+ const activePath = getActiveFilePath();
1349
+ return collectTrackedChangeNodes()
1350
+ .map(node => trackedChangeRefFromNode(node, activePath))
1351
+ .filter(ref => ref.key)
1352
+ .filter(ref => !pathSet.size || !ref.path || pathSet.has(ref.path));
1353
+ }
1354
+
1355
+ function collectTrackedChangeNodes() {
1356
+ const selector = [
1357
+ '[data-change-id]',
1358
+ '[data-review-id]',
1359
+ '[data-track-change-id]',
1360
+ '[data-ol-change-id]',
1361
+ '[data-path][class*="change" i]',
1362
+ '[class*="track-change" i]',
1363
+ '[class*="review-change" i]',
1364
+ '[class*="suggest" i]',
1365
+ '[aria-label*="change" i]',
1366
+ '[title*="change" i]'
1367
+ ].join(',');
1368
+ return uniqueNodes([
1369
+ ...collectElements(selector, 1200),
1370
+ ...collectElements('*', 3500).filter(isTrackedChangeNode)
1371
+ ]).filter(isTrackedChangeNode);
1372
+ }
1373
+
1374
+ function isTrackedChangeNode(node) {
1375
+ if (!node || isInsideCodexPanel(node)) {
1376
+ return false;
1377
+ }
1378
+ const signal = normalizeReviewingSignalText(readNodeSignalText(node));
1379
+ const tag = (node.tagName || '').toLowerCase();
1380
+ if (tag === 'button' && /\b(?:accept|reject|decline|discard|批准|接受|拒绝|丢弃)\b/i.test(signal)) {
1381
+ return false;
1382
+ }
1383
+ if (readTrackedChangeId(node)) {
1384
+ return true;
1385
+ }
1386
+ return /\b(?:tracked change|track change|review change|suggestion|insert(?:ion)?|delet(?:e|ion)|change)\b/i.test(signal)
1387
+ || /留痕|建议|插入|删除|更改|修改/.test(signal);
1388
+ }
1389
+
1390
+ function trackedChangeRefFromNode(node, fallbackPath = '') {
1391
+ const id = readTrackedChangeId(node);
1392
+ const key = id ? `id:${id}` : `sig:${compact(readNodeSignalText(node), 180)}`;
1393
+ const path = normalizeSafeProjectPath(
1394
+ node.getAttribute?.('data-path')
1395
+ || node.getAttribute?.('data-file-path')
1396
+ || node.getAttribute?.('data-doc-path')
1397
+ || fallbackPath
1398
+ || ''
1399
+ );
1400
+ return {
1401
+ key,
1402
+ id,
1403
+ path,
1404
+ label: compact(readNodeSignalText(node), 180)
1405
+ };
1406
+ }
1407
+
1408
+ function readTrackedChangeId(node) {
1409
+ for (const attribute of [
1410
+ 'data-change-id',
1411
+ 'data-review-id',
1412
+ 'data-track-change-id',
1413
+ 'data-ol-change-id',
1414
+ 'data-id',
1415
+ 'id'
1416
+ ]) {
1417
+ const value = node.getAttribute?.(attribute) || '';
1418
+ if (value && /\b(?:change|review|track|suggest)|\d|[a-f0-9-]{8,}/i.test(value)) {
1419
+ return String(value);
1420
+ }
1421
+ }
1422
+ return '';
1423
+ }
1424
+
1425
+ function orderTrackedChangesForReviewAction(refs = []) {
1426
+ return (refs || []).slice().reverse();
1427
+ }
1428
+
1429
+ function findTrackedChangeNode(ref = {}) {
1430
+ const targetKey = ref.key || '';
1431
+ if (!targetKey || ref.invalidProjectPath) {
1432
+ return null;
1433
+ }
1434
+ return collectTrackedChangeNodes()
1435
+ .find(node => trackedChangeRefFromNode(node, ref.path || getActiveFilePath()).key === targetKey)
1436
+ || null;
1437
+ }
1438
+
1439
+ function findNextTrackedChangeNodeForPath(path) {
1440
+ const nodes = findTrackedChangeNodesForPath(path);
1441
+ return nodes[0] || null;
1442
+ }
1443
+
1444
+ function findLastTrackedChangeNodeForPath(path) {
1445
+ const nodes = findTrackedChangeNodesForPath(path);
1446
+ return nodes[nodes.length - 1] || null;
1447
+ }
1448
+
1449
+ function findTrackedChangeNodesForPath(path) {
1450
+ const targetPath = normalizeSafeProjectPath(path || getActiveFilePath());
1451
+ return collectTrackedChangeNodes()
1452
+ .filter(node => {
1453
+ const ref = trackedChangeRefFromNode(node, targetPath);
1454
+ return !targetPath || !ref.path || ref.path === targetPath;
1455
+ })
1456
+ }
1457
+
1458
+ function findRejectControlForTrackedChangeNode(node) {
1459
+ const scopes = [];
1460
+ let current = node;
1461
+ for (let index = 0; current && index < 6; index += 1) {
1462
+ scopes.push(current);
1463
+ current = current.parentElement;
1464
+ }
1465
+
1466
+ for (const scope of scopes) {
1467
+ const candidates = scope.querySelectorAll
1468
+ ? Array.from(scope.querySelectorAll('button,[role="button"],[aria-label],[title]'))
1469
+ : [];
1470
+ const reject = candidates.find(isRejectTrackedChangeControl);
1471
+ if (reject) {
1472
+ return reject;
1473
+ }
1474
+ }
1475
+ return isRejectTrackedChangeControl(node) ? node : null;
1476
+ }
1477
+
1478
+ function findEditorUndoControl() {
1479
+ return collectElements('button,[role="button"],[aria-label],[title]', 1200)
1480
+ .find(isEditorUndoControl)
1481
+ || null;
1482
+ }
1483
+
1484
+ function isRejectTrackedChangeControl(node) {
1485
+ if (!node || node.disabled) {
1486
+ return false;
1487
+ }
1488
+ if (/^(true|disabled)$/i.test(node.getAttribute?.('aria-disabled') || '')) {
1489
+ return false;
1490
+ }
1491
+ const signal = normalizeReviewingSignalText(readNodeSignalText(node));
1492
+ if (/\b(?:accept|approve|apply|resolve|接受|批准|应用)\b/i.test(signal)) {
1493
+ return false;
1494
+ }
1495
+ return /\b(?:reject|decline|discard|revert|拒绝|丢弃|还原)\b/i.test(signal);
1496
+ }
1497
+ return {
1498
+ acceptTrackedChanges,
1499
+ rejectTrackedChanges,
1500
+ collectTrackedChangeRefsForPaths,
1501
+ waitForTrackedChangeDiff
1502
+ };
1503
+ }
1504
+
1505
+ return { create };
1506
+ });