codex-overleaf-link 2.3.6 → 2.4.1

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 (52) hide show
  1. package/README.md +62 -34
  2. package/README.zh-CN.md +546 -0
  3. package/extension/bootstrap/manifest.template.json +1 -1
  4. package/extension/src/background.js +3 -0
  5. package/extension/src/content/activeTurnControl.js +1 -0
  6. package/extension/src/content/assetTransferBroker.js +2 -0
  7. package/extension/src/content/contentRuntime.js +122 -118
  8. package/extension/src/content/contextTray.js +4 -0
  9. package/extension/src/content/diagnosticsController.js +4 -0
  10. package/extension/src/content/diagnosticsPanel.js +128 -4
  11. package/extension/src/content/generated/content.bundle.js +1233 -550
  12. package/extension/src/content/generated/content.bundle.meta.json +4 -3
  13. package/extension/src/content/legacyGlobalRegistry.js +1 -0
  14. package/extension/src/content/modelPicker.js +21 -9
  15. package/extension/src/content/modelPickerSupport.js +38 -1
  16. package/extension/src/content/nativeCompatibilityController.js +4 -1
  17. package/extension/src/content/otWarmMirror.js +34 -30
  18. package/extension/src/content/otWarmMirrorController.js +20 -0
  19. package/extension/src/content/pageBridgeClient.js +2 -0
  20. package/extension/src/content/panelMaintenance.js +8 -4
  21. package/extension/src/content/recentProjects.js +354 -355
  22. package/extension/src/content/runResultActions.js +16 -1
  23. package/extension/src/content/runTimelineView.js +9 -11
  24. package/extension/src/content/scopedPersistenceCoordinator.js +2 -1
  25. package/extension/src/content/scopedPersistencePanelState.js +45 -2
  26. package/extension/src/content/sessionForkController.js +20 -11
  27. package/extension/src/content/sessionPersistence.js +4 -1
  28. package/extension/src/content/settingsPanel.js +16 -2
  29. package/extension/src/content/trackedChangeCaptureController.js +153 -0
  30. package/extension/src/content/writebackOrchestrator.js +31 -0
  31. package/extension/src/page/binaryAssetUploader.js +101 -29
  32. package/extension/src/page/overleafRealtimeObserver.js +4 -1
  33. package/extension/src/page/saveState.js +44 -1
  34. package/extension/src/page/textFileCreator.js +493 -0
  35. package/extension/src/page/trackedChangeCapture.js +342 -0
  36. package/extension/src/page/trackedChangesLifecycle.js +149 -150
  37. package/extension/src/page/treeOperations.js +40 -29
  38. package/extension/src/page/writebackRouter.js +95 -49
  39. package/extension/src/pageBridge.js +65 -25
  40. package/extension/src/shared/compatibility.js +1 -1
  41. package/extension/src/shared/i18n.js +94 -40
  42. package/extension/src/shared/pageRpcContract.js +2 -1
  43. package/extension/src/shared/sessionState.js +6 -2
  44. package/extension/src/shared/storageDb.js +11 -12
  45. package/extension/src/shared/storageRunActions.js +73 -1
  46. package/extension/src/shared/writebackSettlement.js +19 -5
  47. package/extension/styles/panel.css +254 -405
  48. package/native-host/src/codexSessionRunner.js +41 -38
  49. package/native-host/src/codexSessionTiming.js +68 -0
  50. package/native-host/src/readProgressGuard.js +84 -17
  51. package/native-host/src/taskRunnerRuntime.js +25 -0
  52. package/package.json +5 -1
@@ -51,8 +51,7 @@
51
51
  'codex.providers.delete',
52
52
  'task.run',
53
53
  'mirror.sync',
54
- 'mirror.patchFiles',
55
- 'mirror.confirmWriteback',
54
+ 'mirror.patchFiles', 'mirror.confirmWriteback', 'mirror.invalidate',
56
55
  'mirror.scanSensitive',
57
56
  'codex.history.clearPlugin',
58
57
  'skills.list',
@@ -583,6 +582,7 @@
583
582
  RUN_SNAPSHOT_ZIP_TIMEOUT_MS,
584
583
  getState: () => state,
585
584
  getCurrentRunView: () => currentRunView,
585
+ appendRunRecordEvent,
586
586
  onMirrorRefreshSettled: runSettlementPersistence.settleMirrorRefresh
587
587
  });
588
588
  const {
@@ -790,6 +790,7 @@
790
790
  },
791
791
  saveState,
792
792
  saveStateSoon,
793
+ onProjectFilesReady: refreshHistoricalProjectReferences,
793
794
  updateActiveSession,
794
795
  callPageBridge,
795
796
  getCurrentProjectId,
@@ -1776,9 +1777,7 @@
1776
1777
  return contextTrayController.setContextStatus(text);
1777
1778
  }
1778
1779
 
1779
- function resetContextProject() {
1780
- return contextTrayController.resetContextProject();
1781
- }
1780
+ function resetContextProject() { return contextTrayController.resetContextProject(); }
1782
1781
 
