codex-overleaf-link 2.5.0 → 2.6.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 (189) hide show
  1. package/README.md +155 -392
  2. package/README.zh-CN.md +154 -393
  3. package/extension/bootstrap/background.js +52 -9
  4. package/extension/bootstrap/manifest.template.json +1 -1
  5. package/extension/bootstrap/popup.html +1 -0
  6. package/extension/bootstrap/popupFallback.js +55 -0
  7. package/extension/bootstrap/updateStatus.js +1 -0
  8. package/extension/src/background.js +283 -55
  9. package/extension/src/backgroundBootstrapMigration.js +181 -0
  10. package/extension/src/backgroundSharedSessions.js +129 -16
  11. package/extension/src/backgroundUpdateCoordinator.js +242 -29
  12. package/extension/src/backgroundUpdateSafety.js +173 -0
  13. package/extension/src/content/activeTurnControl.js +171 -8
  14. package/extension/src/content/applyResultFormatters.js +46 -73
  15. package/extension/src/content/assetTransferBroker.js +72 -14
  16. package/extension/src/content/composerAttachments.js +104 -47
  17. package/extension/src/content/composerPanel.js +110 -12
  18. package/extension/src/content/contentRuntime.js +1555 -926
  19. package/extension/src/content/contextTray.js +8 -2
  20. package/extension/src/content/contextUsageView.js +267 -0
  21. package/extension/src/content/diagnosticsController.js +122 -96
  22. package/extension/src/content/diagnosticsExport.js +19 -5
  23. package/extension/src/content/diagnosticsPanel.js +48 -0
  24. package/extension/src/content/directoryCleanupRecovery.js +54 -0
  25. package/extension/src/content/generated/content.bundle.js +11077 -3412
  26. package/extension/src/content/generated/content.bundle.meta.json +14 -3
  27. package/extension/src/content/globalPreferencesController.js +1 -1
  28. package/extension/src/content/legacyGlobalRegistry.js +10 -0
  29. package/extension/src/content/localSkillsPanel.js +104 -14
  30. package/extension/src/content/markdownDomRenderer.js +5 -3
  31. package/extension/src/content/markdownText.js +47 -11
  32. package/extension/src/content/modelPicker.js +8 -4
  33. package/extension/src/content/nativeCompatibilityController.js +74 -30
  34. package/extension/src/content/otWarmMirror.js +5 -6
  35. package/extension/src/content/otWarmMirrorController.js +17 -20
  36. package/extension/src/content/pageBridgeClient.js +17 -4
  37. package/extension/src/content/panelMaintenance.js +46 -14
  38. package/extension/src/content/panelRenderer.js +57 -27
  39. package/extension/src/content/pendingInputView.js +1 -1
  40. package/extension/src/content/projectProviderSelection.js +13 -4
  41. package/extension/src/content/projectSessionCleanup.js +109 -62
  42. package/extension/src/content/projectSettingsCoordinator.js +67 -6
  43. package/extension/src/content/providerSettingsCoordinator.js +46 -8
  44. package/extension/src/content/providerSettingsDialog.js +73 -14
  45. package/extension/src/content/recentProjects.js +15 -8
  46. package/extension/src/content/runActivitySummary.js +160 -18
  47. package/extension/src/content/runCompletionMeta.js +532 -0
  48. package/extension/src/content/runController.js +39 -15
  49. package/extension/src/content/runFailureNotice.js +29 -13
  50. package/extension/src/content/runGuidanceController.js +72 -27
  51. package/extension/src/content/runGuidanceView.js +59 -10
  52. package/extension/src/content/runPresence.js +18 -8
  53. package/extension/src/content/runQueueScheduler.js +157 -47
  54. package/extension/src/content/runRecoveryActions.js +147 -0
  55. package/extension/src/content/runResultActions.js +28 -143
  56. package/extension/src/content/runReviewController.js +633 -0
  57. package/extension/src/content/runScrollLayout.js +21 -2
  58. package/extension/src/content/runTimelineView.js +367 -175
  59. package/extension/src/content/scopedPersistencePanelState.js +134 -20
  60. package/extension/src/content/selectionContextView.js +1 -1
  61. package/extension/src/content/sensitivePreflight.js +153 -0
  62. package/extension/src/content/sessionForkController.js +80 -17
  63. package/extension/src/content/sessionManager.js +50 -4
  64. package/extension/src/content/sessionMenuView.js +54 -18
  65. package/extension/src/content/sessionPersistence.js +25 -5
  66. package/extension/src/content/settingsPanel.js +22 -5
  67. package/extension/src/content/settingsWorkbench.js +132 -89
  68. package/extension/src/content/sharedSessionViewSync.js +49 -11
  69. package/extension/src/content/subagentActivityView.js +23 -7
  70. package/extension/src/content/updateIdle.js +4 -1
  71. package/extension/src/content/updateNotice.js +118 -12
  72. package/extension/src/content/updateSafetyReceiver.js +58 -0
  73. package/extension/src/content/writebackController.js +53 -4
  74. package/extension/src/content/writebackOrchestrator.js +116 -94
  75. package/extension/src/content/writingStyleSettings.js +35 -11
  76. package/extension/src/page/binaryAssetUploader.js +136 -14
  77. package/extension/src/page/compileBridge.js +193 -180
  78. package/extension/src/page/fileMover.js +256 -0
  79. package/extension/src/page/overleafCapabilities.js +4 -1
  80. package/extension/src/page/ownedDirectoryCleanup.js +94 -0
  81. package/extension/src/page/referenceProjects.js +1 -0
  82. package/extension/src/page/reviewContext.js +113 -0
  83. package/extension/src/page/saveState.js +6 -4
  84. package/extension/src/page/snapshotRouter.js +84 -39
  85. package/extension/src/page/textCoordinates.js +17 -1
  86. package/extension/src/page/textFileCreator.js +114 -26
  87. package/extension/src/page/trackedChangeCapture.js +12 -2
  88. package/extension/src/page/trackedChangeOwnership.js +19 -1
  89. package/extension/src/page/trackedChangeReplay.js +41 -21
  90. package/extension/src/page/trackedChangesLifecycle.js +217 -199
  91. package/extension/src/page/treeOperations.js +7 -10
  92. package/extension/src/page/updateSafetyProbe.js +41 -0
  93. package/extension/src/page/writebackReceiptJournal.js +2 -0
  94. package/extension/src/page/writebackRouter.js +103 -155
  95. package/extension/src/pageBridge.js +126 -69
  96. package/extension/src/popup.js +62 -10
  97. package/extension/src/shared/agentTranscript.js +373 -17
  98. package/extension/src/shared/auditRecords.js +4 -0
  99. package/extension/src/shared/compatibility.js +22 -6
  100. package/extension/src/shared/compileAdapter.js +129 -66
  101. package/extension/src/shared/composerAttachmentDraft.js +156 -0
  102. package/extension/src/shared/contextUsage.js +125 -0
  103. package/extension/src/shared/directoryCleanupProof.js +43 -0
  104. package/extension/src/shared/failureReasons.js +349 -4
  105. package/extension/src/shared/globalPreferences.js +30 -13
  106. package/extension/src/shared/governanceRules.js +4 -37
  107. package/extension/src/shared/i18n.js +590 -24
  108. package/extension/src/shared/lineReferences.js +38 -31
  109. package/extension/src/shared/managedUpdateProjection.js +2 -2
  110. package/extension/src/shared/nativeRequestIdentity.js +1 -0
  111. package/extension/src/shared/pageRpcContract.js +5 -4
  112. package/extension/src/shared/panelIcons.js +39 -0
  113. package/extension/src/shared/projectFiles.js +21 -44
  114. package/extension/src/shared/providerProfiles.js +5 -3
  115. package/extension/src/shared/reviewing.js +130 -1
  116. package/extension/src/shared/runActivityModel.js +70 -4
  117. package/extension/src/shared/runExecutionSnapshot.js +13 -0
  118. package/extension/src/shared/runInputQueue.js +48 -5
  119. package/extension/src/shared/selectionContext.js +12 -3
  120. package/extension/src/shared/sessionState.js +132 -28
  121. package/extension/src/shared/sessionTitle.js +14 -1
  122. package/extension/src/shared/settlementFacts.js +10 -2
  123. package/extension/src/shared/sharedSessionBridge.js +118 -27
  124. package/extension/src/shared/sharedSessionTransfer.js +140 -0
  125. package/extension/src/shared/staleGuard.js +166 -52
  126. package/extension/src/shared/storageDb.js +90 -26
  127. package/extension/src/shared/storageMigration.js +78 -112
  128. package/extension/src/shared/storageRunActions.js +267 -30
  129. package/extension/src/shared/subagentActivityModel.js +1 -1
  130. package/extension/src/shared/undoOperations.js +253 -32
  131. package/extension/src/shared/updateConsent.js +6 -4
  132. package/extension/src/shared/updateRuntimeIdentity.js +17 -3
  133. package/extension/src/shared/writebackIntent.js +16 -0
  134. package/extension/src/shared/writebackOperationEvidence.js +4 -15
  135. package/extension/src/shared/writebackSettlement.js +58 -40
  136. package/extension/styles/panel.css +1123 -493
  137. package/native-host/src/anthropicBridgeRequest.js +4 -2
  138. package/native-host/src/anthropicBridgeResponse.js +6 -2
  139. package/native-host/src/anthropicMessagesBridge.js +24 -16
  140. package/native-host/src/bootstrapMigration.js +296 -0
  141. package/native-host/src/bootstrapMigrationContract.js +101 -0
  142. package/native-host/src/bootstrapUpdate.js +269 -0
  143. package/native-host/src/bootstrapUpdateWatchdog.js +44 -0
  144. package/native-host/src/chatBridgeRequest.js +60 -6
  145. package/native-host/src/chatBridgeResponse.js +20 -1
  146. package/native-host/src/chatCompletionsBridge.js +48 -15
  147. package/native-host/src/codexConfigPaths.js +106 -0
  148. package/native-host/src/codexHome.js +66 -73
  149. package/native-host/src/codexHomeScope.js +124 -0
  150. package/native-host/src/codexInheritedAuth.js +90 -0
  151. package/native-host/src/codexLocalSkills.js +301 -0
  152. package/native-host/src/codexPromptAssembly.js +114 -127
  153. package/native-host/src/codexProviderTest.js +24 -26
  154. package/native-host/src/codexSessionRunner.js +234 -198
  155. package/native-host/src/codexSessionTiming.js +18 -8
  156. package/native-host/src/codexSkillIsolation.js +170 -0
  157. package/native-host/src/codexThreadFork.js +19 -3
  158. package/native-host/src/index.js +19 -7
  159. package/native-host/src/managedInstall.js +110 -35
  160. package/native-host/src/managedInstallRecovery.js +140 -0
  161. package/native-host/src/managedRuntimeEvidence.js +57 -0
  162. package/native-host/src/mirrorWorkspace.js +92 -22
  163. package/native-host/src/nativeDoctor.js +15 -0
  164. package/native-host/src/nativeHelperLifecycle.js +21 -0
  165. package/native-host/src/nativeHostRegistration.js +33 -0
  166. package/native-host/src/nativeRunLifecycle.js +71 -0
  167. package/native-host/src/processStopBarrier.js +42 -7
  168. package/native-host/src/projectPathPlatform.js +30 -0
  169. package/native-host/src/providerContextError.js +20 -0
  170. package/native-host/src/providerHttp.js +80 -0
  171. package/native-host/src/providerProfile.js +4 -2
  172. package/native-host/src/providerReasoning.js +2 -7
  173. package/native-host/src/providerRuntime.js +1 -0
  174. package/native-host/src/providerStore.js +72 -14
  175. package/native-host/src/responsesPassthroughBridge.js +266 -52
  176. package/native-host/src/runtimeInstaller.js +21 -1
  177. package/native-host/src/selectionScope.js +36 -8
  178. package/native-host/src/subagentAdmission.js +26 -0
  179. package/native-host/src/subagentBroker.js +79 -66
  180. package/native-host/src/subagentTelemetry.js +19 -10
  181. package/native-host/src/subagentWorkspacePath.js +5 -2
  182. package/native-host/src/taskRunnerRuntime.js +92 -57
  183. package/native-host/src/textPatch.js +473 -377
  184. package/native-host/src/tomlStatements.js +140 -0
  185. package/native-host/src/updateManager.js +409 -46
  186. package/native-host/src/writingStyleRuntime.js +57 -29
  187. package/native-host/src/writingStyleStore.js +73 -0
  188. package/package.json +5 -8
  189. package/scripts/install-managed.mjs +1 -0
