codex-overleaf-link 2.5.1 → 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 (93) hide show
  1. package/README.md +149 -398
  2. package/README.zh-CN.md +153 -404
  3. package/extension/bootstrap/manifest.template.json +1 -1
  4. package/extension/src/background.js +78 -3
  5. package/extension/src/backgroundSharedSessions.js +55 -2
  6. package/extension/src/backgroundUpdateCoordinator.js +128 -12
  7. package/extension/src/content/activeTurnControl.js +164 -7
  8. package/extension/src/content/composerAttachments.js +11 -2
  9. package/extension/src/content/composerPanel.js +92 -12
  10. package/extension/src/content/contentRuntime.js +920 -276
  11. package/extension/src/content/contextTray.js +8 -2
  12. package/extension/src/content/contextUsageView.js +267 -0
  13. package/extension/src/content/diagnosticsPanel.js +18 -0
  14. package/extension/src/content/generated/content.bundle.js +4496 -924
  15. package/extension/src/content/generated/content.bundle.meta.json +6 -3
  16. package/extension/src/content/legacyGlobalRegistry.js +4 -0
  17. package/extension/src/content/localSkillsPanel.js +2 -2
  18. package/extension/src/content/markdownDomRenderer.js +5 -3
  19. package/extension/src/content/markdownText.js +42 -5
  20. package/extension/src/content/modelPicker.js +1 -0
  21. package/extension/src/content/nativeCompatibilityController.js +29 -10
  22. package/extension/src/content/otWarmMirror.js +1 -1
  23. package/extension/src/content/pageBridgeClient.js +2 -2
  24. package/extension/src/content/panelMaintenance.js +6 -2
  25. package/extension/src/content/panelRenderer.js +51 -27
  26. package/extension/src/content/pendingInputView.js +1 -1
  27. package/extension/src/content/projectSettingsCoordinator.js +30 -6
  28. package/extension/src/content/providerSettingsDialog.js +2 -1
  29. package/extension/src/content/recentProjects.js +4 -3
  30. package/extension/src/content/runActivitySummary.js +122 -14
  31. package/extension/src/content/runCompletionMeta.js +194 -6
  32. package/extension/src/content/runController.js +2 -1
  33. package/extension/src/content/runFailureNotice.js +8 -1
  34. package/extension/src/content/runPresence.js +18 -8
  35. package/extension/src/content/runQueueScheduler.js +9 -5
  36. package/extension/src/content/runRecoveryActions.js +2 -1
  37. package/extension/src/content/runResultActions.js +22 -20
  38. package/extension/src/content/runReviewController.js +94 -35
  39. package/extension/src/content/runTimelineView.js +224 -25
  40. package/extension/src/content/scopedPersistencePanelState.js +92 -23
  41. package/extension/src/content/sensitivePreflight.js +14 -1
  42. package/extension/src/content/sessionManager.js +49 -3
  43. package/extension/src/content/sessionMenuView.js +54 -18
  44. package/extension/src/content/sessionPersistence.js +25 -5
  45. package/extension/src/content/settingsPanel.js +15 -2
  46. package/extension/src/content/settingsWorkbench.js +127 -89
  47. package/extension/src/content/sharedSessionViewSync.js +4 -3
  48. package/extension/src/content/subagentActivityView.js +23 -7
  49. package/extension/src/content/updateNotice.js +60 -6
  50. package/extension/src/content/writebackOrchestrator.js +35 -22
  51. package/extension/src/page/compileBridge.js +193 -180
  52. package/extension/src/page/overleafCapabilities.js +4 -1
  53. package/extension/src/page/textCoordinates.js +17 -1
  54. package/extension/src/page/textFileCreator.js +9 -1
  55. package/extension/src/page/writebackRouter.js +24 -77
  56. package/extension/src/pageBridge.js +107 -66
  57. package/extension/src/popup.js +1 -1
  58. package/extension/src/shared/agentTranscript.js +350 -17
  59. package/extension/src/shared/auditRecords.js +2 -0
  60. package/extension/src/shared/compatibility.js +1 -1
  61. package/extension/src/shared/compileAdapter.js +58 -17
  62. package/extension/src/shared/composerAttachmentDraft.js +6 -1
  63. package/extension/src/shared/contextUsage.js +125 -0
  64. package/extension/src/shared/failureReasons.js +42 -3
  65. package/extension/src/shared/i18n.js +299 -29
  66. package/extension/src/shared/lineReferences.js +38 -31
  67. package/extension/src/shared/pageRpcContract.js +1 -1
  68. package/extension/src/shared/panelIcons.js +39 -0
  69. package/extension/src/shared/reviewing.js +130 -1
  70. package/extension/src/shared/runActivityModel.js +70 -4
  71. package/extension/src/shared/runExecutionSnapshot.js +13 -0
  72. package/extension/src/shared/sessionState.js +63 -4
  73. package/extension/src/shared/sessionTitle.js +14 -1
  74. package/extension/src/shared/sharedSessionBridge.js +81 -13
  75. package/extension/src/shared/staleGuard.js +72 -63
  76. package/extension/src/shared/storageDb.js +35 -3
  77. package/extension/src/shared/storageMigration.js +7 -1
  78. package/extension/src/shared/storageRunActions.js +62 -8
  79. package/extension/src/shared/undoOperations.js +126 -28
  80. package/extension/src/shared/updateRuntimeIdentity.js +16 -2
  81. package/extension/src/shared/writebackSettlement.js +21 -3
  82. package/extension/styles/panel.css +1106 -490
  83. package/native-host/src/bootstrapUpdate.js +52 -16
  84. package/native-host/src/chatBridgeRequest.js +60 -6
  85. package/native-host/src/chatBridgeResponse.js +10 -1
  86. package/native-host/src/chatCompletionsBridge.js +29 -2
  87. package/native-host/src/codexPromptAssembly.js +86 -118
  88. package/native-host/src/codexSessionRunner.js +126 -17
  89. package/native-host/src/providerContextError.js +20 -0
  90. package/native-host/src/taskRunnerRuntime.js +18 -6
  91. package/native-host/src/textPatch.js +473 -377
  92. package/native-host/src/updateManager.js +120 -15
  93. package/package.json +5 -8