1783
1782
  async function runTask(options = {}) {
1784
1783
  // Barrier on a still-flying background mirror refresh from the previous
@@ -2485,14 +2484,15 @@
2485
2484
  setRunning(false);
2486
2485
  nativeChannel.clearActiveRequest();
2487
2486
  stopRunElapsedTick();
2488
- if (isExperimentalOtEnabled()) {
2489
- await resumeOtWarmMirror('run-settled');
2490
- }
2491
2487
  await flushQueuedSaveState().catch(() => {});
2492
2488
  currentRunView = null;
2493
2489
  runCancellationRequested = false;
2494
2490
  runCancellationController = null;
2495
2491
  activeTurnControl.release(settlement.requestId);
2492
+ if (isExperimentalOtEnabled()) {
2493
+ try { await resumeOtWarmMirror('run-settled'); }
2494
+ catch (_error) { updateOtStatusDisplay('unavailable'); }
2495
+ }
2496
2496
  if (settlement.requestId) {
2497
2497
  activeTurnControl.acknowledge(settlement.requestId).catch(() => {});
2498
2498
  }
@@ -5570,6 +5570,7 @@
5570
5570
  title: session.title || '',
5571
5571
  titleSource: session.titleSource || 'auto',
5572
5572
  focusFiles: session.focusFiles || [],
5573
+ projectReferenceFiles: session.projectReferenceFiles || [],
5573
5574
  codexThreadId: session.codexThreadId || '',
5574
5575
  createdAt: session.createdAt,
5575
5576
  updatedAt: session.updatedAt,
@@ -5665,7 +5666,7 @@
5665
5666
  const urlProjectId = getCurrentProjectId();
5666
5667
  const projectId = projectIdOverride || urlProjectId;
5667
5668
  if (!projectId) {
5668
- return;
5669
+ if (options?.reviewRunIds?.length) throw new Error('Review persistence lost its project.'); return;
5669
5670
  }
5670
5671
  // Read currentRunView through a defensive accessor so historical test
5671
5672
  // harnesses that inline `saveState` without re-declaring the module-
@@ -5677,7 +5678,7 @@
5677
5678
  'Skipped saveState: run is in flight on a different project than the current URL; settlement owns persistence.',
5678
5679
  '已跳过 saveState:本次运行所属项目与当前 URL 不一致,写回由 settlement 负责。'
5679
5680
  ));
5680
- return;
5681
+ if (options?.reviewRunIds?.length) throw new Error('Review persistence project changed.'); return;
5681
5682
  }
5682
5683
  const compactState = prepareStateForStorage(state, { onAggressive: notifyAggressiveCompactionOnce });
5683
5684
  compactState.autoRecompile = state.autoRecompile;
@@ -5698,7 +5699,7 @@
5698
5699
  state: stateSnapshot,
5699
5700
  compactState: compactStateSnapshot,
5700
5701
  projectId,
5701
- deletedSessionIds: options?.deletedSessionIds,
5702
+ deletedSessionIds: options?.deletedSessionIds, reviewRunIds: options?.reviewRunIds,
5702
5703
  Migration,
5703
5704
  StorageDb,
5704
5705
  SessionPersistence: Modules.SessionPersistence,
@@ -5726,7 +5727,7 @@
5726
5727
  persistScopedState
5727
5728
  );
5728
5729
  if (!committed.ok && (
5729
- options?.queueMutation
5730
+ options?.reviewRunIds?.length || options?.queueMutation
5730
5731
  || options?.queueMutations?.length
5731
5732
  || committed.reason !== 'stale_view'
5732
5733
  )) {
@@ -5738,12 +5739,12 @@
5738
5739
  } catch (error) {
5739
5740
  if (
5740
5741
  error?.code === 'account_scope_unavailable' &&
5741
- !options?.queueMutation &&
5742
+ !options?.reviewRunIds?.length && !options?.queueMutation &&
5742
5743
  !(Array.isArray(options?.queueMutations) && options.queueMutations.length)
5743
5744
  ) {
5744
5745
  return;
5745
5746
  }
5746
- if (options?.queueMutation || options?.queueMutations?.length) {
5747
+ if (options?.reviewRunIds?.length || options?.queueMutation || options?.queueMutations?.length) {
5747
5748
  throw error;
5748
5749
  }
5749
5750
  let fallbackError = null;
@@ -5868,6 +5869,7 @@
5868
5869
  // here (e.g. fallback chrome.storage.local.set rethrew).
5869
5870
  emitStorageQuotaFailure(error, null);
5870
5871
  }
5872
+ if (persistenceOptions?.reviewRunIds?.length) throw error;
5871
5873
  appendPlainLog(tx(`Failed to save session state: ${formatStateSaveError(error)}`, `保存会话状态失败:${formatStateSaveError(error)}`));
5872
5874
  })