@@ -1,20 +1,18 @@
1
1
  (function initCodexOverleafTrackedChangesLifecycle(root, factory) {
2
2
  if (typeof module === 'object' && module.exports) {
3
- module.exports = factory(require('./trackedChangeReplay'));
3
+ module.exports = factory(require('./trackedChangeReplay'), require('./reviewContext'));
4
4
  } else {
5
- root.CodexOverleafTrackedChangesLifecycle = factory(root.CodexOverleafTrackedChangeReplay);
5
+ root.CodexOverleafTrackedChangesLifecycle = factory(root.CodexOverleafTrackedChangeReplay, root.CodexOverleafReviewContext);
6
6
  }
7
- })(typeof window !== 'undefined' ? window : globalThis, function trackedChangesLifecycleFactory(Replay) {
7
+ })(typeof window !== 'undefined' ? window : globalThis, function trackedChangesLifecycleFactory(Replay, ReviewContext) {
8
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
9
  function create(deps = {}) {
10
+ const base = createCore(deps);
11
+ const run = (method, params) => ReviewContext.create(deps, params).run((scoped, input) => createCore(scoped)[method](input));
12
+ return { ...base, rejectTrackedChanges: params => run('rejectTrackedChanges', params),
13
+ acceptTrackedChanges: params => run('acceptTrackedChanges', params) };
14
+ }
15
+ function createCore(deps = {}) {
18
16
  const {
19
17
  window,
20
18
  checkWritebackRunProjectId,
@@ -41,7 +39,7 @@
41
39
  normalizeOperationPaths,
42
40
  normalizeReviewingSignalText,
43
41
  normalizeSafeProjectPath,
44
- openFileByPath,
42
+ openFileByPath: openReviewFileByPath,
45
43
  readActiveEditorText,
46
44
  readNodeSignalText,
47
45
  setReviewingEnabled,
@@ -49,13 +47,19 @@
49
47
  verifyActiveEditorText,
50
48
  collectElements,
51
49
  } = deps;
52
- const { buildAcceptReplayOperations, rebaseCheckpointPair } = Replay.create({
50
+ const { buildAcceptReplayOperations, rebaseCheckpointPair, prepareSnapshotUndoCheckpoints, buildSnapshotUndoResult } = Replay.create({
53
51
  normalizeSafeProjectPath, applyTextPatches
54
52
  });
53
+ function openFileByPath(path, ...options) {
54
+ if (path !== getActiveFilePath()) rememberTrackedChangeNodeContext(getActiveFilePath());
55
+ return openReviewFileByPath(path, ...options);
56
+ }
55
57
 
56
58
  function nativeReviewBlocked(path, code, applied = [], requestStarted = false) {
57
59
  const reason = requestStarted
58
60
  ? 'The native review request could not be fully verified; some target changes may already be accepted. No text replay was attempted. Check Overleaf Review before retrying.'
61
+ : code === 'native_untracked_undo_pending_changes'
62
+ ? 'This file has unresolved tracked changes. Undo without track changes was stopped to preserve them; no document changes were made.'
59
63
  : 'The native tracked-change scope could not be safely isolated. No review mutation was attempted. Use Overleaf Review to handle the changes individually.';
60
64
  return { ok: false, applied, skipped: [{ trackedChange: { path }, result: {
61
65
  ok: false, code, reason, failure: {
@@ -68,14 +72,15 @@
68
72
  } }] };
69
73
  }
70
74
 
71
- async function waitForNativeReview(path, refs, untracked = false) {
75
+ async function waitForNativeReview(path, refs, untracked = false, checkpointReady = () => true) {
72
76
  const prepare = () => untracked ? prepareUntrackedUndo(path) : prepareTrackedChangeReview(path, refs);
73
77
  for (let attempt = 0; attempt < 16; attempt++) {
74
78
  const scope = prepare();
75
- if (scope.ok) return scope;
79
+ if (scope.ok && checkpointReady(scope)) return scope;
76
80
  await delay(150);
77
81
  }
78
- return prepare();
82
+ const scope = prepare();
83
+ return scope.ok && !checkpointReady(scope) ? { ...scope, ok: false } : scope;
79
84
  }
80
85
 
81
86
  async function prepareNativeReview(params, allowUntrackedUndo = false) {
@@ -99,7 +104,7 @@
99
104
  ...checkpoints.map(file => normalizeSafeProjectPath(file?.path))])];
100
105
  if ((!refs.length && !untracked) || refs.some(ref => ref.invalidProjectPath || !ref.path) || paths.some(path => !path))
101
106
  return nativeReviewBlocked('', 'native_review_identity_unavailable');
102
- const files = [], deadline = Date.now() + 90000;
107
+ const files = [], deadline = Math.min(Date.now() + 90000, deps.reviewDeadlineAt ?? Infinity);
103
108
  for (const path of paths) {
104
109
  if (Date.now() >= deadline || checkWritebackRunProjectId(params))
105
110
  return nativeReviewBlocked(path, 'native_review_context_changed');
@@ -162,6 +167,7 @@
162
167
  }));
163
168
  const expectedText = accepting ? post[0].content : before[0].content;
164
169
  const expectedKeys = accepting ? scope.merged.preservedKeys : scope.merged.beforeKeys;
170
+ if (deps.reviewGuard?.()) return nativeReviewBlocked(path, 'native_review_context_changed');
165
171
  attempted = true;
166
172
  view.dispatch({ changes });
167
173
  compileBridge.markSourceEdited();
@@ -199,7 +205,7 @@
199
205
  async function rejectPlanWithMergedInsertions(params, plan) {
200
206
  const mixed = plan.files.find(file => !file.scope.merged && file.scope.unrelated.length);
201
207
  if (mixed) return nativeReviewBlocked(mixed.path, 'native_reject_mixed_changes');
202
- const applied = [];
208
+ const applied = deps.createReviewApplied?.() || [];
203
209
  for (const file of plan.files) {
204
210
  if (Date.now() >= plan.deadline || checkWritebackRunProjectId(params))
205
211
  return nativeReviewBlocked(file.path, 'native_review_context_changed', applied, applied.length > 0);
@@ -220,7 +226,7 @@
220
226
  }
221
227
 
222
228
  async function acceptNativeReview(params, plan) {
223
- const applied = [];
229
+ const applied = deps.createReviewApplied?.() || [];
224
230
  let token = window?.document?.querySelector?.('meta[name="ol-csrfToken"]')?.getAttribute('content');
225
231
  try { token = JSON.parse(token); } catch (_error) { /* Older pages expose plain text. */ }
226
232
  if (typeof token !== 'string' || !token || token.length > 4096
@@ -251,6 +257,7 @@
251
257
  const controller = new window.AbortController();
252
258
  const timer = window.setTimeout(() => controller.abort(), Math.min(12000, Math.max(1, plan.deadline - Date.now())));
253
259
  try {
260
+ if (deps.reviewGuard?.()) return nativeReviewBlocked(path, 'native_review_context_changed', applied, applied.length > 0);
254
261
  // Same ID-scoped endpoint and CSRF header used by Overleaf's RangesProvider.
255
262
  const response = await window.fetch('/project/' + encodeURIComponent(params.runProjectId)
256
263
  + '/doc/' + encodeURIComponent(scope.nativeDocId) + '/changes/accept', {
@@ -258,6 +265,7 @@
258
265
  headers: { 'Content-Type': 'application/json', Accept: 'application/json', 'X-Csrf-Token': token },
259
266
  body: JSON.stringify({ change_ids: scope.ids })
260
267
  });
268
+ if (deps.reviewGuard?.()) return nativeReviewBlocked(path, 'native_review_context_changed', applied, true);
261
269
  if (!response.ok) return nativeReviewBlocked(path, 'native_accept_http_' + response.status, applied, true);
262
270
  } catch (_error) {
263
271
  return nativeReviewBlocked(path, 'native_accept_request_unconfirmed', applied, true);
@@ -282,19 +290,15 @@
282
290
  return { ok: true, verified: true, applied, skipped: [] };
283
291
  }
284
292
 
293
+ let nativeRejectPlan = null;
285
294
  async function rejectTrackedChanges(params = {}) {
286
- // Welcome-panel + write-guard: defense-in-depth.
287
- // The pageBridge wrapper already runs the runProjectId guard, but a
288
- // future caller could reach the router directly. A missing or empty
289
- // `runProjectId` blocks the reject with the same shape the page-side
290
- // guard uses. Existing tests that target individual reject behaviors set
291
- // `runProjectId` on params; tests that intentionally omit it land here.
295
+ // Direct router callers also require the original run project identity.
292
296
  const writeGuardBlock = checkWritebackRunProjectId(params);
293
297
  if (writeGuardBlock) return writeGuardBlock;
294
298
  const trackedChanges = normalizeTrackedChangeRefs(params.trackedChanges || []);
295
299
  const expectedFiles = Array.isArray(params.expectedFiles) ? params.expectedFiles : [];
296
300
  const postFiles = Array.isArray(params.postFiles) ? params.postFiles : [];
297
- const applied = [];
301
+ const applied = deps.createReviewApplied?.() || [];
298
302
  const skipped = [];
299
303
  const appliedPaths = new Set();
300
304
  const invalidTrackedChange = trackedChanges.find(trackedChange => trackedChange.invalidProjectPath);
@@ -310,6 +314,7 @@
310
314
  }
311
315
 
312
316
  const nativeReview = await prepareNativeReview(params, true);
317
+ nativeRejectPlan = nativeReview;
313
318
  if (nativeReview && !nativeReview.ok) return nativeReview;
314
319
  if (nativeReview?.files.some(file => file.scope.merged))
315
320
  return rejectPlanWithMergedInsertions(params, nativeReview);
@@ -320,12 +325,12 @@
320
325
  // An ordinary write has no owned editor-history entry. Restore its
321
326
  // checkpoint directly; global Undo may belong to an earlier user edit.
322
327
  const restored = await restoreExpectedFilesWithNoTraceUndo(expectedFiles, postFiles, {
323
- runProjectId: params.runProjectId
328
+ runProjectId: params.runProjectId, untrackedPlan: nativeReview
324
329
  });
325
- if (restored.applied.length) compileBridge.markSourceEdited();
330
+ if (restored.applied.some(entry => entry.result?.changedDocument !== false)) compileBridge.markSourceEdited();
326
331
  return { ...restored, ok: restored.attempted && restored.ok,
327
- skipped: restored.attempted ? restored.skipped : [{ trackedChange: null, result: {
328
- ok: false, code: restored.code || 'snapshot_undo_unavailable',
332
+ skipped: restored.attempted ? restored.skipped : [{ trackedChange: { path: restored.file || '' }, result: {
333
+ ok: false, file: restored.file || '', code: restored.code || 'snapshot_undo_unavailable',
329
334
  reason: restored.reason || 'The saved checkpoint is unavailable for this undo.'
330
335
  } }] };
331
336
  }
@@ -427,7 +432,7 @@
427
432
  continue;
428
433
  }
429
434
 
430
- const rejectControl = findRejectControlForTrackedChangeNode(node);
435
+ const rejectControl = isTrackedChangeNodeInNativeScope(node, trackedChange.path || getActiveFilePath()) ? findRejectControlForTrackedChangeNode(node) : null;
431
436
  if (!rejectControl) {
432
437
  if (expectedFiles.length || trackedChange.path) {
433
438
  continue;
@@ -477,7 +482,7 @@
477
482
  const completion = await rejectRemainingTrackedChangesForExpectedFiles(expectedFiles, applied);
478
483
  if (!completion.ok) {
479
484
  skipped.push({
480
- trackedChange: null,
485
+ trackedChange: completion.file ? { path: completion.file } : null,
481
486
  result: completion
482
487
  });
483
488
  }
@@ -485,7 +490,7 @@
485
490
  const completion = await rejectRemainingTrackedChangesForTrackedPaths(trackedChanges, applied);
486
491
  if (!completion.ok) {
487
492
  skipped.push({
488
- trackedChange: null,
493
+ trackedChange: completion.file ? { path: completion.file } : null,
489
494
  result: completion
490
495
  });
491
496
  }
@@ -505,18 +510,15 @@
505
510
  // Native-ledger browsers accept exact IDs without changing document text.
506
511
  // The original undo/replay flow remains only for legacy adapter callers.
507
512
  async function acceptTrackedChanges(params = {}) {
508
- // Welcome-panel + write-guard: defense-in-depth.
509
- // The pageBridge wrapper already runs the runProjectId guard, but a
510
- // future caller could reach the router directly. A missing or empty
511
- // `runProjectId` blocks the accept with the same shape the page-side
512
- // guard uses.
513
+ // Direct router callers also require the original run project identity.
513
514
  const writeGuardBlock = checkWritebackRunProjectId(params);
514
515
  if (writeGuardBlock) return writeGuardBlock;
516
+ if (params.verifyManualReview === true) return nativeReviewBlocked('', 'native_accept_protocol_unsupported');
515
517
  const nativeReview = await prepareNativeReview(params);
516
518
  if (nativeReview) return nativeReview.ok ? acceptNativeReview(params, nativeReview) : nativeReview;
517
519
  const expectedFiles = Array.isArray(params.expectedFiles) ? params.expectedFiles : [];
518
520
  const postFiles = Array.isArray(params.postFiles) ? params.postFiles : [];
519
- const applied = [];
521
+ const applied = deps.createReviewApplied?.() || [];
520
522
  const skipped = [];
521
523
  // diagnostics: a per-step trace returned to the caller so the run card can
522
524
  // surface exactly what happened on each Accept All step. Each entry is
@@ -559,12 +561,8 @@
559
561
  const editorUndo = await rejectTrackedChangesViaEditorUndo(expectedFiles, postFiles, applied);
560
562
  let undoReady = editorUndo.ok === true;
561
563
  let snapshotUndo = null;
562
- // A page refresh preserves the run checkpoint but clears CodeMirror's
563
- // in-memory undo history. When the native editor undo made no progress,
564
- // reuse Undo's guarded snapshot path: it first verifies that every file is
565
- // still exactly at this run's post-write content, then restores the
566
- // persisted pre-write content with Track Changes disabled. The normal
567
- // Accept replay below can then apply the post-write patch untracked.
564
+ // After a refresh clears native history, guarded snapshot rollback can
565
+ // restore exact checkpoints in Editing before the legacy Accept replay.
568
566
  if (!undoReady && applied.length === appliedBeforeUndo) {
569
567
  snapshotUndo = await restoreExpectedFilesWithNoTraceUndo(expectedFiles, postFiles, {
570
568
  runProjectId: params.runProjectId
@@ -630,10 +628,8 @@
630
628
  reason: editingSwitch.reason || ''
631
629
  });
632
630
  if (!editingSwitch.ok) {
633
- // The undo already reverted the writeback; without a *confirmed* Editing
634
- // mode the replay would itself be tracked. Bail rather than re-introduce
635
- // tracked changes. The structured failure from forceEditingForAcceptReplay
636
- // (editing_not_confirmed, changedDocument:true) is preserved verbatim.
631
+ // Undo already changed the document. Preserve that evidence and stop
632
+ // when Editing cannot be confirmed for an untracked replay.
637
633
  return {
638
634
  ok: false,
639
635
  applied: [],
@@ -676,12 +672,8 @@
676
672
  };
677
673
  }
678
674
 
679
- // Step 3: re-apply each file's changed fragments as a plain edit. With
680
- // tracking off these land as permanent, untracked text. The replay must
681
- // write only the minimal changed fragments via the `patches` path of
682
- // applyOperationsCore — never a whole-file replaceAll, which would clobber
683
- // any unrelated content and produce one giant tracked change if anything
684
- // about the mode switch were imperfect.
675
+ // Replay only the changed fragments in Editing; whole-file replacement
676
+ // would overwrite unrelated text or create one large tracked change.
685
677
  const operationsResult = buildAcceptReplayOperations(
686
678
  paths,
687
679
  expectedByPath,
@@ -703,18 +695,9 @@
703
695
  };
704
696
  }
705
697
  const operations = operationsResult.operations;
706
- // Per-op replay loop with sticky-Editing re-confirm and a short stable
707
- // window before every actual CodeMirror write.
708
- //
709
- // Bug C: even after forceEditingForAcceptReplay confirmed Editing once,
710
- // Overleaf has been observed flipping back to Reviewing between the
711
- // confirm and the next CodeMirror write (Overleaf-side override, per-user
712
- // "Track Changes for me" setting, or a positive-confirm false-positive
713
- // window). The fix: re-verify Editing immediately BEFORE every single
714
- // operation, and if Reviewing has slipped back on, force the toggle off
715
- // again and positively re-confirm before writing. If Editing still cannot
716
- // be positively confirmed after the re-toggle, bail the rest of the loop
717
- // so we never land a tracked write.
698
+ // Overleaf may return to Reviewing between confirmation and a write.
699
+ // Reconfirm stable Editing before every operation, force it off again
700
+ // if needed, and stop the tail whenever confirmation fails.
718
701
  let bailedReason = null;
719
702
  for (const operation of operations) {
720
703
  const opPath = operation?.path || '';
@@ -868,12 +851,8 @@
868
851
  }
869
852
  }
870
853
 
871
- // Step 4 intentionally does NOT restore Reviewing. Accept replay is a
872
- // no-trace write transaction: restoring Track Changes immediately after the
873
- // CodeMirror dispatch can race with Overleaf's collaboration/reviewing
874
- // settlement and cause the replay itself to land as fresh tracked changes.
875
- // Leave the editor in Editing after a successful replay; the user can turn
876
- // Reviewing back on manually after Overleaf has saved the accepted text.
854
+ // Leave Editing active: restoring Reviewing before collaboration settles
855
+ // can turn the untracked Accept replay into fresh tracked changes.
877
856
  if (weToggledOff) {
878
857
  pushDiagnostic('restoreReviewing', {
879
858
  ok: true,
@@ -895,12 +874,8 @@
895
874
  };
896
875
  }
897
876
 
898
- // Summarizes a reviewing state for the per-step diagnostics. Surfaces
899
- // exactly the bits the user needs to diagnose a sticky-Editing slip: the
900
- // reviewing detector's verdict (ok/status/source), a compact controls
901
- // summary, and the two strict gates (isReviewingPositivelyOn /
902
- // isEditingPositivelyConfirmed). Self-contained — the run card can render
903
- // this verbatim without re-reading any page state.
877
+ // Capture the detector signals and strict gates for self-contained
878
+ // diagnostics; rendering must not reread mutable page state.
904
879
  function summarizeReviewingStateForDiagnostics(state = {}) {
905
880
  const reviewing = state.reviewing || {};
906
881
  const controls = (state.signals?.controls || []).slice(0, 6).map(control => {
@@ -926,10 +901,8 @@
926
901
  };
927
902
  }
928
903
 
929
- // Track Changes is positively confirmed OFF only when Editing is positively
930
- // detected AND Reviewing is NOT positively confirmed on. This is the strict
931
- // gate the Accept replay needs: it must never replay while tracked, so
932
- // anything short of a positive "Editing on / Reviewing off" is rejected.
904
+ // Accept replay requires positively detected Editing and no positively
905
+ // confirmed Reviewing; every weaker observation must stop the replay.
933
906
  function isEditingPositivelyConfirmed(state = {}) {
934
907
  return isEditingConfirmedForNoTraceUndo(state) && !isReviewingConfirmedForWrite(state);
935
908
  }
@@ -988,42 +961,20 @@
988
961
  };
989
962
  }
990
963
 
991
- // Robustly switch Overleaf to Editing (Track Changes OFF) for the Accept
992
- // replay.
993
- //
994
- // Bug B root cause: the accept flow used ensureEditing, which short-circuits
995
- // on isEditingConfirmedForNoTraceUndo. That detector is negation-based — it
996
- // returns true whenever Reviewing is not *positively* confirmed-for-write
997
- // (which requires an active aria attribute the real Overleaf reviewing
998
- // control does not always carry) AND a control loosely matches an Editing
999
- // pattern. So while Track Changes was actually ON, it false-positived
1000
- // "already Editing", ensureEditing returned activated:false without toggling,
1001
- // and the replay landed as tracked changes.
1002
- //
1003
- // The fix here does NOT trust that lenient short-circuit: it reads the
1004
- // reviewing state explicitly and treats Editing as confirmed only when it is
1005
- // *positively* confirmed (Editing detected AND Reviewing not confirmed on).
1006
- // Whenever that strict gate is not met it forces setReviewingEnabled(false),
1007
- // then positively re-confirms Editing before returning ok; if it still cannot
1008
- // confirm Editing it returns not-ok so the caller bails instead of replaying
1009
- // while tracked.
964
+ // Accept replay requires positively confirmed Editing. The legacy detector
965
+ // can misclassify Reviewing controls without aria state; force the toggle
966
+ // and recheck before replaying untracked text.
1010
967
  async function forceEditingForAcceptReplay() {
1011
968
  const initial = getReviewingState({});
1012
969
  if (isEditingPositivelyConfirmed(initial)) {
1013
- // Positively confirmed already in Editing — no toggle needed.
1014
970
  return { ok: true, activated: false };
1015
971
  }
1016
972
 
1017
- // Reviewing is on, or Editing is not positively confirmed off. Force the
1018
- // toggle rather than trusting ensureEditing's lenient detection.
973
+ // Force the toggle whenever Editing is not positively confirmed.
1019
974
  const switched = await setReviewingEnabled(false, { waitMs: 1800 });
1020
975
  if (!switched.ok) {
1021
- // §9.4 editing_not_confirmed: the document is at pre-write state after
1022
- // the editor-undo succeeded; the mode toggle failed. The Accept replay
1023
- // was rolled back so the user-visible doc state has moved from the
1024
- // post-write tracked state back to pre-write — set changedDocument:true
1025
- // per the design spec's "is there text the user might want to inspect"
1026
- // contract for accept-side failures after a rollback.
976
+ // Native Undo already changed the document; a failed mode switch must
977
+ // preserve changedDocument evidence even though replay never started.
1027
978
  return {
1028
979
  ok: false,
1029
980
  code: switched.code || 'accept_editing_not_confirmed',
@@ -1065,10 +1016,6 @@
1065
1016
  return { ok: true, activated: true };
1066
1017
  }
1067
1018
 
1068
-
1069
-
1070
-
1071
-
1072
1019
  async function rejectTrackedChangesViaEditorUndo(expectedFiles, postFiles, applied) {
1073
1020
  const expectedByPath = new Map((expectedFiles || [])
1074
1021
  .filter(file => file?.path && typeof file.content === 'string')
@@ -1087,10 +1034,8 @@
1087
1034
  const postContent = postByPath.get(path);
1088
1035
  const activePathMatches = getActiveFilePath() === path;
1089
1036
  const activeContentMatches = readActiveEditorText() === postContent;
1090
- // Overleaf can update the selected tree path before CodeMirror swaps its
1091
- // document. In that state getActiveFilePath() already names `path`, while
1092
- // readActiveEditorText() still belongs to the previously viewed file.
1093
- // Force-reopen the target whenever either identity signal disagrees.
1037
+ // Tree selection can precede the editor swap. Reopen when either
1038
+ // the target path or its exact postimage is not yet active.
1094
1039
  if (path && (!activePathMatches || !activeContentMatches)) {
1095
1040
  const opened = await openFileByPath(path, { force: true });
1096
1041
  if (!opened.ok) {
@@ -1141,18 +1086,23 @@
1141
1086
  }
1142
1087
 
1143
1088
  async function restoreExpectedFilesWithNoTraceUndo(expectedFiles, postFiles, options = {}) {
1144
- const expectedByPath = new Map((expectedFiles || [])
1145
- .filter(file => file?.path && typeof file.content === 'string')
1146
- .map(file => [normalizeSafeProjectPath(file.path), file.content])
1147
- .filter(([path]) => path));
1148
- const postByPath = new Map((postFiles || [])
1149
- .filter(file => file?.path && typeof file.content === 'string')
1150
- .map(file => [normalizeSafeProjectPath(file.path), file.content])
1151
- .filter(([path]) => path));
1152
- const paths = Array.from(expectedByPath.keys()).filter(path => postByPath.has(path));
1089
+ const { expectedByPath, postByPath, paths } = prepareSnapshotUndoCheckpoints(expectedFiles, postFiles);
1153
1090
  const rebasedExpectedByPath = new Map();
1154
1091
  const rebasedPostByPath = new Map();
1155
1092
  const undoRebaseProofs = new Map();
1093
+ // Only the ordinary, native-ledger-checked Undo route may recognize a
1094
+ // lost receipt's exact pre-image. This consumes an existing checkpoint;
1095
+ // it grants no native review, file creation or move ownership.
1096
+ const untrackedByPath = new Map((options.untrackedPlan?.files || []).map(file => [file.path, file]));
1097
+ const alreadyRestored = new Set();
1098
+ const mismatch = path => ({ ok: false, file: path, changedDocument: false, code: 'snapshot_undo_current_mismatch',
1099
+ reason: `${path} 当前内容或文档身份与本轮撤销检查点不一致;Codex 保留恢复材料并停止撤销。` });
1100
+ async function verifyAlreadyRestored(path) {
1101
+ if (getActiveFilePath() !== path && !(await openFileByPath(path)).ok) return false;
1102
+ return (await waitForNativeReview(path, [], true, scope => Boolean(scope.nativeDocId)
1103
+ && scope.nativeDocId === untrackedByPath.get(path)?.nativeDocId
1104
+ && getActiveFilePath() === path && readActiveEditorText() === expectedByPath.get(path))).ok === true;
1105
+ }
1156
1106
  if (!paths.length) {
1157
1107
  return {
1158
1108
  ok: false,
@@ -1173,7 +1123,7 @@
1173
1123
  attempted: false,
1174
1124
  applied: [],
1175
1125
  skipped: [],
1176
- code: 'snapshot_undo_open_failed',
1126
+ code: 'snapshot_undo_open_failed', file: path,
1177
1127
  reason: `无法打开 ${path} 来执行快照撤销。`
1178
1128
  };
1179
1129
  }
@@ -1182,7 +1132,8 @@
1182
1132
  path,
1183
1133
  expectedByPath.get(path),
1184
1134
  postByPath.get(path),
1185
- 1500
1135
+ 1500,
1136
+ untrackedByPath.has(path)
1186
1137
  );
1187
1138
  if (!ready.ok) {
1188
1139
  return {
@@ -1192,10 +1143,14 @@
1192
1143
  skipped: [],
1193
1144
  rebasedExpectedFiles: [],
1194
1145
  rebasedPostFiles: [],
1195
- code: 'snapshot_undo_current_mismatch',
1146
+ code: 'snapshot_undo_current_mismatch', file: path,
1196
1147
  reason: `${path} 当前内容已经不是本轮写入后的内容;为避免覆盖你的后续修改,Codex 不执行快照撤销。`
1197
1148
  };
1198
1149
  }
1150
+ if (ready.alreadyRestored) {
1151
+ if (!(await verifyAlreadyRestored(path))) return { ...mismatch(path), attempted: false, applied: [], skipped: [] };
1152
+ alreadyRestored.add(path);
1153
+ }
1199
1154
  rebasedExpectedByPath.set(path, ready.expectedContent);
1200
1155
  rebasedPostByPath.set(path, ready.postContent);
1201
1156
  if (ready.prefixLength || ready.suffixLength) {
@@ -1203,55 +1158,49 @@
1203
1158
  }
1204
1159
  }
1205
1160
 
1206
- const operations = paths.map(path => ({
1161
+ // Re-check observations after other preflight awaits before starting the
1162
+ // unfinished tail. They are checked again after writes before settlement.
1163
+ for (const path of alreadyRestored) {
1164
+ if (!(await verifyAlreadyRestored(path))) return { ...mismatch(path), attempted: false, applied: [], skipped: [] };
1165
+ }
1166
+ const operations = paths.filter(path => !alreadyRestored.has(path)).map(path => ({
1207
1167
  type: 'edit',
1208
1168
  path,
1209
1169
  replaceAll: rebasedExpectedByPath.get(path),
1210
1170
  reason: 'Undo tracked edit'
1211
1171
  }));
1212
- const result = await applyOperationsWithNoTraceUndo(operations, {
1172
+ const result = operations.length ? await applyOperationsWithNoTraceUndo(operations, {
1213
1173
  baseFiles: paths.map(path => ({
1214
1174
  path,
1215
1175
  content: rebasedPostByPath.get(path)
1216
1176
  })),
1217
1177
  runProjectId: typeof options.runProjectId === 'string' ? options.runProjectId : ''
1218
- });
1219
- const toTrackedResult = item => ({
1220
- trackedChange: {
1221
- key: `snapshot-undo:${item.operation?.path || ''}`,
1222
- id: '',
1223
- path: item.operation?.path || '',
1224
- label: 'No-trace snapshot undo'
1225
- },
1226
- result: item.result?.ok !== false && item.result?.verified === true
1227
- && undoRebaseProofs.has(item.operation?.path)
1228
- && item.result.verifiedContent === rebasedExpectedByPath.get(item.operation?.path)
1229
- ? { ...item.result, undoRebaseProof: undoRebaseProofs.get(item.operation.path) }
1230
- : item.result
1231
- });
1232
- return {
1233
- ok: !(result.skipped || []).length,
1234
- attempted: true,
1235
- applied: (result.applied || []).map(toTrackedResult),
1236
- skipped: (result.skipped || []).map(toTrackedResult),
1237
- rebasedExpectedFiles: paths.map(path => ({
1238
- path,
1239
- content: rebasedExpectedByPath.get(path)
1240
- })),
1241
- rebasedPostFiles: paths.map(path => ({
1242
- path,
1243
- content: rebasedPostByPath.get(path)
1244
- }))
1245
- };
1178
+ }) : { applied: [], skipped: [] };
1179
+ const converted = buildSnapshotUndoResult(result, rebasedExpectedByPath, rebasedPostByPath, undoRebaseProofs);
1180
+ const applied = deps.createReviewApplied?.() || [];
1181
+ applied.push(...converted.applied);
1182
+ const skipped = converted.skipped;
1183
+ for (const path of alreadyRestored) {
1184
+ const verified = await verifyAlreadyRestored(path);
1185
+ const entry = { trackedChange: { key: `snapshot-undo:${path}`, id: '', path,
1186
+ label: 'Already restored checkpoint' }, result: verified
1187
+ ? { ok: true, verified: true, verifiedContent: expectedByPath.get(path), changedDocument: false, idempotent: true }
1188
+ : mismatch(path) };
1189
+ (verified ? applied : skipped).push(entry);
1190
+ }
1191
+ return { ...converted, ok: !skipped.length, applied, skipped };
1246
1192
  }
1247
1193
 
1248
- async function waitForActiveEditorCheckpoint(filePath, expectedContent, postContent, timeoutMs) {
1194
+ async function waitForActiveEditorCheckpoint(filePath, expectedContent, postContent, timeoutMs, allowAlreadyRestored = false) {
1249
1195
  const deadline = Date.now() + Math.max(0, Number(timeoutMs) || 0);
1250
1196
  let actual = readActiveEditorText();
1251
1197
  while (Date.now() < deadline) {
1252
1198
  const activeFileMatches = !filePath || getActiveFilePath() === filePath;
1253
1199
  actual = readActiveEditorText();
1254
1200
  if (activeFileMatches) {
1201
+ if (allowAlreadyRestored && actual === expectedContent) {
1202
+ return { ok: true, alreadyRestored: true, expectedContent, postContent, prefixLength: 0, suffixLength: 0 };
1203
+ }
1255
1204
  const rebased = rebaseCheckpointPair(actual, expectedContent, postContent);
1256
1205
  if (rebased.ok) {
1257
1206
  return rebased;
@@ -1265,7 +1214,6 @@
1265
1214
  };
1266
1215
  }
1267
1216
 
1268
-
1269
1217
  async function waitForActiveEditorExpectedText(filePath, expectedContent, timeoutMs) {
1270
1218
  const deadline = Date.now() + Math.max(0, Number(timeoutMs) || 0);
1271
1219
  let actual = readActiveEditorText();
@@ -1292,34 +1240,36 @@
1292
1240
  const maxClicks = 200;
1293
1241
  for (let clicks = 0; clicks <= maxClicks; clicks += 1) {
1294
1242
  if (readActiveEditorText() === expectedContent) {
1295
- return {
1296
- ok: true,
1297
- attempted: clicks > 0,
1298
- clicks
1299
- };
1243
+ return { ok: true, attempted: clicks > 0, clicks };
1300
1244
  }
1301
1245
  if (clicks === maxClicks) {
1302
1246
  break;
1303
1247
  }
1304
1248
 
1305
- const undoControl = findEditorUndoControl();
1249
+ const beforeText = readActiveEditorText();
1250
+ let undoControl = findEditorUndoControl();
1306
1251
  if (!undoControl) {
1307
- return {
1308
- ok: false,
1309
- attempted: clicks > 0,
1310
- code: 'editor_undo_control_not_found',
1252
+ // Text and the native ledger can be ready before the toolbar updates.
1253
+ // Wait only before dispatch, keeping this click's scope fixed.
1254
+ const assertStable = await createReviewScopeGuard(path, beforeText), until = Date.now() + 1500;
1255
+ while (!undoControl && Date.now() < until) {
1256
+ assertStable();
1257
+ await delay(Math.min(60, until - Date.now()));
1258
+ assertStable();
1259
+ undoControl = findEditorUndoControl();
1260
+ }
1261
+ assertStable();
1262
+ }
1263
+ if (!undoControl) {
1264
+ return { ok: false, attempted: clicks > 0, code: 'editor_undo_control_not_found',
1311
1265
  reason: `没有找到 Overleaf 编辑器自己的 Undo/撤销按钮,无法一次性撤销 ${path} 的本轮留痕。`
1312
1266
  };
1313
1267
  }
1314
1268
 
1315
- const beforeText = readActiveEditorText();
1316
1269
  clickNode(undoControl);
1317
1270
  await waitForEditorTextProgress(beforeText, expectedContent, 1000);
1318
1271
  if (readActiveEditorText() === beforeText) {
1319
- return {
1320
- ok: false,
1321
- attempted: clicks > 0,
1322
- code: 'editor_undo_no_progress',
1272
+ return { ok: false, attempted: clicks > 0, code: 'editor_undo_no_progress',
1323
1273
  reason: `Codex 点击了 Overleaf Undo/撤销,但 ${path} 内容没有变化。`
1324
1274
  };
1325
1275
  }
@@ -1352,7 +1302,9 @@
1352
1302
  }
1353
1303
  const result = await rejectRemainingTrackedChangesForExpectedFile(file, applied);
1354
1304
  if (!result.ok) {
1355
- return result;
1305
+ // The sweep knows its target even when no DOM review node remains.
1306
+ // Keep that scope so independently verified files can leave recovery.
1307
+ return { ...result, file: file.path };
1356
1308
  }
1357
1309
  }
1358
1310
  return { ok: true };
@@ -1373,7 +1325,7 @@
1373
1325
  for (const path of paths) {
1374
1326
  const result = await rejectRemainingTrackedChangesForPath(path, applied);
1375
1327
  if (!result.ok) {
1376
- return result;
1328
+ return { ...result, file: path };
1377
1329
  }
1378
1330
  }
1379
1331
  return { ok: true };
@@ -1394,7 +1346,9 @@
1394
1346
  const appliedCountBefore = applied.length;
1395
1347
  const maxAttempts = 200;
1396
1348
  for (let attempt = 0; attempt < maxAttempts; attempt += 1) {
1397
- const node = findLastTrackedChangeNodeForPath(path);
1349
+ const selection = await waitForRejectableTrackedChange(path);
1350
+ selection.assertStable();
1351
+ const node = selection.node;
1398
1352
  if (!node) {
1399
1353
  if (applied.length > appliedCountBefore) {
1400
1354
  return { ok: true };
@@ -1406,7 +1360,7 @@
1406
1360
  };
1407
1361
  }
1408
1362
 
1409
- const rejectControl = findRejectControlForTrackedChangeNode(node);
1363
+ const rejectControl = isTrackedChangeNodeInNativeScope(node, path) ? findRejectControlForTrackedChangeNode(node) : null;
1410
1364
  if (!rejectControl) {
1411
1365
  return {
1412
1366
  ok: false,
@@ -1417,6 +1371,7 @@
1417
1371
 
1418
1372
  const trackedChange = trackedChangeRefFromNode(node, path);
1419
1373
  const beforeText = readActiveEditorText();
1374
+ selection.assertStable();
1420
1375
  clickNode(rejectControl);
1421
1376
  await waitForTrackedChangeRejectProgress(trackedChange, path, beforeText, 1200);
1422
1377
  applied.push({
@@ -1447,6 +1402,17 @@
1447
1402
  };
1448
1403
  }
1449
1404
 
1405
+ async function verifyReviewFileContent(file, applied) {
1406
+ const verified = await verifyActiveEditorText(file.content, file.path, 800);
1407
+ if (getActiveFilePath() !== file.path) verified.ok = false;
1408
+ if (verified.ok) for (const entry of applied) {
1409
+ if (entry.trackedChange?.path === file.path && entry.result?.ok === true
1410
+ && ['overleaf-review-reject', 'overleaf-review-reject-sweep'].includes(entry.result.method))
1411
+ Object.assign(entry.result, { verified: true, verifiedContent: file.content });
1412
+ }
1413
+ return verified;
1414
+ }
1415
+
1450
1416
  async function rejectRemainingTrackedChangesForExpectedFile(file, applied) {
1451
1417
  const opened = await openFileByPath(file.path);
1452
1418
  if (!opened.ok) {
@@ -1460,12 +1426,16 @@
1460
1426
  const appliedCountBefore = applied.length;
1461
1427
  const maxAttempts = 200;
1462
1428
  for (let attempt = 0; attempt < maxAttempts; attempt += 1) {
1463
- const node = findLastTrackedChangeNodeForPath(file.path);
1429
+ if (applied.some(entry => entry.trackedChange?.path === file.path
1430
+ && ['overleaf-review-reject', 'overleaf-review-reject-sweep'].includes(entry.result?.method))
1431
+ && getActiveFilePath() === file.path && readActiveEditorText() === file.content
1432
+ && (await verifyReviewFileContent(file, applied)).ok) return { ok: true };
1433
+ const selection = await waitForRejectableTrackedChange(file.path);
1434
+ selection.assertStable();
1435
+ const node = selection.node;
1464
1436
  if (!node) {
1465
- const verified = await verifyActiveEditorText(file.content, file.path, 800);
1466
- if (verified.ok) {
1467
- return { ok: true };
1468
- }
1437
+ const verified = await verifyReviewFileContent(file, applied);
1438
+ if (verified.ok) return { ok: true };
1469
1439
  const rejectedAnyForFile = applied.length > appliedCountBefore;
1470
1440
  return {
1471
1441
  ...verified,
@@ -1476,7 +1446,7 @@
1476
1446
  };
1477
1447
  }
1478
1448
 
1479
- const rejectControl = findRejectControlForTrackedChangeNode(node);
1449
+ const rejectControl = isTrackedChangeNodeInNativeScope(node, file.path) ? findRejectControlForTrackedChangeNode(node) : null;
1480
1450
  if (!rejectControl) {
1481
1451
  return {
1482
1452
  ok: false,
@@ -1487,6 +1457,7 @@
1487
1457
 
1488
1458
  const trackedChange = trackedChangeRefFromNode(node, file.path);
1489
1459
  const beforeText = readActiveEditorText();
1460
+ selection.assertStable();
1490
1461
  clickNode(rejectControl);
1491
1462
  await waitForTrackedChangeRejectProgress(trackedChange, file.path, beforeText, 1200);
1492
1463
  applied.push({
@@ -1532,13 +1503,10 @@
1532
1503
  return false;
1533
1504
  }
1534
1505
 
1535
-
1536
-
1537
-
1538
1506
  const captureModule = window?.CodexOverleafTrackedChangeCapture
1539
1507
  || (typeof module === 'object' && module.exports ? require('./trackedChangeCapture.js') : null);
1540
1508
  const { collectTrackedChangeNodes, trackedChangeRefFromNode, collectTrackedChangeRefsForPaths,
1541
- waitForTrackedChangeDiff, prepareTrackedChangeCapture, prepareTrackedChangeReview, prepareUntrackedUndo, getTrackedChangeCaptureStatus,
1509
+ waitForTrackedChangeDiff, prepareTrackedChangeCapture, prepareTrackedChangeReview, prepareUntrackedUndo, getTrackedChangeCaptureStatus, isTrackedChangeNodeInNativeScope, rememberTrackedChangeNodeContext,
1542
1510
  captureTrackedWrite, reconcileTrackedChangeCapture } = captureModule.create(deps);
1543
1511
 
1544
1512
  function orderTrackedChangesForReviewAction(refs = []) {
@@ -1561,9 +1529,56 @@
1561
1529
  return nodes[0] || null;
1562
1530
  }
1563
1531
 
1564
- function findLastTrackedChangeNodeForPath(path) {
1565
- const nodes = findTrackedChangeNodesForPath(path);
1566
- return nodes[nodes.length - 1] || null;
1532
+ async function createReviewScopeGuard(path, expectedText) {
1533
+ const owner = nativeRejectPlan?.files?.find(file => file.path === path);
1534
+ const changed = () => {
1535
+ const failure = nativeReviewBlocked(path, 'native_review_scope_changed').skipped[0].result;
1536
+ throw Object.assign(new Error(failure.reason), { reviewFailure: failure });
1537
+ };
1538
+ let snapshot = prepareTrackedChangeCapture(path);
1539
+ const until = Date.now() + 1500;
1540
+ while ((owner || snapshot.source === 'native') && (snapshot.source !== 'native' || !snapshot.ready) && Date.now() < until) {
1541
+ if (getActiveFilePath() !== path || (expectedText !== undefined && readActiveEditorText() !== expectedText)) changed();
1542
+ await delay(Math.min(60, until - Date.now()));
1543
+ snapshot = prepareTrackedChangeCapture(path);
1544
+ }
1545
+ if (owner || snapshot.source === 'native') {
1546
+ const owned = (owner?.scope.refs || []).map(ref => JSON.stringify([ref.id, ref.kind, ref.textHash]));
1547
+ if (!snapshot.ready || !snapshot.refs.length || !owner || snapshot.nativeDocId !== owner.nativeDocId || snapshot.refs.some(ref => {
1548
+ const index = owned.indexOf(JSON.stringify([ref.id, ref.kind, ref.textHash]));
1549
+ if (index < 0) return true;
1550
+ owned.splice(index, 1); return false;
1551
+ })) changed();
1552
+ }
1553
+ const beforeText = expectedText ?? readActiveEditorText();
1554
+ const key = value => value.source !== 'native' ? '' : !value.ready ? null
1555
+ : JSON.stringify([value.nativeDocId, value.refs.map(ref => ref.key).sort()]);
1556
+ const beforeScope = key(snapshot);
1557
+ return () => {
1558
+ if (getActiveFilePath() !== path || readActiveEditorText() !== beforeText
1559
+ || key(prepareTrackedChangeCapture(path)) !== beforeScope) changed();
1560
+ };
1561
+ }
1562
+ async function waitForRejectableTrackedChange(path) {
1563
+ const until = Date.now() + 1500;
1564
+ const select = () => {
1565
+ const nodes = findTrackedChangeNodesForPath(path).reverse();
1566
+ for (const node of nodes) {
1567
+ if (!isTrackedChangeNodeInNativeScope(node, path)) continue;
1568
+ const control = findRejectControlForTrackedChangeNode(node);
1569
+ if (control) return { node, control };
1570
+ }
1571
+ return { node: nodes[0] || null, control: null };
1572
+ };
1573
+ let selection = select();
1574
+ if (!selection.node) return { ...selection, assertStable() {} };
1575
+ const assertStable = await createReviewScopeGuard(path);
1576
+ selection = select();
1577
+ while (!selection.control && Date.now() < until) {
1578
+ assertStable(); await delay(Math.min(60, until - Date.now())); assertStable();
1579
+ selection = select();
1580
+ }
1581
+ return { ...selection, assertStable };
1567
1582
  }
1568
1583
 
1569
1584
  function findTrackedChangeNodesForPath(path) {
@@ -1576,6 +1591,7 @@
1576
1591
  }
1577
1592
 
1578
1593
  function findRejectControlForTrackedChangeNode(node) {
1594
+ if (!node || node.isConnected === false) return null;
1579
1595
  const scopes = [];
1580
1596
  let current = node;
1581
1597
  for (let index = 0; current && index < 6; index += 1) {
@@ -1602,17 +1618,19 @@
1602
1618
  }
1603
1619
 
1604
1620
  function isRejectTrackedChangeControl(node) {
1605
- if (!node || node.disabled) {
1621
+ if (!node || node.disabled || node.isConnected === false) {
1606
1622
  return false;
1607
1623
  }
1608
1624
  if (/^(true|disabled)$/i.test(node.getAttribute?.('aria-disabled') || '')) {
1609
1625
  return false;
1610
1626
  }
1611
- const signal = normalizeReviewingSignalText(readNodeSignalText(node));
1612
- if (/\b(?:accept|approve|apply|resolve|接受|批准|应用)\b/i.test(signal)) {
1627
+ const signal = normalizeReviewingSignalText([readNodeSignalText(node),
1628
+ ...Array.from(node.querySelectorAll?.('.visually-hidden') || [])
1629
+ .map(label => label.textContent || '')].join(' '));
1630
+ if (/\b(?:accept|approve|apply|resolve)\b|接受|批准|应用/i.test(signal)) {
1613
1631
  return false;
1614
1632
  }
1615
- return /\b(?:reject|decline|discard|revert|拒绝|丢弃|还原)\b/i.test(signal);
1633
+ return /\b(?:reject|decline|discard|revert)\b|拒绝|丢弃|还原/i.test(signal);
1616
1634
  }
1617
1635
  return {
1618
1636
  acceptTrackedChanges,