@@ -367,12 +367,12 @@
367
367
  const trackReviewingChanges = options.requireReviewing === true && hasOperations;
368
368
  let reviewingPolicy = null;
369
369
  if (options.requireReviewing === true && hasOperations) {
370
- const reviewing = await ensureReviewing({ waitMs: 1800 });
370
+ const reviewing = await ensureReviewing({ waitMs: 1800, runProjectId: options.runProjectId });
371
371
  if (!reviewing.ok) {
372
372
  return buildReviewingRequiredBlockedResult(operations, reviewing);
373
373
  }
374
374
  } else if (options.requireEditing === true && hasOperations) {
375
- const editing = await ensureEditing({ waitMs: 1800 });
375
+ const editing = await ensureEditing({ waitMs: 1800, runProjectId: options.runProjectId });
376
376
  if (!editing.ok) {
377
377
  return buildEditingRequiredBlockedResult(operations, editing);
378
378
  }
@@ -676,13 +676,13 @@
676
676
  return decorated;
677
677
  }
678
678
  current = ready.text;
679
- freshness = { ok: true };
679
+ freshness = ready.freshness;
680
680
  }
681
681
 
682
682
  let nextContent = null;
683
683
  let preparedPatches;
684
684
  if (Array.isArray(operation.patches) && operation.patches.length) {
685
- preparedPatches = window.CodexOverleafStaleGuard?.prepareOperationPatches(operation, current, options.baseFileLookup)
685
+ preparedPatches = freshness.preparedPatches || window.CodexOverleafStaleGuard?.prepareOperationPatches(operation, current, options.baseFileLookup)
686
686
  || { ok: true, patches: operation.patches };
687
687
  const patched = preparedPatches.ok ? applyTextPatches(current, preparedPatches.patches) : preparedPatches;
688
688
  if (!patched.ok) {
@@ -736,12 +736,6 @@
736
736
  operationType: 'undo', changedDocument: false,
737
737
  userMessage: 'The project identity guard is unavailable; the undo write was not applied.' }) };
738
738
  if (blocked) return blocked.skipped?.[0]?.result || blocked;