5873
5875
  .finally(() => {
@@ -5922,6 +5924,7 @@
5922
5924
  ...(current || {}),
5923
5925
  ...incoming,
5924
5926
  queueMutation: undefined,
5927
+ reviewRunIds: Array.from(new Set([...(current?.reviewRunIds || []), ...(incoming?.reviewRunIds || [])])),
5925
5928
  queueMutations,
5926
5929
  deletedSessionIds: Array.from(new Set([
5927
5930
  ...(current?.deletedSessionIds || []),
@@ -5977,15 +5980,7 @@
5977
5980
  }
5978
5981
 
5979
5982
  function getRenderedModelEntries() {
5980
- return Array.from(panel?.querySelector('[data-model]')?.options || []).map(option => ({
5981
- id: option.value,
5982
- label: option.textContent,
5983
- reasoningEfforts: (option.dataset.reasoningEfforts || '').split(',').filter(Boolean),
5984
- defaultReasoningEffort: option.dataset.defaultReasoningEffort || '',
5985
- reasoningPresentation: option.dataset.reasoningPresentation || '',
5986
- speedTiers: (option.dataset.speedTiers || 'standard').split(',').filter(Boolean),
5987
- defaultSpeedTier: option.dataset.defaultSpeedTier || 'standard'
5988
- }));
5983
+ return Modules.ModelPickerSupport.getRenderedModelEntries(panel);
5989
5984
  }
5990
5985
 
5991
5986
  async function selectMode(mode) {
@@ -6123,6 +6118,7 @@
6123
6118
  applySessionLabel();
6124
6119
  renderSessionList();
6125
6120
  renderRunHistory(options);
6121
+ getTrackedChangeCaptureController().schedule();
6126
6122
  renderContextSelection();
6127
6123
  renderContextSummary();
6128
6124
  // v1.8.0 C4: refresh recovery — small attachments persisted in state are
@@ -6212,7 +6208,7 @@
6212
6208
  undoExpectedFiles: [],
6213
6209
  undoStatus: '',
6214
6210
  queueItemId,
6215
- nativeRequestId: '',
6211
+ nativeRequestId: '', codexThreadId: '',
6216
6212
  codexTurnId: '',
6217
6213
  nativeEventSeq: 0,
6218
6214
  executionSnapshot: executionSnapshot
@@ -6379,7 +6375,7 @@
6379
6375
  currentRunView.activeTurn = { threadId, turnId };
6380
6376
  const record = findRunRecord(currentRunView.recordId, currentRunView.sessionId);
6381
6377
  if (record) {
6382
- record.codexTurnId = turnId;
6378
+ record.codexThreadId = threadId; record.codexTurnId = turnId;
6383
6379
  record.nativeEventSeq = Math.max(Number(record.nativeEventSeq || 0), Number(journalSeq || 0));
6384
6380
  }
6385
6381
  if (threadId) {
@@ -6753,6 +6749,13 @@
6753
6749
  return files;
6754
6750
  }
6755
6751
 
6752
+ function refreshHistoricalProjectReferences(project) {
6753
+ if (!project?.id || String(project.id) !== getCurrentProjectId()) return;
6754
+ state = updateActiveSession(state, { projectReferenceFiles: captureProjectReferenceFiles(project) });
6755
+ saveStateSoon();
6756
+ if (!currentRunView && !trackedChangeInFlight.size) renderRunHistory({ preserveScroll: true });
6757
+ }
6758
+
6756
6759
  function persistCurrentProjectReferenceFiles(project) {
6757
6760
  const projectFiles = captureProjectReferenceFiles(project);
6758
6761
  if (currentRunView) {
@@ -6884,6 +6887,7 @@
6884
6887
  runProjectId: getRunProjectIdForWriteback(run)
6885
6888
  });
6886
6889
  const undoApplied = isUndoResultEffectivelyApplied(run, result);
6890
+ writebackOrchestrator.invalidateMirrorAfterUndo(runId, getRunProjectIdForWriteback(run), result);
6887
6891
  appendUndoReviewingPolicyEvent(runId, result.reviewingPolicy);
6888
6892
  appendRunRecordEvent(runId, {
6889
6893
  title: tr('undoResult', { applied: result.applied?.length || 0, skipped: result.skipped?.length || 0 }),
@@ -6945,48 +6949,54 @@
6945
6949
  detail: { [tr('detailWillUndo')]: trackedUndo ? formatTrackedChangeFiles(run.undoTrackedChanges) : formatTrackedUndoFiles(run) }
6946
6950
  });
6947
6951
 
6948
- let result;
6949
6952
  try {
6950
- result = await callPageBridge('rejectTrackedChanges', {
6953
+ const trackedUndoPostFilesAtDispatch = buildTrackedUndoPostFiles(run);
6954
+ const result = await callPageBridge('rejectTrackedChanges', {
6951
6955
  trackedChanges: run.undoTrackedChanges || [],
6952
6956
  expectedFiles: run.undoExpectedFiles || [],
6953
- postFiles: buildTrackedUndoPostFiles(run),
6957
+ postFiles: trackedUndoPostFilesAtDispatch,
6958
+ untrackedUndo: run.executionSnapshot
6959
+ ? run.executionSnapshot.requireReviewing === false && ['submitted', 'legacy-captured'].includes(run.executionSnapshot.source)
6960
+ : getTrackedChangeCaptureController().hasLegacyUntrackedCheckpoint(run),
6954
6961
  // Welcome-panel + write-guard:
6955
6962
  // bind the reject to the run's original project. If the user has
6956
6963
  // navigated away the page-side guard refuses with
6957
6964
  // `aborted_project_changed` and the document is left untouched.
6958
6965
  runProjectId: getRunProjectIdForWriteback(run)
6959
6966
  });
6967
+ writebackOrchestrator.invalidateMirrorAfterUndo(runId, getRunProjectIdForWriteback(run), result);
6968
+ appendRunRecordEvent(runId, {
6969
+ title: trackedUndo
6970
+ ? tr('undoTrackedResult', { applied: result.applied?.length || 0, skipped: result.skipped?.length || 0 })
6971
+ : tr('undoNativeResult', { applied: result.applied?.length || 0, skipped: result.skipped?.length || 0 }),
6972
+ status: result.skipped?.length ? 'failed' : 'completed',
6973
+ detail: {
6974
+ [trackedUndo ? tr('detailRejected') : tr('detailUndone')]: (result.applied || []).map(item => ({
6975
+ [tr('detailFile')]: item.trackedChange?.path || tr('unknownFile'),
6976
+ [tr('detailRecord')]: item.trackedChange?.label || item.trackedChange?.id || item.trackedChange?.key
6977
+ })),
6978
+ [tr('detailSkipped')]: (result.skipped || []).map(item => ({
6979
+ [tr('detailFile')]: item.trackedChange?.path || tr('unknownFile'),
6980
+ [tr('detailRecord')]: item.trackedChange?.label || item.trackedChange?.id || item.trackedChange?.key || '',
6981
+ [tr('detailReason')]: formatBridgeResultReason(item.result, item.trackedChange?.path)
6982
+ }))
6983
+ }
6984
+ });
6985
+ if (lifecycleReject) {
6986
+ WritebackSettlement.attachUndoNotVerifiedFailure(run, result, {
6987
+ buildFailure: buildContentFailure,
6988
+ postFiles: trackedUndoPostFilesAtDispatch
6989
+ });
6990
+ await applyTrackedChangeSettlement(runId, 'reject', result);
6991
+ return;
6992
+ }
6993
+ applyLegacyUndoSettlement(runId, result.skipped?.length ? 'partial' : 'applied', result);
6960
6994
  } finally {
6961
6995
  if (lifecycleReject) {
6962
6996
  trackedChangeInFlight.delete(runId);
6997
+ refreshRunCardControls(runId);
6963
6998
  }
6964
6999
  }
6965
- appendRunRecordEvent(runId, {
6966
- title: trackedUndo
6967
- ? tr('undoTrackedResult', { applied: result.applied?.length || 0, skipped: result.skipped?.length || 0 })
6968
- : tr('undoNativeResult', { applied: result.applied?.length || 0, skipped: result.skipped?.length || 0 }),
6969
- status: result.skipped?.length ? 'failed' : 'completed',
6970
- detail: {
6971
- [trackedUndo ? tr('detailRejected') : tr('detailUndone')]: (result.applied || []).map(item => ({
6972
- [tr('detailFile')]: item.trackedChange?.path || tr('unknownFile'),
6973
- [tr('detailRecord')]: item.trackedChange?.label || item.trackedChange?.id || item.trackedChange?.key
6974
- })),
6975
- [tr('detailSkipped')]: (result.skipped || []).map(item => ({
6976
- [tr('detailFile')]: item.trackedChange?.path || tr('unknownFile'),
6977
- [tr('detailRecord')]: item.trackedChange?.label || item.trackedChange?.id || item.trackedChange?.key || '',
6978
- [tr('detailReason')]: formatBridgeResultReason(item.result, item.trackedChange?.path)
6979
- }))
6980
- }
6981
- });
6982
- if (lifecycleReject) {
6983
- WritebackSettlement.attachUndoNotVerifiedFailure(run, result, {
6984
- buildFailure: buildContentFailure
6985
- });
6986
- applyTrackedChangeSettlement(runId, 'reject', result);
6987
- return;
6988
- }
6989
- applyLegacyUndoSettlement(runId, result.skipped?.length ? 'partial' : 'applied', result);
6990
7000
  }
6991
7001
 
6992
7002
  async function acceptRun(runId) {
@@ -7016,50 +7026,50 @@
7016
7026
  detail: { [tr('detailAccepted')]: formatTrackedChangeFiles(run.undoTrackedChanges) }
7017
7027
  });
7018
7028
 
7019
- let result;
7020
7029
  try {
7021
- result = await callPageBridge('acceptTrackedChanges', {
7030
+ const result = await callPageBridge('acceptTrackedChanges', {
7022
7031
  trackedChanges: run.undoTrackedChanges || [],
7023
7032
  expectedFiles: run.undoExpectedFiles || [],
7024
7033
  postFiles: buildTrackedUndoPostFiles(run),
7025
7034
  appliedOperations: Array.isArray(run.appliedOperations) ? run.appliedOperations : [],
7026
7035
  runProjectId: getRunProjectIdForWriteback(run)
7027
7036
  });
7028
- } finally {
7029
- trackedChangeInFlight.delete(runId);
7030
- }
7031
- appendAcceptDiagnosticEvents(runId, Array.isArray(result.diagnostics) ? result.diagnostics : []);
7032
- appendRunRecordEvent(runId, {
7033
- title: tr('runAcceptTrackedResult', { applied: result.applied?.length || 0, skipped: result.skipped?.length || 0 }),
7034
- status: result.skipped?.length ? 'failed' : 'completed',
7035
- detail: {
7036
- [tr('detailAccepted')]: (result.applied || []).map(item => ({
7037
- [tr('detailFile')]: item.trackedChange?.path || tr('unknownFile'),
7038
- [tr('detailRecord')]: item.trackedChange?.label || item.trackedChange?.id || item.trackedChange?.key
7039
- })),
7040
- [tr('detailSkipped')]: (result.skipped || []).map(item => ({
7041
- [tr('detailFile')]: item.trackedChange?.path || tr('unknownFile'),
7042
- [tr('detailRecord')]: item.trackedChange?.label || item.trackedChange?.id || item.trackedChange?.key || '',
7043
- [tr('detailReason')]: formatBridgeResultReason(item.result, item.trackedChange?.path)
7044
- }))
7045
- }
7046
- });
7047
- if (result.ok === false) {
7037
+ appendAcceptDiagnosticEvents(runId, Array.isArray(result.diagnostics) ? result.diagnostics : []);
7048
7038
  appendRunRecordEvent(runId, {
7049
- title: tr('runAcceptTrackedFailed'),
7050
- status: 'failed',
7039
+ title: tr('runAcceptTrackedResult', { applied: result.applied?.length || 0, skipped: result.skipped?.length || 0 }),
7040
+ status: result.skipped?.length ? 'failed' : 'completed',
7051
7041
  detail: {
7052
- [tr('detailReason')]: tr('runAcceptTrackedFailedReason')
7042
+ [tr('detailAccepted')]: (result.applied || []).map(item => ({
7043
+ [tr('detailFile')]: item.trackedChange?.path || tr('unknownFile'),
7044
+ [tr('detailRecord')]: item.trackedChange?.label || item.trackedChange?.id || item.trackedChange?.key
7045
+ })),
7046
+ [tr('detailSkipped')]: (result.skipped || []).map(item => ({
7047
+ [tr('detailFile')]: item.trackedChange?.path || tr('unknownFile'),
7048
+ [tr('detailRecord')]: item.trackedChange?.label || item.trackedChange?.id || item.trackedChange?.key || '',
7049
+ [tr('detailReason')]: formatBridgeResultReason(item.result, item.trackedChange?.path)
7050
+ }))
7053
7051
  }
7054
7052
  });
7055
- if (!WritebackSettlement.isSuccessfulTrackedChangeSettlement(result)) {
7056
- refreshRunCardControls(runId);
7053
+ if (result.ok === false) {
7054
+ appendRunRecordEvent(runId, {
7055
+ title: tr('runAcceptTrackedFailed'),
7056
+ status: 'failed',
7057
+ detail: {
7058
+ [tr('detailReason')]: tr('runAcceptTrackedFailedReason')
7059
+ }
7060
+ });
7061
+ if (!WritebackSettlement.isSuccessfulTrackedChangeSettlement(result)) {
7062
+ refreshRunCardControls(runId);
7063
+ }
7057
7064
  }
7065
+ WritebackSettlement.attachAcceptNotVerifiedFailure(run, result, {
7066
+ buildFailure: buildContentFailure
7067
+ });
7068
+ await applyTrackedChangeSettlement(runId, 'accept', result);
7069
+ } finally {
7070
+ trackedChangeInFlight.delete(runId);
7071
+ refreshRunCardControls(runId);
7058
7072
  }
7059
- WritebackSettlement.attachAcceptNotVerifiedFailure(run, result, {
7060
- buildFailure: buildContentFailure
7061
- });
7062
- applyTrackedChangeSettlement(runId, 'accept', result);
7063
7073
  }
7064
7074
 
7065
7075
  function getRunProjectIdForWriteback(run) {
@@ -7103,7 +7113,7 @@
7103
7113
  );
7104
7114
  }
7105
7115
 
7106
- function applyTrackedChangeSettlement(runId, kind, result) {
7116
+ async function applyTrackedChangeSettlement(runId, kind, result) {
7107
7117
  const run = findRunRecord(runId);
7108
7118
  const settlement = WritebackSettlement.settleTrackedChangeLifecycle({
7109
7119
  kind,
@@ -7112,12 +7122,12 @@
7112
7122
  failureReasons: FailureReasons
7113
7123
  });
7114
7124
  if (settlement.decision === 'blocked') {
7115
- refreshRunCardControls(runId);
7116
7125
  return;
7117
7126
  }
7118
- Object.assign(run, WritebackSettlement.applySettlementTransition(run, settlement));
7119
- saveStateSoon();
7120
- refreshRunCardControls(runId);
7127
+ await Modules.ScopedPersistenceCoordinator.applyReviewTransition({
7128
+ getState: () => state, runId, transition: WritebackSettlement.applySettlementTransition(run, settlement),
7129
+ persist: flushQueuedSaveState
7130
+ });
7121
7131
  }
7122
7132
 
7123
7133
  function getRunUndoCount(run) {
@@ -7195,6 +7205,25 @@
7195
7205
  return WritebackSettlement.attachVerifiedContentToOperation(operation, result);
7196
7206
  }
7197
7207
 
7208
+ let trackedChangeCaptureController = null;
7209
+ function getTrackedChangeCaptureController() {
7210
+ return trackedChangeCaptureController ||= Modules.TrackedChangeCaptureController.create({
7211
+ getRuns: () => (state.sessions || []).flatMap(session => session.runs || []),
7212
+ getProjectId: () => window.location.pathname.match(/^\/project\/([^/]+)/)?.[1] || '',
7213
+ buildExpectedFilesAfterOperations, normalizeTrackedChanges: normalizeApplyTrackedChanges, callPageBridge,
7214
+ persist: () => flushQueuedSaveState({ preserveRunActionPayload: true }),
7215
+ refresh: run => refreshRunCardControls(run.id),
7216
+ notify: (run, kind, detail) => appendRunRecordEvent(run.id, {
7217
+ title: {
7218
+ pending: tx('Files written; confirming tracked changes.', '文件已写入,正在确认留痕记录。'),
7219
+ observed: tx('Tracked changes confirmed; Accept is available.', '留痕记录已确认,可以接受改动。'),
7220
+ needs_review: tx('Tracked changes could not be safely attributed. Review them in Overleaf; Undo is preserved.', '留痕归属尚未确认,请在 Overleaf 审阅;原有撤销能力已保留。')
7221
+ }[kind], status: 'info', detail
7222
+ }),
7223
+ onError: error => appendPlainLog(tx('Tracked-change confirmation unavailable: ', '留痕确认暂不可用:') + error.message)
7224
+ });
7225
+ }
7226
+
7198
7227
  function recordUndoFromApply(project, applyResult) {
7199
7228
  const appliedEntries = getAppliedEntries(applyResult);
7200
7229
  if (!currentRunView?.recordId || !appliedEntries.length) {
@@ -7238,6 +7267,7 @@
7238
7267
  if (combinedTrackedChanges.length) {
7239
7268
  record.trackedChangeStatus = 'pending';
7240
7269
  }
7270
+ getTrackedChangeCaptureController().record(record, applyResult?.trackedChangeCaptures);
7241
7271
  refreshRunCardControls(record.id);
7242
7272
  if (combinedTrackedChanges.length) {
7243
7273
  appendRunEvent({
@@ -7302,38 +7332,12 @@
7302
7332
  return WritebackSettlement.normalizeApplyTrackedChanges(changes);
7303
7333
  }
7304
7334
 
7305
- function selectExpectedFilesForTrackedUndo(
7306
- project,
7307
- operations = [],
7308
- trackedChanges = [],
7309
- previousExpectedFiles = []
7310
- ) {
7311
- return WritebackSettlement.selectExpectedFilesForTrackedUndo(
7312
- project,
7313
- operations,
7314
- trackedChanges,
7315
- previousExpectedFiles
7316
- );
7335
+ function selectExpectedFilesForTrackedUndo(project, operations = [], trackedChanges = [], previousExpectedFiles = []) {
7336
+ return WritebackSettlement.selectExpectedFilesForTrackedUndo(project, operations, trackedChanges, previousExpectedFiles);
7317
7337
  }
7318
7338
 
7319
7339
  function buildTrackedUndoPostFiles(run) {
7320
- const expectedFiles = Array.isArray(run?.undoExpectedFiles) ? run.undoExpectedFiles : [];
7321
- const appliedOperations = Array.isArray(run?.appliedOperations) ? run.appliedOperations : [];
7322
- if (!expectedFiles.length || !appliedOperations.length) {
7323
- return [];
7324
- }
7325
-
7326
- const postFilesByPath = buildExpectedFilesAfterOperations(
7327
- { files: expectedFiles },
7328
- appliedOperations
7329
- );
7330
- const expectedPaths = new Set(expectedFiles.map(file => file?.path).filter(Boolean));
7331
- return Array.from(postFilesByPath.entries())
7332
- .filter(([path, content]) => expectedPaths.has(path) && typeof content === 'string')
7333
- .map(([path, content]) => ({
7334
- path,
7335
- content
7336
- }));
7340
+ return getTrackedChangeCaptureController().buildPostFiles(run);
7337
7341
  }
7338
7342
 
7339
7343
  function hasTrackedEditorUndo(run) {
@@ -22,6 +22,7 @@
22
22
  setState = () => {},
23
23
  saveState = null,
24
24
  saveStateSoon = () => {},
25
+ onProjectFilesReady = () => {},
25
26
  updateActiveSession = defaultUpdateActiveSession,
26
27
  callPageBridge = () => Promise.resolve({ ok: false }),
27
28
  getCurrentProjectId = () => '',
@@ -201,6 +202,7 @@
201
202
  renderContextFiles(contextProject);
202
203
  if (isExactContextFileListProject(contextProject)) {
203
204
  setContextSyncState('exact-ready');
205
+ onProjectFilesReady(contextProject);
204
206
  } else if (contextSyncState !== 'partial') {
205
207
  setContextSyncState('exact-loading');
206
208
  void enhanceContextFilesFromExactSnapshot({ loadId: contextLoadId });
@@ -224,6 +226,7 @@
224
226
  contextProject = project;
225
227
  setContextSyncState(isExactContextFileListProject(project) ? 'exact-ready' : 'exact-loading');
226
228
  renderContextFiles(project);
229
+ if (isExactContextFileListProject(project)) onProjectFilesReady(project);
227
230
  if (!isExactContextFileListProject(project)) {
228
231
  void enhanceContextFilesFromExactSnapshot({
229
232
  force: Boolean(options.force),
@@ -292,6 +295,7 @@
292
295
  contextProject = project;
293
296
  setContextSyncState('exact-ready');
294
297
  renderContextFiles(project);
298
+ onProjectFilesReady(project);
295
299
  } catch (_error) {
296
300
  // The page file tree remains usable when the optional exact ZIP list is unavailable.
297
301
  if (loadId === contextLoadId) {
@@ -641,6 +641,10 @@
641
641
  lines.push(`CodeMirror: docLength=${view.docLength || 0}; dispatch=${Boolean(view.hasDispatch)}; source=${view.source || 'unknown'}`);
642
642
  }
643
643
  }
644
+ const capture = probe?.trackedChangeCapture;
645
+ if (capture) lines.push(tx('Tracked ledger: ', '留痕记录:')
646
+ + 'source=' + capture.source + '; ready=' + capture.ready + '; count=' + capture.count
647
+ + '; doc=' + (capture.docId || 'none') + '; adapter=' + capture.adapter + '; reason=' + (capture.reason || 'none'));
644
648
  const projectDiagnostics = probe?.projectDiagnostics;
645
649
  if (projectDiagnostics) {
646
650
  lines.push(`Project records: ${projectDiagnostics.docRecordCount || 0}; roots=${(projectDiagnostics.internalRootKeys || []).slice(0, 6).join(', ') || 'none'}`);
@@ -10,7 +10,8 @@
10
10
  container,
11
11
  callbacks: options.callbacks || {},
12
12
  i18n: options.i18n || {},
13
- dismissInstalled: false
13
+ dismissInstalled: false,
14
+ cleanup: []
14
15
  };
15
16
 
16
17
  container.innerHTML = `
@@ -99,6 +100,11 @@
99
100
  instance.callbacks.onBeforeOpen?.();
100
101
  }
101
102
  popover.hidden = !open;
103
+ if (open) {
104
+ openFloating(instance, popover, 300);
105
+ } else {
106
+ hideFloating(popover);
107
+ }
102
108
  button.dataset.active = open ? 'true' : 'false';
103
109
  button.setAttribute('aria-expanded', open ? 'true' : 'false');
104
110
  }
@@ -110,7 +116,7 @@
110
116
  if (!popover || !button) {
111
117
  return;
112
118
  }
113
- popover.hidden = true;
119
+ hideFloating(popover);
114
120
  button.dataset.active = 'false';
115
121
  button.setAttribute('aria-expanded', 'false');
116
122
  }
@@ -119,7 +125,7 @@
119
125
  const instance = target?._instance || target;
120
126
  const result = instance?.container?.querySelector('[data-diagnostics-result]');
121
127
  if (result) {
122
- result.hidden = true;
128
+ hideFloating(result);
123
129
  }
124
130
  }
125
131
 
@@ -181,6 +187,73 @@
181
187
  details.open = false;
182
188
  details.hidden = !technicalText;
183
189
  technical.textContent = technicalText;
190
+ closeMenu(instance);
191
+ openFloating(instance, root, 560);
192
+ }
193
+
194
+ // Keep DOM ownership (theme, locale and callbacks), but render above clipping
195
+ // ancestors where the browser supports the top layer. Fixed positioning is
196
+ // the viewport-based fallback for browsers without the Popover API.
197
+ function openFloating(instance, element, preferredWidth) {
198
+ if (element.isConnected === false) return;
199
+ element.hidden = false;
200
+ if (typeof element.showPopover === 'function') {
201
+ element.setAttribute('popover', 'manual');
202
+ try {
203
+ if (!element.matches(':popover-open')) element.showPopover();
204
+ } catch (_error) {
205
+ element.removeAttribute('popover');
206
+ }
207
+ }
208
+ positionFloating(instance, element, preferredWidth);
209
+ }
210
+
211
+ function hideFloating(element) {
212
+ if (!element) return;
213
+ if (typeof element.hidePopover === 'function' && element.getAttribute('popover') === 'manual') {
214
+ try {
215
+ if (element.matches(':popover-open')) element.hidePopover();
216
+ } catch (_error) {
217
+ element.removeAttribute('popover');
218
+ }
219
+ }
220
+ element.hidden = true;
221
+ }
222
+
223
+ function positionFloating(instance, element, preferredWidth) {
224
+ if (!element || element.hidden) return;
225
+ const button = instance.container.querySelector('[data-diagnostics-menu]');
226
+ if (!button?.getBoundingClientRect || !element.getBoundingClientRect) return;
227
+ if (button.getClientRects && !button.getClientRects().length) {
228
+ closeMenu(instance);
229
+ closeResult(instance);
230
+ return;
231
+ }
232
+ const doc = instance.container.ownerDocument || document;
233
+ const view = doc.defaultView || window;
234
+ const viewport = view.visualViewport;
235
+ const width = viewport?.width || view.innerWidth || doc.documentElement?.clientWidth;
236
+ const height = viewport?.height || view.innerHeight || doc.documentElement?.clientHeight;
237
+ if (!(width > 0 && height > 0)) return;
238
+ const margin = 12;
239
+ const gap = 8;
240
+ const leftEdge = (viewport?.offsetLeft || 0) + margin;
241
+ const topEdge = (viewport?.offsetTop || 0) + margin;
242
+ const rightEdge = leftEdge + Math.max(0, width - margin * 2);
243
+ const bottomEdge = topEdge + Math.max(0, height - margin * 2);
244
+ element.style.width = `${Math.min(preferredWidth, Math.max(0, width - margin * 2))}px`;
245
+ element.style.maxHeight = `${Math.min(640, Math.max(0, height - margin * 2))}px`;
246
+ const anchor = button.getBoundingClientRect();
247
+ const bounds = element.getBoundingClientRect();
248
+ const below = Math.max(0, bottomEdge - anchor.bottom - gap);
249
+ const above = Math.max(0, anchor.top - topEdge - gap);
250
+ const desiredTop = bounds.height > below && above > below
251
+ ? anchor.top - gap - bounds.height
252
+ : anchor.bottom + gap;
253
+ element.style.left = `${Math.max(leftEdge, Math.min(anchor.right - bounds.width, rightEdge - bounds.width))}px`;
254
+ element.style.top = `${Math.max(topEdge, Math.min(desiredTop, bottomEdge - bounds.height))}px`;
255
+ element.style.right = 'auto';
256
+ element.style.bottom = 'auto';
184
257
  }
185
258
 
186
259
  function renderInstallCommand(instance, container, command) {
@@ -272,13 +345,60 @@
272
345
  return;
273
346
  }
274
347
  instance.dismissInstalled = true;
275
- document.addEventListener('click', event => {
348
+ const doc = instance.container.ownerDocument || document;
349
+ const view = doc.defaultView || window;
350
+ const listen = (target, type, handler, options) => {
351
+ if (!target?.addEventListener) return;
352
+ target.addEventListener(type, handler, options);
353
+ instance.cleanup.push(() => target.removeEventListener(type, handler, options));
354
+ };
355
+ listen(doc, 'click', event => {
276
356
  const wrap = instance.container?.querySelector('.codex-diagnostics-wrap');
277
357
  if (!wrap || wrap.contains(event.target)) {
278
358
  return;
279
359
  }
280
360
  closeMenu(instance);
281
361
  }, true);
362
+ listen(doc, 'keydown', event => {
363
+ if (event.key !== 'Escape') return;
364
+ const menu = instance.container.querySelector('[data-diagnostics-popover]');
365
+ const result = instance.container.querySelector('[data-diagnostics-result]');
366
+ if (menu && !menu.hidden) closeMenu(instance);
367
+ else if (result && !result.hidden) closeResult(instance);
368
+ else return;
369
+ event.preventDefault();
370
+ event.stopPropagation();
371
+ instance.container.querySelector('[data-diagnostics-menu]')?.focus();
372
+ }, true);
373
+ let frame = null;
374
+ const reposition = () => {
375
+ frame = null;
376
+ positionFloating(instance, instance.container.querySelector('[data-diagnostics-popover]'), 300);
377
+ positionFloating(instance, instance.container.querySelector('[data-diagnostics-result]'), 560);
378
+ };
379
+ const schedule = () => {
380
+ if (frame !== null) return;
381
+ if (view.requestAnimationFrame) frame = view.requestAnimationFrame(reposition);
382
+ else reposition();
383
+ };
384
+ listen(view, 'resize', schedule);
385
+ listen(doc, 'scroll', schedule, true);
386
+ listen(view.visualViewport, 'resize', schedule);
387
+ listen(view.visualViewport, 'scroll', schedule);
388
+ if (typeof view.ResizeObserver === 'function') {
389
+ const observer = new view.ResizeObserver(schedule);
390
+ for (const node of [
391
+ instance.container.closest('#codex-overleaf-panel') || instance.container,
392
+ instance.container.querySelector('[data-diagnostics-popover]'),
393
+ instance.container.querySelector('[data-diagnostics-result]')
394
+ ]) {
395
+ if (node) observer.observe(node);
396
+ }
397
+ instance.cleanup.push(() => observer.disconnect());
398
+ }
399
+ instance.cleanup.push(() => {
400
+ if (frame !== null) view.cancelAnimationFrame?.(frame);
401
+ });
282
402
  }
283
403
 
284
404
  // Drive the trigger's health dot. `health` is one of ok / warn / fail /
@@ -450,6 +570,10 @@
450
570
  }
451
571
 
452
572
  function destroy(instance) {
573
+ closeMenu(instance);
574
+ closeResult(instance);
575
+ for (const cleanup of instance.cleanup || []) cleanup();
576
+ instance.cleanup = [];
453
577
  instance.container.textContent = '';
454
578
  }
455
579