739
- if (getActiveFilePath() !== operation.path || readActiveEditorText() !== current) {
740
- const reason = 'The editor changed while confirming Editing mode; the undo write was not applied.';
741
- return { ok: false, code: 'stale_source_changed', reason,
742
- failure: buildPageFailure('stale_source_changed', { file: operation.path,
743
- operationType: 'undo', changedDocument: false, userMessage: reason }) };
744
- }
745
739
  if (!isEditingConfirmedForNoTraceUndo(getReviewingState({})))
746
740
  return buildNoTraceUndoBlockedResult([operation], { ok: false, code: 'editing_not_confirmed',
747
741
  reason: 'Overleaf left Editing mode before the undo write.' }).skipped[0].result;
@@ -749,6 +743,14 @@
749
743
 
750
744
  const finalReviewBlock = options.reviewGuard?.();
751
745
  if (finalReviewBlock) return finalReviewBlock;
746
+ // Read and dispatch synchronously against the same document. Navigation,
747
+ // freshness polling and Undo mode/project checks can all have awaited.
748
+ if (getActiveFilePath() !== operation.path || readActiveEditorText() !== current) {
749
+ const reason = 'The editor changed before the edit could be applied; the document was not overwritten.';
750
+ return { ok: false, code: 'stale_source_changed', reason,
751
+ failure: buildPageFailure('stale_source_changed', { file: operation.path,
752
+ operationType: options.noTraceUndo ? 'undo' : 'edit', changedDocument: false, userMessage: reason }) };
753
+ }
752
754
  const result = Array.isArray(operation.patches) && operation.patches.length
753
755
  ? replaceActiveEditorPatches(preparedPatches.patches, nextContent)
754
756
  : replaceActiveEditorText(nextContent);
@@ -827,6 +829,7 @@
827
829
  }),
828
830
  verified: true,
829
831
  verifiedContent: nextContent,
832
+ ...(preparedPatches ? { appliedPatches: preparedPatches.patches } : {}),
830
833
  trackedChanges: mergeTrackedChangeRefs(trackedChanges)
831
834
  };
832
835
  }
@@ -840,6 +843,7 @@
840
843
  ...result,
841
844
  verified: true,
842
845
  verifiedContent: nextContent,
846
+ ...(preparedPatches ? { appliedPatches: preparedPatches.patches } : {}),
843
847
  trackedChangeCapture,
844
848
  trackedChanges: mergeTrackedChangeRefs(trackedChanges)
845
849
  };
@@ -970,35 +974,17 @@
970
974
  lastText = readActiveEditorText();
971
975
  const activeFileMatches = lastActivePath === filePath;
972
976
  const identityChanged = Boolean(previousEditorIdentity) && activeEditorIdentityChanged(previousEditorIdentity);
973
- const baseComparison = compareEditorTextToBase(filePath, lastText, baseFileLookup);
974
- const baseMatches = baseComparison.matches;
975
977
  const settledLongEnough = Date.now() - startedAt >= minSettleMs;
976
978
  const contentChangedFromPrevious = !options.previousSignature
977
979
  || contentSignature(lastText) !== options.previousSignature;
978
- // Switch confirmation must not depend on any single fragile signal
979
- // (v1.6: identity comparison proved unreliable against newer Overleaf
980
- // view lifecycles, which made every write to a non-active file time
981
- // out as target_editor_not_ready). Acceptors, strongest first:
982
- // 1. editor identity visibly changed (fast path when it works);
983
- // 2. the editor shows EXACTLY the target file's expected base —
984
- // the strongest possible proof the right document is loaded;
985
- // 3. same-file reopen settled with unchanged content;
986
- // 4. base UNKNOWN only: the content moved off the previous document
987
- // and the settle window elapsed. When the base IS known, content
988
- // must match it (acceptor 2) — a weakly-anchored patch (e.g. an
989
- // insert into an empty file, expected:'') would otherwise anchor
990
- // trivially into a wrong-but-different document.
980
+ // Confirm navigation independently of source freshness. A stale target
981
+ // can be correctly open; identical baseline bytes alone cannot prove a
982
+ // switch. Content movement remains the fallback for editor lifecycles
983
+ // whose document identity cannot be observed reliably.
991
984
  const switchConfirmed = identityChanged
992
- || baseMatches
993
- || (options.initialActivePath === filePath
994
- && options.previousSignature
995
- && contentSignature(lastText) === options.previousSignature
996
- && settledLongEnough)
997
- || (!baseComparison.known && contentChangedFromPrevious && settledLongEnough);
998
- const contentNoLongerLooksLikePreviousDocument = contentChangedFromPrevious
999
- || baseMatches
1000
- || options.initialActivePath === filePath;
1001
- if (activeFileMatches && switchConfirmed && settledLongEnough && contentNoLongerLooksLikePreviousDocument) {
985
+ || options.initialActivePath === filePath
986
+ || (contentChangedFromPrevious && settledLongEnough);
987
+ if (activeFileMatches && switchConfirmed && settledLongEnough) {
1002
988
  return {
1003
989
  ok: true,
1004
990
  text: lastText
@@ -1074,47 +1060,6 @@
1074
1060
  };
1075
1061
  }
1076
1062
 
1077
- function editorContentMatchesBase(filePath, content, baseFileLookup) {
1078
- return compareEditorTextToBase(filePath, content, baseFileLookup).matches;
1079
- }
1080
-
1081
- function compareEditorTextToBase(filePath, content, baseFileLookup) {
1082
- const normalizedPath = normalizeSafeProjectPath(filePath);
1083
- const current = normalizeEditorTextForComparison(content);
1084
- if (!baseFileLookup || !normalizedPath || !baseFileLookup.has(normalizedPath)) {
1085
- return {
1086
- known: false,
1087
- hasContent: false,
1088
- matches: false,
1089
- baseLength: null,
1090
- currentLength: current.length
1091
- };
1092
- }
1093
- const base = normalizeEditorTextForComparison(baseFileLookup.get(normalizedPath));
1094
- const matches = current === base;
1095
- if (!matches && base.trim().length > 0) {
1096
- console.warn('[codex-overleaf] editorContentMatchesBase MISMATCH', filePath,
1097
- 'base.length:', base.length, 'current.length:', current.length,
1098
- 'base[0..80]:', JSON.stringify(base.slice(0, 80)),
1099
- 'current[0..80]:', JSON.stringify(current.slice(0, 80)));
1100
- }
1101
- return {
1102
- known: true,
1103
- hasContent: base.trim().length > 0,
1104
- matches,
1105
- baseLength: base.length,
1106
- currentLength: current.length
1107
- };
1108
- }
1109
-
1110
- function hasBaseFileContent(filePath, baseFileLookup) {
1111
- if (!baseFileLookup || !filePath || !baseFileLookup.has(filePath)) {
1112
- return false;
1113
- }
1114
- const content = baseFileLookup.get(filePath);
1115
- return typeof content === 'string' && content.trim().length > 0;
1116
- }
1117
-
1118
1063
  function normalizeEditorTextForComparison(value) {
1119
1064
  const normalized = String(value ?? '').replace(/\r\n/g, '\n');
1120
1065
  return normalized.trim().length === 0 ? '' : normalized;
@@ -1136,7 +1081,8 @@
1136
1081
  if (freshness.ok) {
1137
1082
  return {
1138
1083
  ok: true,
1139
- text
1084
+ text,
1085
+ freshness
1140
1086
  };
1141
1087
  }
1142
1088
  await delay(60);
@@ -1895,6 +1841,7 @@
1895
1841
  ok: false,
1896
1842
  code: reviewing?.code || 'reviewing_not_enabled',
1897
1843
  reason: reviewing?.reason || 'Overleaf Reviewing/Track Changes was not confirmed before writing. Codex did not change this file.',
1844
+ evidence: reviewing?.evidence, failure: reviewing?.failure,
1898
1845
  reviewing: reviewing?.reviewing || null
1899
1846
  }
1900
1847
  })),
@@ -73,6 +73,7 @@
73
73
  const textFileCreator = requirePageModule('CodexOverleafTextFileCreator').create({
74
74
  window, document, treeOperations, snapshotRouter,
75
75
  collectElements, ensureEditing, ensureReviewing, readActiveEditorText, readWriteCancellationSequence,
76
+ findUpgradeModal: () => window.CodexOverleafReviewing.findUpgradeModal(document),
76
77
  uploadHelpers: requirePageModule('CodexOverleafBinaryAssetUploader')
77
78
  });
78
79
  binaryAssetUploader = requirePageModule('CodexOverleafBinaryAssetUploader').create({
@@ -399,6 +400,11 @@
399
400
  fallbackUserMessage: 'Target file is active but the editor was not ready before timeout.',
400
401
  fallbackNextAction: 'Wait for Overleaf to finish loading, then retry.'
401
402
  },
403
+ reviewing_unavailable: {
404
+ stage: 'reviewing', severity: 'blocked', defaultRetryable: false,
405
+ fallbackUserMessage: "Track Changes is unavailable for this project. It depends on the project owner's Overleaf plan.",
406
+ fallbackNextAction: 'Turn off Track in the composer to write without tracked changes. Codex still records each change so you can undo it.'
407
+ },
402
408
  reviewing_state_unknown: {
403
409
  stage: 'reviewing', severity: 'blocked', defaultRetryable: true,
404
410
  fallbackUserMessage: 'Codex could not determine whether Reviewing/Track Changes is enabled.',
@@ -488,6 +494,8 @@
488
494
  findFileTreeManager,
489
495
  findHistoryObject,
490
496
  findReviewingActivationControl,
497
+ findUpgradeModal: () => window.CodexOverleafReviewing.findUpgradeModal(document),
498
+ reviewingConfirmed: isReviewingConfirmedForWrite(getReviewingState()),
491
499
  getCodeMirrorEditorView,
492
500
  getDeepActiveElement,
493
501
  isInsideCodexPanel,
@@ -497,7 +505,7 @@
497
505
 
498
506
  async function ensureReviewing(params = {}) {
499
507
  const initial = getReviewingState(params);
500
- if (isReviewingConfirmedForWrite(initial)) {
508
+ if (isReviewingConfirmedForWrite(initial) && !window.CodexOverleafReviewing.findUpgradeModal(document)) {
501
509
  return {
502
510
  ok: true,
503
511
  activated: false,
@@ -506,6 +514,7 @@
506
514
  }
507
515
 
508
516
  const switched = await toggleReviewingMode(true, params);
517
+ if (switched.code === 'reviewing_unavailable') return { ...switched, reason: switched.failure.userMessage };
509
518
  if (switched.ok) {
510
519
  return {
511
520
  ok: true,
@@ -553,6 +562,7 @@
553
562
  }
554
563
 
555
564
  const switched = await toggleReviewingMode(false, params);
565
+ if (switched.code === 'reviewing_unavailable') return { ...switched, reason: switched.failure.userMessage };
556
566
  if (switched.ok) {
557
567
  return {
558
568
  ok: true,
@@ -743,9 +753,16 @@
743
753
  const activeFileMatches = getActiveFilePath() === filePath;
744
754
  const text = readActiveEditorText();
745
755
  lastText = text;
756
+ const currentEditorIdentity = getActiveEditorIdentity();
757
+ // A file-tree click can reconfigure or blur the old editor before the
758
+ // target document loads. A new view/state alone does not prove a switch.
759
+ const documentChanged = currentEditorIdentity?.type === 'codemirror-view'
760
+ && previousEditorIdentity?.type === 'codemirror-view'
761
+ ? currentEditorIdentity.doc !== previousEditorIdentity.doc
762
+ : Boolean(currentEditorIdentity) && activeEditorIdentityChanged(previousEditorIdentity);
746
763
  if (activeFileMatches
747
764
  && window.CodexOverleafProjectFiles.isUsableProjectFileContent(text)
748
- && activeEditorIdentityChanged(previousEditorIdentity)) {
765
+ && documentChanged) {
749
766
  return {
750
767
  ok: true,
751
768
  text
@@ -871,6 +888,7 @@
871
888
  }
872
889
 
873
890
  const switched = await toggleReviewingMode(enabled, params);
891
+ if (switched.code === 'reviewing_unavailable') return { ...switched, reason: switched.failure.userMessage };
874
892
  if (switched.ok) {
875
893
  return {
876
894
  ok: true,
@@ -917,67 +935,23 @@
917
935
  }
918
936
 
919
937
  async function toggleReviewingMode(enabled, params = {}) {
920
- const attemptedNodes = uniqueNodes([
921
- findReviewingActivationControl(),
922
- findReviewingModeMenuControl()
923
- ].filter(Boolean));
924
-
925
- for (const control of attemptedNodes) {
926
- clickNode(control);
927
- let after = await waitForReviewingEnabled(enabled, {
928
- ...params,
929
- waitMs: Math.min(resolveWaitMs(params), 180)
930
- });
931
- if (isReviewingTargetStateConfirmed(enabled, after)) {
932
- return {
933
- ok: true,
934
- attempted: true,
935
- reviewing: after.reviewing
936
- };
938
+ const sequence = readWriteCancellationSequence();
939
+ const assertCurrent = () => {
940
+ if (sequence !== readWriteCancellationSequence()) {
941
+ throw Object.assign(new Error('Writeback was cancelled.'), { code: 'codex_cancelled' });
937
942
  }
938
-
939
- const modeOption = findReviewingModeOption(enabled, { requireMenuOption: true });
940
- if (modeOption) {
941
- clickNode(modeOption);
942
- after = await waitForReviewingEnabled(enabled, params);
943
- if (isReviewingTargetStateConfirmed(enabled, after)) {
944
- return {
945
- ok: true,
946
- attempted: true,
947
- reviewing: after.reviewing
948
- };
949
- }
943
+ if (params.runProjectId && writeGuard.getEditorProjectIdPageSide(params.runProjectId) !== params.runProjectId) {
944
+ throw Object.assign(new Error('The active project changed.'), { code: 'aborted_project_changed' });
950
945
  }
951
- }
952
-
953
- const visibleModeOption = findReviewingModeOption(enabled, { requireMenuOption: true });
954
- if (visibleModeOption) {
955
- clickNode(visibleModeOption);
956
- const after = await waitForReviewingEnabled(enabled, params);
957
- return {
958
- ok: isReviewingTargetStateConfirmed(enabled, after),
959
- attempted: true,
960
- reviewing: after.reviewing
961
- };
962
- }
963
-
964
- const after = getReviewingState(params);
965
- return {
966
- ok: false,
967
- attempted: attemptedNodes.length > 0,
968
- reviewing: after.reviewing
969
946
  };
970
- }
971
-
972
- async function waitForReviewingEnabled(enabled, params = {}) {
973
- const waitMs = resolveWaitMs(params);
974
- const deadline = Date.now() + Math.max(0, waitMs);
975
- let current = getReviewingState(params);
976
- while (!isReviewingTargetStateConfirmed(enabled, current) && Date.now() < deadline) {
977
- await delay(120);
978
- current = getReviewingState(params);
979
- }
980
- return current;
947
+ return window.CodexOverleafReviewing.toggleMode(enabled, params, {
948
+ document, assertCurrent, delay,
949
+ getState: () => getReviewingState(params), isConfirmed: isReviewingTargetStateConfirmed,
950
+ click: clickNode,
951
+ findControls: () => findReviewingModeControls(enabled),
952
+ findOption: value => findReviewingModeOption(value, { requireMenuOption: true }),
953
+ buildFailure: buildPageBridgeFailure
954
+ });
981
955
  }
982
956
 
983
957
  function resolveWaitMs(params = {}) {
@@ -1109,7 +1083,72 @@
1109
1083
  return String(value || '').replace(/\s+/g, ' ').trim();
1110
1084
  }
1111
1085
 
1086
+ function isReviewingModeNodeAvailable(node) {
1087
+ if (!node || node.ownerDocument !== document || !node.isConnected || node.disabled
1088
+ || node.getAttribute?.('aria-disabled') === 'true' || !node.getClientRects?.().length) return false;
1089
+ for (let ancestor = node; ancestor; ancestor = ancestor.parentElement || ancestor.getRootNode?.().host) {
1090
+ if (isInsideCodexPanel(ancestor) || ancestor.closest?.(
1091
+ '.review-panel, .review-panel-container, .review-panel-header-slot, .review-sidebar, #review-panel, [data-testid="review-panel"]'
1092
+ ) || ancestor.hidden || ancestor.getAttribute?.('aria-hidden') === 'true' || ancestor.hasAttribute?.('inert')) return false;
1093
+ const style = window.getComputedStyle?.(ancestor);
1094
+ if (style?.display === 'none' || style?.visibility === 'hidden' || style?.visibility === 'collapse'
1095
+ || style?.opacity === '0') return false;
1096
+ }
1097
+ return true;
1098
+ }
1099
+
1100
+ function findReviewingToolbarControl() {
1101
+ return Array.from(document.querySelectorAll(
1102
+ '.toolbar-editor .review-mode-switcher > .review-mode-switcher-toggle-button'
1103
+ )).find(isReviewingModeNodeAvailable) || null;
1104
+ }
1105
+
1106
+ function findReviewingModeControls(enabled) {
1107
+ const toolbarControl = findReviewingToolbarControl();
1108
+ return toolbarControl ? [toolbarControl]
1109
+ : [findReviewingActivationControl(enabled), findReviewingModeMenuControl()];
1110
+ }
1111
+
1112
+ function findReviewingToolbarMenu(control) {
1113
+ const controlledIds = (control.getAttribute('aria-controls') || '').split(/\s+/).filter(Boolean);
1114
+ for (const id of controlledIds) {
1115
+ const menu = document.getElementById(id);
1116
+ if (isReviewingModeNodeAvailable(menu)) return menu;
1117
+ }
1118
+ if (control.getAttribute('aria-expanded') !== 'true') return null;
1119
+ const selector = '[role="menu"], [role="listbox"], .dropdown-menu';
1120
+ // Current Overleaf keeps the menu beside the toggle, inside this switcher.
1121
+ const localMenu = Array.from(control.parentElement?.querySelectorAll(selector) || [])
1122
+ .find(node => node.closest('.review-mode-switcher') === control.parentElement && isReviewingModeNodeAvailable(node));
1123
+ if (localMenu) return localMenu;
1124
+ // A portalled menu must still name this switch, rather than an unrelated dropdown.
1125
+ return control.id ? Array.from(document.querySelectorAll(selector)).find(node =>
1126
+ (node.getAttribute('aria-labelledby') || '').split(/\s+/).includes(control.id)
1127
+ && isReviewingModeNodeAvailable(node)) || null : null;
1128
+ }
1129
+
1130
+ function isReviewingToolbarModeOption(node, enabled) {
1131
+ const mode = enabled ? 'reviewing' : 'editing';
1132
+ const values = [node.value, node.getAttribute('value'), node.getAttribute('data-value'),
1133
+ node.getAttribute('label'), node.getAttribute('aria-label'), ...String(node.className || '').split(/\s+/)];
1134
+ if (values.some(value => normalizeReviewingSignalText(value).toLowerCase() === mode)) return true;
1135
+ // Read a label separately from icon text and the option's explanatory subtitle.
1136
+ return [node, ...node.querySelectorAll('*')].some(label => {
1137
+ if (label.closest('[aria-hidden="true"]')) return false;
1138
+ const directText = Array.from(label.childNodes).filter(child => child.nodeType === 3)
1139
+ .map(child => child.textContent).join(' ');
1140
+ return [label.textContent, directText].some(value => normalizeReviewingSignalText(value).toLowerCase() === mode);
1141
+ });
1142
+ }
1143
+
1112
1144
  function findReviewingModeOption(enabled, options = {}) {
1145
+ const toolbarControl = findReviewingToolbarControl();
1146
+ if (toolbarControl) {
1147
+ const menu = findReviewingToolbarMenu(toolbarControl);
1148
+ return Array.from(menu?.querySelectorAll(
1149
+ '[role="menuitem"], [role="menuitemradio"], [role="option"], .dropdown-item, button, a, option'
1150
+ ) || []).find(node => isReviewingModeNodeAvailable(node) && isReviewingToolbarModeOption(node, enabled)) || null;
1151
+ }
1113
1152
  const candidates = collectReviewingModeOptionNodes()
1114
1153
  .filter(node => isReviewingModeOptionCandidate(node, enabled, options))
1115
1154
  .sort((left, right) => scoreReviewingModeOption(right, enabled) - scoreReviewingModeOption(left, enabled));
@@ -1145,7 +1184,7 @@
1145
1184
  }
1146
1185
 
1147
1186
  function isReviewingModeMenuControlCandidate(node) {
1148
- if (!node || node.disabled || node.getAttribute?.('aria-disabled') === 'true') {
1187
+ if (!isReviewingModeNodeAvailable(node) || isMenuLikeModeOption(node)) {
1149
1188
  return false;
1150
1189
  }
1151
1190
  const text = readNodeSignalText(node);
@@ -1198,7 +1237,7 @@
1198
1237
  }
1199
1238
 
1200
1239
  function isReviewingModeOptionCandidate(node, enabled, options = {}) {
1201
- if (!node || node.disabled || node.getAttribute?.('aria-disabled') === 'true') {
1240
+ if (!isReviewingModeNodeAvailable(node)) {
1202
1241
  return false;
1203
1242
  }
1204
1243
  if (options.requireMenuOption && !isMenuLikeModeOption(node)) {
@@ -1244,10 +1283,10 @@
1244
1283
  && !/\bsyntax highlighting\b/i.test(text);
1245
1284
  }
1246
1285
 
1247
- function findReviewingActivationControl() {
1286
+ function findReviewingActivationControl(enabled = true) {
1248
1287
  const nodes = collectReviewingControlNodes();
1249
1288
  const candidates = nodes
1250
- .filter(node => isReviewingActivationCandidate(node))
1289
+ .filter(node => isReviewingActivationCandidate(node, enabled))
1251
1290
  .sort((left, right) => scoreReviewingControl(right) - scoreReviewingControl(left));
1252
1291
  return candidates[0] || null;
1253
1292
  }
@@ -1271,10 +1310,12 @@
1271
1310
  ]).slice(0, 1500);
1272
1311
  }
1273
1312
 
1274
- function isReviewingActivationCandidate(node) {
1275
- if (!node || node.disabled || node.getAttribute?.('aria-disabled') === 'true') {
1313
+ function isReviewingActivationCandidate(node, enabled) {
1314
+ if (!isReviewingModeNodeAvailable(node) || isMenuLikeModeOption(node)) {
1276
1315
  return false;
1277
1316
  }
1317
+ // A standalone Reviewing command enables tracking; it cannot open Editing.
1318
+ if (!enabled && !node.getAttribute?.('aria-haspopup') && !node.hasAttribute?.('aria-expanded')) return false;
1278
1319
  const text = readNodeSignalText(node);
1279
1320
  return /\breviewing\b|\btrack(?:ed)? changes?\b|\bsuggest(?:ing|ions?)\b/i.test(text)
1280
1321
  && !/\breview panel\b/i.test(text);
@@ -708,7 +708,7 @@ async function sendConsentAction(action) {
708
708
  const code = String(state.code || '');
709
709
  if (code === 'update_recovery_pending') return utx('The update result is being checked. Recovery has not been confirmed.', '正在确认更新结果,恢复状态尚未确认。');
710
710
  if (['update_bootstrap_health_timeout', 'update_bootstrap_reload_failed'].includes(code)) return utx('The updated startup components did not finish restarting and confirming. Check recovery before retrying.', '更新后的启动组件未能完成重启和确认,请先检查恢复状态。');
711
- if (['update_bootstrap_source_mismatch', 'update_bootstrap_source_version_mismatch'].includes(code)) return utx('The installed startup files do not match a supported update source. Check recovery before retrying.', '已安装的启动文件与支持的升级来源不一致,请先检查恢复状态。');
711
+ if (['update_bootstrap_source_mismatch', 'update_bootstrap_source_version_mismatch'].includes(code)) return utx('The installed startup files do not match a supported update source. Restore the previous version in Settings, or reinstall once.', '已安装的启动文件与支持的升级来源不一致。请在设置中恢复到上一版本,或重新安装一次。');
712
712
  if (['update_bootstrap_record_missing', 'update_bootstrap_record_invalid', 'update_bootstrap_backup_mismatch', 'update_bootstrap_already_restoring'].includes(code)) return utx('The startup recovery material could not be confirmed. The update result remains unconfirmed.', '尚未确认启动组件的恢复材料有效,更新结果仍待确认。');
713
713
  if (['update_bootstrap_confirmation_pending', 'update_bootstrap_native_restart_required'].includes(code)) return utx('Waiting for the updated startup components and Native Host to finish restarting and confirming.', '正在等待更新后的启动组件和本机服务完成重启及确认。');
714
714
  if (code === 'update_bootstrap_monitor_unavailable') return utx('The update recovery monitor could not start. Check recovery before retrying.', '更新恢复监护程序未能启动,请先检查恢复状态。');